diff options
Diffstat (limited to 'runtime/lua')
-rw-r--r-- | runtime/lua/vim/F.lua | 13 | ||||
-rw-r--r-- | runtime/lua/vim/_meta.lua | 1 | ||||
-rw-r--r-- | runtime/lua/vim/diagnostic.lua | 1381 | ||||
-rw-r--r-- | runtime/lua/vim/highlight.lua | 20 | ||||
-rw-r--r-- | runtime/lua/vim/lsp.lua | 392 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/_snippet.lua | 399 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/buf.lua | 281 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/codelens.lua | 57 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/diagnostic.lua | 1448 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/handlers.lua | 287 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/health.lua | 27 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/log.lua | 46 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/protocol.lua | 24 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/rpc.lua | 168 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/util.lua | 674 | ||||
-rw-r--r-- | runtime/lua/vim/shared.lua | 157 | ||||
-rw-r--r-- | runtime/lua/vim/treesitter.lua | 23 | ||||
-rw-r--r-- | runtime/lua/vim/treesitter/highlighter.lua | 20 | ||||
-rw-r--r-- | runtime/lua/vim/treesitter/language.lua | 8 | ||||
-rw-r--r-- | runtime/lua/vim/treesitter/languagetree.lua | 34 | ||||
-rw-r--r-- | runtime/lua/vim/treesitter/query.lua | 71 | ||||
-rw-r--r-- | runtime/lua/vim/ui.lua | 36 | ||||
-rw-r--r-- | runtime/lua/vim/uri.lua | 32 |
23 files changed, 3577 insertions, 2022 deletions
diff --git a/runtime/lua/vim/F.lua b/runtime/lua/vim/F.lua index 7925ff6e44..1a258546a5 100644 --- a/runtime/lua/vim/F.lua +++ b/runtime/lua/vim/F.lua @@ -2,8 +2,8 @@ local F = {} --- Returns {a} if it is not nil, otherwise returns {b}. --- ---@param a ---@param b +---@param a +---@param b function F.if_nil(a, b) if a == nil then return b end return a @@ -27,5 +27,14 @@ function F.nil_wrap(fn) end end +--- like {...} except preserve the lenght explicitly +function F.pack_len(...) + return {n=select('#', ...), ...} +end + +--- like unpack() but use the length set by F.pack_len if present +function F.unpack_len(t) + return unpack(t, 1, t.n) +end return F diff --git a/runtime/lua/vim/_meta.lua b/runtime/lua/vim/_meta.lua index b1f935541c..f7d47c1030 100644 --- a/runtime/lua/vim/_meta.lua +++ b/runtime/lua/vim/_meta.lua @@ -494,7 +494,6 @@ local convert_value_to_lua = (function() for _, key_value_str in ipairs(comma_split) do local key, value = unpack(vim.split(key_value_str, ":")) key = vim.trim(key) - value = vim.trim(value) result[key] = value end diff --git a/runtime/lua/vim/diagnostic.lua b/runtime/lua/vim/diagnostic.lua new file mode 100644 index 0000000000..00945a7fb3 --- /dev/null +++ b/runtime/lua/vim/diagnostic.lua @@ -0,0 +1,1381 @@ +local M = {} + +M.severity = { + ERROR = 1, + WARN = 2, + INFO = 3, + HINT = 4, +} + +vim.tbl_add_reverse_lookup(M.severity) + +-- Mappings from qflist/loclist error types to severities +M.severity.E = M.severity.ERROR +M.severity.W = M.severity.WARN +M.severity.I = M.severity.INFO +M.severity.N = M.severity.HINT + +local global_diagnostic_options = { + signs = true, + underline = true, + virtual_text = true, + update_in_insert = false, + severity_sort = false, +} + +-- Local functions {{{ + +---@private +local function to_severity(severity) + return type(severity) == 'string' and M.severity[string.upper(severity)] or severity +end + +---@private +local function filter_by_severity(severity, diagnostics) + if not severity then + return diagnostics + end + + if type(severity) ~= "table" then + severity = to_severity(severity) + return vim.tbl_filter(function(t) return t.severity == severity end, diagnostics) + end + + local min_severity = to_severity(severity.min) or M.severity.HINT + local max_severity = to_severity(severity.max) or M.severity.ERROR + + return vim.tbl_filter(function(t) return t.severity <= min_severity and t.severity >= max_severity end, diagnostics) +end + +---@private +local function prefix_source(source, diagnostics) + vim.validate { source = {source, function(v) + return v == "always" or v == "if_many" + end, "Invalid value for option 'source'" } } + + if source == "if_many" then + local sources = {} + for _, d in pairs(diagnostics) do + if d.source then + sources[d.source] = true + end + end + if #vim.tbl_keys(sources) <= 1 then + return diagnostics + end + end + + return vim.tbl_map(function(d) + if not d.source then + return d + end + + local t = vim.deepcopy(d) + t.message = string.format("%s: %s", d.source, d.message) + return t + end, diagnostics) +end + +---@private +local function reformat_diagnostics(format, diagnostics) + vim.validate { + format = {format, 'f'}, + diagnostics = {diagnostics, 't'}, + } + + local formatted = vim.deepcopy(diagnostics) + for _, diagnostic in ipairs(formatted) do + diagnostic.message = format(diagnostic) + end + return formatted +end + +---@private +local function resolve_optional_value(option, namespace, bufnr) + local enabled_val = {} + + if not option then + return false + elseif option == true then + return enabled_val + elseif type(option) == 'function' then + local val = option(namespace, bufnr) + if val == true then + return enabled_val + else + return val + end + elseif type(option) == 'table' then + return option + else + error("Unexpected option type: " .. vim.inspect(option)) + end +end + +local all_namespaces = {} + +---@private +local function get_namespace(ns) + if not all_namespaces[ns] then + local name + for k, v in pairs(vim.api.nvim_get_namespaces()) do + if ns == v then + name = k + break + end + end + + if not name then + return vim.notify("namespace does not exist or is anonymous", vim.log.levels.ERROR) + end + + all_namespaces[ns] = { + name = name, + sign_group = string.format("vim.diagnostic.%s", name), + opts = {} + } + end + return all_namespaces[ns] +end + +---@private +local function get_resolved_options(opts, namespace, bufnr) + local ns = get_namespace(namespace) + local resolved = vim.tbl_extend('keep', opts or {}, ns.opts, global_diagnostic_options) + for k in pairs(global_diagnostic_options) do + if resolved[k] ~= nil then + resolved[k] = resolve_optional_value(resolved[k], namespace, bufnr) + end + end + return resolved +end + +-- Default diagnostic highlights +local diagnostic_severities = { + [M.severity.ERROR] = { ctermfg = 1, guifg = "Red" }; + [M.severity.WARN] = { ctermfg = 3, guifg = "Orange" }; + [M.severity.INFO] = { ctermfg = 4, guifg = "LightBlue" }; + [M.severity.HINT] = { ctermfg = 7, guifg = "LightGrey" }; +} + +-- Make a map from DiagnosticSeverity -> Highlight Name +---@private +local function make_highlight_map(base_name) + local result = {} + for k in pairs(diagnostic_severities) do + local name = M.severity[k] + name = name:sub(1, 1) .. name:sub(2):lower() + result[k] = "Diagnostic" .. base_name .. name + end + + return result +end + +local virtual_text_highlight_map = make_highlight_map("VirtualText") +local underline_highlight_map = make_highlight_map("Underline") +local floating_highlight_map = make_highlight_map("Floating") +local sign_highlight_map = make_highlight_map("Sign") + +---@private +local define_default_signs = (function() + local signs_defined = false + return function() + if signs_defined then + return + end + + for severity, sign_hl_name in pairs(sign_highlight_map) do + if vim.tbl_isempty(vim.fn.sign_getdefined(sign_hl_name)) then + local severity_name = M.severity[severity] + vim.fn.sign_define(sign_hl_name, { + text = (severity_name or 'U'):sub(1, 1), + texthl = sign_hl_name, + linehl = '', + numhl = '', + }) + end + end + + signs_defined = true + end +end)() + +---@private +local function get_bufnr(bufnr) + if not bufnr or bufnr == 0 then + return vim.api.nvim_get_current_buf() + end + return bufnr +end + +-- Metatable that automatically creates an empty table when assigning to a missing key +local bufnr_and_namespace_cacher_mt = { + __index = function(t, bufnr) + if not bufnr or bufnr == 0 then + bufnr = vim.api.nvim_get_current_buf() + end + + if rawget(t, bufnr) == nil then + rawset(t, bufnr, {}) + end + + return rawget(t, bufnr) + end, + + __newindex = function(t, bufnr, v) + if not bufnr or bufnr == 0 then + bufnr = vim.api.nvim_get_current_buf() + end + + rawset(t, bufnr, v) + end, +} + +local diagnostic_cleanup = setmetatable({}, bufnr_and_namespace_cacher_mt) +local diagnostic_cache = setmetatable({}, bufnr_and_namespace_cacher_mt) +local diagnostic_cache_extmarks = setmetatable({}, bufnr_and_namespace_cacher_mt) +local diagnostic_attached_buffers = {} +local diagnostic_disabled = {} +local bufs_waiting_to_update = setmetatable({}, bufnr_and_namespace_cacher_mt) + +---@private +local function is_disabled(namespace, bufnr) + if type(diagnostic_disabled[bufnr]) == "table" then + return diagnostic_disabled[bufnr][namespace] + end + return diagnostic_disabled[bufnr] +end + +---@private +local function diagnostic_lines(diagnostics) + if not diagnostics then + return {} + end + + local diagnostics_by_line = {} + for _, diagnostic in ipairs(diagnostics) do + local line_diagnostics = diagnostics_by_line[diagnostic.lnum] + if not line_diagnostics then + line_diagnostics = {} + diagnostics_by_line[diagnostic.lnum] = line_diagnostics + end + table.insert(line_diagnostics, diagnostic) + end + return diagnostics_by_line +end + +---@private +local function set_diagnostic_cache(namespace, bufnr, diagnostics) + for _, diagnostic in ipairs(diagnostics) do + diagnostic.severity = diagnostic.severity and to_severity(diagnostic.severity) or M.severity.ERROR + diagnostic.end_lnum = diagnostic.end_lnum or diagnostic.lnum + diagnostic.end_col = diagnostic.end_col or diagnostic.col + diagnostic.namespace = namespace + diagnostic.bufnr = bufnr + end + diagnostic_cache[bufnr][namespace] = diagnostics +end + +---@private +local function clear_diagnostic_cache(namespace, bufnr) + diagnostic_cache[bufnr][namespace] = nil +end + +---@private +local function restore_extmarks(bufnr, last) + for ns, extmarks in pairs(diagnostic_cache_extmarks[bufnr]) do + local extmarks_current = vim.api.nvim_buf_get_extmarks(bufnr, ns, 0, -1, {details = true}) + local found = {} + for _, extmark in ipairs(extmarks_current) do + -- nvim_buf_set_lines will move any extmark to the line after the last + -- nvim_buf_set_text will move any extmark to the last line + if extmark[2] ~= last + 1 then + found[extmark[1]] = true + end + end + for _, extmark in ipairs(extmarks) do + if not found[extmark[1]] then + local opts = extmark[4] + opts.id = extmark[1] + -- HACK: end_row should be end_line + if opts.end_row then + opts.end_line = opts.end_row + opts.end_row = nil + end + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, extmark[2], extmark[3], opts) + end + end + end +end + +---@private +local function save_extmarks(namespace, bufnr) + bufnr = bufnr == 0 and vim.api.nvim_get_current_buf() or bufnr + if not diagnostic_attached_buffers[bufnr] then + vim.api.nvim_buf_attach(bufnr, false, { + on_lines = function(_, _, _, _, _, last) + restore_extmarks(bufnr, last - 1) + end, + on_detach = function() + diagnostic_cache_extmarks[bufnr] = nil + end}) + diagnostic_attached_buffers[bufnr] = true + end + diagnostic_cache_extmarks[bufnr][namespace] = vim.api.nvim_buf_get_extmarks(bufnr, namespace, 0, -1, {details = true}) +end + +local registered_autocmds = {} + +---@private +local function make_augroup_key(namespace, bufnr) + local ns = get_namespace(namespace) + return string.format("DiagnosticInsertLeave:%s:%s", bufnr, ns.name) +end + +--- Table of autocmd events to fire the update for displaying new diagnostic information +local insert_leave_auto_cmds = { "InsertLeave", "CursorHoldI" } + +---@private +local function schedule_display(namespace, bufnr, args) + bufs_waiting_to_update[bufnr][namespace] = args + + local key = make_augroup_key(namespace, bufnr) + if not registered_autocmds[key] then + vim.cmd(string.format("augroup %s", key)) + vim.cmd(" au!") + vim.cmd( + string.format( + [[autocmd %s <buffer=%s> lua vim.diagnostic._execute_scheduled_display(%s, %s)]], + table.concat(insert_leave_auto_cmds, ","), + bufnr, + namespace, + bufnr + ) + ) + vim.cmd("augroup END") + + registered_autocmds[key] = true + end +end + +---@private +local function clear_scheduled_display(namespace, bufnr) + local key = make_augroup_key(namespace, bufnr) + + if registered_autocmds[key] then + vim.cmd(string.format("augroup %s", key)) + vim.cmd(" au!") + vim.cmd("augroup END") + + registered_autocmds[key] = nil + end +end + +---@private +--- Open a floating window with the provided diagnostics +---@param opts table Configuration table +--- - show_header (boolean, default true): Show "Diagnostics:" header +--- - all opts for |vim.util.open_floating_preview()| can be used here +---@param diagnostics table: The diagnostics to display +---@return table {popup_bufnr, win_id} +local function show_diagnostics(opts, diagnostics) + if not diagnostics or vim.tbl_isempty(diagnostics) then + return + end + local lines = {} + local highlights = {} + local show_header = vim.F.if_nil(opts.show_header, true) + if show_header then + table.insert(lines, "Diagnostics:") + table.insert(highlights, {0, "Bold"}) + end + + if opts.format then + diagnostics = reformat_diagnostics(opts.format, diagnostics) + end + + if opts.source then + diagnostics = prefix_source(opts.source, diagnostics) + end + + for i, diagnostic in ipairs(diagnostics) do + local prefix = string.format("%d. ", i) + local hiname = floating_highlight_map[diagnostic.severity] + assert(hiname, 'unknown severity: ' .. tostring(diagnostic.severity)) + + local message_lines = vim.split(diagnostic.message, '\n', true) + table.insert(lines, prefix..message_lines[1]) + table.insert(highlights, {#prefix, hiname}) + for j = 2, #message_lines do + table.insert(lines, string.rep(' ', #prefix) .. message_lines[j]) + table.insert(highlights, {0, hiname}) + end + end + + local popup_bufnr, winnr = require('vim.lsp.util').open_floating_preview(lines, 'plaintext', opts) + for i, hi in ipairs(highlights) do + local prefixlen, hiname = unpack(hi) + -- Start highlight after the prefix + vim.api.nvim_buf_add_highlight(popup_bufnr, -1, hiname, i-1, prefixlen, -1) + end + + return popup_bufnr, winnr +end + +---@private +local function set_list(loclist, opts) + opts = opts or {} + local open = vim.F.if_nil(opts.open, true) + local title = opts.title or "Diagnostics" + local winnr = opts.winnr or 0 + local bufnr + if loclist then + bufnr = vim.api.nvim_win_get_buf(winnr) + end + local diagnostics = M.get(bufnr, opts) + local items = M.toqflist(diagnostics) + if loclist then + vim.fn.setloclist(winnr, {}, ' ', { title = title, items = items }) + else + vim.fn.setqflist({}, ' ', { title = title, items = items }) + end + if open then + vim.api.nvim_command(loclist and "lopen" or "copen") + end +end + +---@private +local function clamp_line_numbers(bufnr, diagnostics) + local buf_line_count = vim.api.nvim_buf_line_count(bufnr) + if buf_line_count == 0 then + return + end + + for _, diagnostic in ipairs(diagnostics) do + diagnostic.lnum = math.max(math.min(diagnostic.lnum, buf_line_count - 1), 0) + diagnostic.end_lnum = math.max(math.min(diagnostic.end_lnum, buf_line_count - 1), 0) + end +end + +---@private +local function next_diagnostic(position, search_forward, bufnr, opts, namespace) + position[1] = position[1] - 1 + bufnr = get_bufnr(bufnr) + local wrap = vim.F.if_nil(opts.wrap, true) + local line_count = vim.api.nvim_buf_line_count(bufnr) + local diagnostics = M.get(bufnr, vim.tbl_extend("keep", opts, {namespace = namespace})) + clamp_line_numbers(bufnr, diagnostics) + local line_diagnostics = diagnostic_lines(diagnostics) + for i = 0, line_count do + local offset = i * (search_forward and 1 or -1) + local lnum = position[1] + offset + if lnum < 0 or lnum >= line_count then + if not wrap then + return + end + lnum = (lnum + line_count) % line_count + end + if line_diagnostics[lnum] and not vim.tbl_isempty(line_diagnostics[lnum]) then + local sort_diagnostics, is_next + if search_forward then + sort_diagnostics = function(a, b) return a.col < b.col end + is_next = function(diagnostic) return diagnostic.col > position[2] end + else + sort_diagnostics = function(a, b) return a.col > b.col end + is_next = function(diagnostic) return diagnostic.col < position[2] end + end + table.sort(line_diagnostics[lnum], sort_diagnostics) + if i == 0 then + for _, v in pairs(line_diagnostics[lnum]) do + if is_next(v) then + return v + end + end + else + return line_diagnostics[lnum][1] + end + end + end +end + +---@private +local function diagnostic_move_pos(opts, pos) + opts = opts or {} + + local enable_popup = vim.F.if_nil(opts.enable_popup, true) + local win_id = opts.win_id or vim.api.nvim_get_current_win() + + if not pos then + vim.api.nvim_echo({{"No more valid diagnostics to move to", "WarningMsg"}}, true, {}) + return + end + + vim.api.nvim_win_set_cursor(win_id, {pos[1] + 1, pos[2]}) + + if enable_popup then + -- This is a bit weird... I'm surprised that we need to wait til the next tick to do this. + vim.schedule(function() + M.show_position_diagnostics(opts.popup_opts, vim.api.nvim_win_get_buf(win_id)) + end) + end +end + +-- }}} + +-- Public API {{{ + +--- Configure diagnostic options globally or for a specific diagnostic +--- namespace. +--- +---@note Each of the configuration options below accepts one of the following: +--- - `false`: Disable this feature +--- - `true`: Enable this feature, use default settings. +--- - `table`: Enable this feature with overrides. +--- - `function`: Function with signature (namespace, bufnr) that returns any of the above. +--- +---@param opts table Configuration table with the following keys: +--- - underline: (default true) Use underline for diagnostics. Options: +--- * severity: Only underline diagnostics matching the given severity +--- |diagnostic-severity| +--- - virtual_text: (default true) Use virtual text for diagnostics. Options: +--- * severity: Only show virtual text for diagnostics matching the given +--- severity |diagnostic-severity| +--- * source: (string) Include the diagnostic source in virtual +--- text. One of "always" or "if_many". +--- * format: (function) A function that takes a diagnostic as input and +--- returns a string. The return value is the text used to display +--- the diagnostic. Example: +--- <pre> +--- function(diagnostic) +--- if diagnostic.severity == vim.diagnostic.severity.ERROR then +--- return string.format("E: %s", diagnostic.message) +--- end +--- return diagnostic.message +--- end +--- </pre> +--- - signs: (default true) Use signs for diagnostics. Options: +--- * severity: Only show signs for diagnostics matching the given severity +--- |diagnostic-severity| +--- - update_in_insert: (default false) Update diagnostics in Insert mode (if false, +--- diagnostics are updated on InsertLeave) +--- - severity_sort: (default false) Sort diagnostics by severity. This affects the order in +--- which signs and virtual text are displayed. When true, higher severities +--- are displayed before lower severities (e.g. ERROR is displayed before WARN). +--- Options: +--- * reverse: (boolean) Reverse sort order +---@param namespace number|nil Update the options for the given namespace. When omitted, update the +--- global diagnostic options. +function M.config(opts, namespace) + vim.validate { + opts = { opts, 't' }, + namespace = { namespace, 'n', true }, + } + + local t + if namespace then + local ns = get_namespace(namespace) + t = ns.opts + else + t = global_diagnostic_options + end + + for opt in pairs(global_diagnostic_options) do + if opts[opt] ~= nil then + t[opt] = opts[opt] + end + end + + if namespace then + for bufnr, v in pairs(diagnostic_cache) do + if vim.api.nvim_buf_is_loaded(bufnr) and v[namespace] then + M.show(namespace, bufnr) + end + end + else + for bufnr, v in pairs(diagnostic_cache) do + if vim.api.nvim_buf_is_loaded(bufnr) then + for ns in pairs(v) do + M.show(ns, bufnr) + end + end + end + end +end + +--- Set diagnostics for the given namespace and buffer. +--- +---@param namespace number The diagnostic namespace +---@param bufnr number Buffer number +---@param diagnostics table A list of diagnostic items |diagnostic-structure| +---@param opts table|nil Display options to pass to |vim.diagnostic.show()| +function M.set(namespace, bufnr, diagnostics, opts) + vim.validate { + namespace = {namespace, 'n'}, + bufnr = {bufnr, 'n'}, + diagnostics = {diagnostics, 't'}, + opts = {opts, 't', true}, + } + + if vim.tbl_isempty(diagnostics) then + return M.reset(namespace, bufnr) + end + + if not diagnostic_cleanup[bufnr][namespace] then + diagnostic_cleanup[bufnr][namespace] = true + + -- Clean up our data when the buffer unloads. + vim.api.nvim_buf_attach(bufnr, false, { + on_detach = function(_, b) + clear_diagnostic_cache(b, namespace) + diagnostic_cleanup[b][namespace] = nil + end + }) + end + + set_diagnostic_cache(namespace, bufnr, diagnostics) + + if vim.api.nvim_buf_is_loaded(bufnr) then + M.show(namespace, bufnr, diagnostics, opts) + elseif opts then + M.config(opts, namespace) + end + + vim.api.nvim_command("doautocmd <nomodeline> User DiagnosticsChanged") +end + +--- Get current diagnostics. +--- +---@param bufnr number|nil Buffer number to get diagnostics from. Use 0 for +--- current buffer or nil for all buffers. +---@param opts table|nil A table with the following keys: +--- - namespace: (number) Limit diagnostics to the given namespace. +--- - lnum: (number) Limit diagnostics to the given line number. +--- - severity: See |diagnostic-severity|. +---@return table A list of diagnostic items |diagnostic-structure|. +function M.get(bufnr, opts) + vim.validate { + bufnr = { bufnr, 'n', true }, + opts = { opts, 't', true }, + } + + opts = opts or {} + + local namespace = opts.namespace + local diagnostics = {} + + ---@private + local function add(d) + if not opts.lnum or d.lnum == opts.lnum then + table.insert(diagnostics, d) + end + end + + if namespace == nil and bufnr == nil then + for _, t in pairs(diagnostic_cache) do + for _, v in pairs(t) do + for _, diagnostic in pairs(v) do + add(diagnostic) + end + end + end + elseif namespace == nil then + for iter_namespace in pairs(diagnostic_cache[bufnr]) do + for _, diagnostic in pairs(diagnostic_cache[bufnr][iter_namespace]) do + add(diagnostic) + end + end + elseif bufnr == nil then + for _, t in pairs(diagnostic_cache) do + for _, diagnostic in pairs(t[namespace] or {}) do + add(diagnostic) + end + end + else + for _, diagnostic in pairs(diagnostic_cache[bufnr][namespace] or {}) do + add(diagnostic) + end + end + + if opts.severity then + diagnostics = filter_by_severity(opts.severity, diagnostics) + end + + return diagnostics +end + +--- Get the previous diagnostic closest to the cursor position. +--- +---@param opts table See |vim.diagnostic.goto_next()| +---@return table Previous diagnostic +function M.get_prev(opts) + opts = opts or {} + + local win_id = opts.win_id or vim.api.nvim_get_current_win() + local bufnr = vim.api.nvim_win_get_buf(win_id) + local cursor_position = opts.cursor_position or vim.api.nvim_win_get_cursor(win_id) + + return next_diagnostic(cursor_position, false, bufnr, opts, opts.namespace) +end + +--- Return the position of the previous diagnostic in the current buffer. +--- +---@param opts table See |vim.diagnostic.goto_next()| +---@return table Previous diagnostic position as a (row, col) tuple. +function M.get_prev_pos(opts) + local prev = M.get_prev(opts) + if not prev then + return false + end + + return {prev.lnum, prev.col} +end + +--- Move to the previous diagnostic in the current buffer. +---@param opts table See |vim.diagnostic.goto_next()| +function M.goto_prev(opts) + return diagnostic_move_pos( + opts, + M.get_prev_pos(opts) + ) +end + +--- Get the next diagnostic closest to the cursor position. +--- +---@param opts table See |vim.diagnostic.goto_next()| +---@return table Next diagnostic +function M.get_next(opts) + opts = opts or {} + + local win_id = opts.win_id or vim.api.nvim_get_current_win() + local bufnr = vim.api.nvim_win_get_buf(win_id) + local cursor_position = opts.cursor_position or vim.api.nvim_win_get_cursor(win_id) + + return next_diagnostic(cursor_position, true, bufnr, opts, opts.namespace) +end + +--- Return the position of the next diagnostic in the current buffer. +--- +---@param opts table See |vim.diagnostic.goto_next()| +---@return table Next diagnostic position as a (row, col) tuple. +function M.get_next_pos(opts) + local next = M.get_next(opts) + if not next then + return false + end + + return {next.lnum, next.col} +end + +--- Move to the next diagnostic. +--- +---@param opts table|nil Configuration table with the following keys: +--- - namespace: (number) Only consider diagnostics from the given namespace. +--- - cursor_position: (cursor position) Cursor position as a (row, col) tuple. See +--- |nvim_win_get_cursor()|. Defaults to the current cursor position. +--- - wrap: (boolean, default true) Whether to loop around file or not. Similar to 'wrapscan'. +--- - severity: See |diagnostic-severity|. +--- - enable_popup: (boolean, default true) Call |vim.diagnostic.show_line_diagnostics()| +--- on jump. +--- - popup_opts: (table) Table to pass as {opts} parameter to +--- |vim.diagnostic.show_line_diagnostics()| +--- - win_id: (number, default 0) Window ID +function M.goto_next(opts) + return diagnostic_move_pos( + opts, + M.get_next_pos(opts) + ) +end + +-- Diagnostic Setters {{{ + +--- Set signs for given diagnostics. +--- +---@param namespace number The diagnostic namespace +---@param bufnr number Buffer number +---@param diagnostics table A list of diagnostic items |diagnostic-structure|. When omitted the +--- current diagnostics in the given buffer are used. +---@param opts table Configuration table with the following keys: +--- - priority: Set the priority of the signs |sign-priority|. +---@private +function M._set_signs(namespace, bufnr, diagnostics, opts) + vim.validate { + namespace = {namespace, 'n'}, + bufnr = {bufnr, 'n'}, + diagnostics = {diagnostics, 't'}, + opts = {opts, 't', true}, + } + + bufnr = get_bufnr(bufnr) + opts = get_resolved_options({ signs = opts }, namespace, bufnr) + + if opts.signs and opts.signs.severity then + diagnostics = filter_by_severity(opts.signs.severity, diagnostics) + end + + local ns = get_namespace(namespace) + + define_default_signs() + + -- 10 is the default sign priority when none is explicitly specified + local priority = opts.signs and opts.signs.priority or 10 + local get_priority + if opts.severity_sort then + if type(opts.severity_sort) == "table" and opts.severity_sort.reverse then + get_priority = function(severity) + return priority + (severity - vim.diagnostic.severity.ERROR) + end + else + get_priority = function(severity) + return priority + (vim.diagnostic.severity.HINT - severity) + end + end + else + get_priority = function() + return priority + end + end + + for _, diagnostic in ipairs(diagnostics) do + vim.fn.sign_place( + 0, + ns.sign_group, + sign_highlight_map[diagnostic.severity], + bufnr, + { + priority = get_priority(diagnostic.severity), + lnum = diagnostic.lnum + 1 + } + ) + end +end + +--- Set underline for given diagnostics. +--- +---@param namespace number The diagnostic namespace +---@param bufnr number Buffer number +---@param diagnostics table A list of diagnostic items |diagnostic-structure|. When omitted the +--- current diagnostics in the given buffer are used. +---@param opts table Configuration table. Currently unused. +---@private +function M._set_underline(namespace, bufnr, diagnostics, opts) + vim.validate { + namespace = {namespace, 'n'}, + bufnr = {bufnr, 'n'}, + diagnostics = {diagnostics, 't'}, + opts = {opts, 't', true}, + } + + bufnr = get_bufnr(bufnr) + opts = get_resolved_options({ underline = opts }, namespace, bufnr).underline + + if opts and opts.severity then + diagnostics = filter_by_severity(opts.severity, diagnostics) + end + + for _, diagnostic in ipairs(diagnostics) do + local higroup = underline_highlight_map[diagnostic.severity] + + if higroup == nil then + -- Default to error if we don't have a highlight associated + higroup = underline_highlight_map.Error + end + + vim.highlight.range( + bufnr, + namespace, + higroup, + { diagnostic.lnum, diagnostic.col }, + { diagnostic.end_lnum, diagnostic.end_col } + ) + end +end + +--- Set virtual text for given diagnostics. +--- +---@param namespace number The diagnostic namespace +---@param bufnr number Buffer number +---@param diagnostics table A list of diagnostic items |diagnostic-structure|. When omitted the +--- current diagnostics in the given buffer are used. +---@param opts table|nil Configuration table with the following keys: +--- - prefix: (string) Prefix to display before virtual text on line. +--- - spacing: (number) Number of spaces to insert before virtual text. +--- - source: (string) Include the diagnostic source in virtual text. One of "always" or +--- "if_many". +---@private +function M._set_virtual_text(namespace, bufnr, diagnostics, opts) + vim.validate { + namespace = {namespace, 'n'}, + bufnr = {bufnr, 'n'}, + diagnostics = {diagnostics, 't'}, + opts = {opts, 't', true}, + } + + bufnr = get_bufnr(bufnr) + opts = get_resolved_options({ virtual_text = opts }, namespace, bufnr).virtual_text + + if opts and opts.format then + diagnostics = reformat_diagnostics(opts.format, diagnostics) + end + + if opts and opts.source then + diagnostics = prefix_source(opts.source, diagnostics) + end + + local buffer_line_diagnostics = diagnostic_lines(diagnostics) + for line, line_diagnostics in pairs(buffer_line_diagnostics) do + if opts and opts.severity then + line_diagnostics = filter_by_severity(opts.severity, line_diagnostics) + end + local virt_texts = M._get_virt_text_chunks(line_diagnostics, opts) + + if virt_texts then + vim.api.nvim_buf_set_extmark(bufnr, namespace, line, 0, { + hl_mode = "combine", + virt_text = virt_texts, + }) + end + end +end + +--- Get virtual text chunks to display using |nvim_buf_set_extmark()|. +--- +--- Exported for backward compatibility with +--- vim.lsp.diagnostic.get_virtual_text_chunks_for_line(). When that function is eventually removed, +--- this can be made local. +---@private +function M._get_virt_text_chunks(line_diags, opts) + if #line_diags == 0 then + return nil + end + + opts = opts or {} + local prefix = opts.prefix or "■" + local spacing = opts.spacing or 4 + + -- Create a little more space between virtual text and contents + local virt_texts = {{string.rep(" ", spacing)}} + + for i = 1, #line_diags - 1 do + table.insert(virt_texts, {prefix, virtual_text_highlight_map[line_diags[i].severity]}) + end + local last = line_diags[#line_diags] + + -- TODO(tjdevries): Allow different servers to be shown first somehow? + -- TODO(tjdevries): Display server name associated with these? + if last.message then + table.insert( + virt_texts, + { + string.format("%s %s", prefix, last.message:gsub("\r", ""):gsub("\n", " ")), + virtual_text_highlight_map[last.severity] + } + ) + + return virt_texts + end +end + +--- Callback scheduled when leaving Insert mode. +--- +--- This function must be exported publicly so that it is available to be +--- called from the Vimscript autocommand. +--- +--- See @ref schedule_display() +--- +---@private +function M._execute_scheduled_display(namespace, bufnr) + local args = bufs_waiting_to_update[bufnr][namespace] + if not args then + return + end + + -- Clear the args so we don't display unnecessarily. + bufs_waiting_to_update[bufnr][namespace] = nil + + M.show(namespace, bufnr, nil, args) +end + +--- Hide currently displayed diagnostics. +--- +--- This only clears the decorations displayed in the buffer. Diagnostics can +--- be redisplayed with |vim.diagnostic.show()|. To completely remove +--- diagnostics, use |vim.diagnostic.reset()|. +--- +--- To hide diagnostics and prevent them from re-displaying, use +--- |vim.diagnostic.disable()|. +--- +---@param namespace number The diagnostic namespace +---@param bufnr number|nil Buffer number. Defaults to the current buffer. +function M.hide(namespace, bufnr) + vim.validate { + namespace = { namespace, 'n' }, + bufnr = { bufnr, 'n', true }, + } + + bufnr = get_bufnr(bufnr) + diagnostic_cache_extmarks[bufnr][namespace] = {} + + local ns = get_namespace(namespace) + + -- clear sign group + vim.fn.sign_unplace(ns.sign_group, {buffer=bufnr}) + + -- clear virtual text namespace + vim.api.nvim_buf_clear_namespace(bufnr, namespace, 0, -1) +end + + +--- Display diagnostics for the given namespace and buffer. +--- +---@param namespace number Diagnostic namespace +---@param bufnr number|nil Buffer number. Defaults to the current buffer. +---@param diagnostics table|nil The diagnostics to display. When omitted, use the +--- saved diagnostics for the given namespace and +--- buffer. This can be used to display a list of diagnostics +--- without saving them or to display only a subset of +--- diagnostics. +---@param opts table|nil Display options. See |vim.diagnostic.config()|. +function M.show(namespace, bufnr, diagnostics, opts) + vim.validate { + namespace = { namespace, 'n' }, + bufnr = { bufnr, 'n', true }, + diagnostics = { diagnostics, 't', true }, + opts = { opts, 't', true }, + } + + bufnr = get_bufnr(bufnr) + if is_disabled(namespace, bufnr) then + return + end + + M.hide(namespace, bufnr) + + diagnostics = diagnostics or M.get(bufnr, {namespace=namespace}) + + if not diagnostics or vim.tbl_isempty(diagnostics) then + return + end + + opts = get_resolved_options(opts, namespace, bufnr) + + if opts.update_in_insert then + clear_scheduled_display(namespace, bufnr) + else + local mode = vim.api.nvim_get_mode() + if string.sub(mode.mode, 1, 1) == 'i' then + schedule_display(namespace, bufnr, opts) + return + end + end + + if vim.F.if_nil(opts.severity_sort, false) then + if type(opts.severity_sort) == "table" and opts.severity_sort.reverse then + table.sort(diagnostics, function(a, b) return a.severity < b.severity end) + else + table.sort(diagnostics, function(a, b) return a.severity > b.severity end) + end + end + + clamp_line_numbers(bufnr, diagnostics) + + if opts.underline then + M._set_underline(namespace, bufnr, diagnostics, opts.underline) + end + + if opts.virtual_text then + M._set_virtual_text(namespace, bufnr, diagnostics, opts.virtual_text) + end + + if opts.signs then + M._set_signs(namespace, bufnr, diagnostics, opts.signs) + end + + save_extmarks(namespace, bufnr) +end + +--- Open a floating window with the diagnostics at the given position. +--- +---@param opts table|nil Configuration table with the same keys as +--- |vim.lsp.util.open_floating_preview()| in addition to the following: +--- - namespace: (number) Limit diagnostics to the given namespace +--- - severity: See |diagnostic-severity|. +--- - show_header: (boolean, default true) Show "Diagnostics:" header +--- - source: (string) Include the diagnostic source in +--- the message. One of "always" or "if_many". +--- - format: (function) A function that takes a diagnostic as input and returns a +--- string. The return value is the text used to display the diagnostic. +---@param bufnr number|nil Buffer number. Defaults to the current buffer. +---@param position table|nil The (0,0)-indexed position. Defaults to the current cursor position. +---@return tuple ({popup_bufnr}, {win_id}) +function M.show_position_diagnostics(opts, bufnr, position) + vim.validate { + opts = { opts, 't', true }, + bufnr = { bufnr, 'n', true }, + position = { position, 't', true }, + } + + opts = opts or {} + + opts.focus_id = "position_diagnostics" + bufnr = get_bufnr(bufnr) + if not position then + local curr_position = vim.api.nvim_win_get_cursor(0) + curr_position[1] = curr_position[1] - 1 + position = curr_position + end + local match_position_predicate = function(diag) + return position[1] == diag.lnum and + position[2] >= diag.col and + (position[2] <= diag.end_col or position[1] < diag.end_lnum) + end + local diagnostics = M.get(bufnr, opts) + clamp_line_numbers(bufnr, diagnostics) + local position_diagnostics = vim.tbl_filter(match_position_predicate, diagnostics) + table.sort(position_diagnostics, function(a, b) return a.severity < b.severity end) + return show_diagnostics(opts, position_diagnostics) +end + +--- Open a floating window with the diagnostics from the given line. +--- +---@param opts table Configuration table. See |vim.diagnostic.show_position_diagnostics()|. +---@param bufnr number|nil Buffer number. Defaults to the current buffer. +---@param lnum number|nil Line number. Defaults to line number of cursor. +---@return tuple ({popup_bufnr}, {win_id}) +function M.show_line_diagnostics(opts, bufnr, lnum) + vim.validate { + opts = { opts, 't', true }, + bufnr = { bufnr, 'n', true }, + lnum = { lnum, 'n', true }, + } + + opts = opts or {} + opts.focus_id = "line_diagnostics" + bufnr = get_bufnr(bufnr) + local diagnostics = M.get(bufnr, opts) + clamp_line_numbers(bufnr, diagnostics) + lnum = lnum or (vim.api.nvim_win_get_cursor(0)[1] - 1) + local line_diagnostics = diagnostic_lines(diagnostics)[lnum] + return show_diagnostics(opts, line_diagnostics) +end + +--- Remove all diagnostics from the given namespace. +--- +--- Unlike |vim.diagnostic.hide()|, this function removes all saved +--- diagnostics. They cannot be redisplayed using |vim.diagnostic.show()|. To +--- simply remove diagnostic decorations in a way that they can be +--- re-displayed, use |vim.diagnostic.hide()|. +--- +---@param namespace number +---@param bufnr number|nil Remove diagnostics for the given buffer. When omitted, +--- diagnostics are removed for all buffers. +function M.reset(namespace, bufnr) + if bufnr == nil then + for iter_bufnr, namespaces in pairs(diagnostic_cache) do + if namespaces[namespace] then + M.reset(namespace, iter_bufnr) + end + end + else + clear_diagnostic_cache(namespace, bufnr) + M.hide(namespace, bufnr) + end + + vim.api.nvim_command("doautocmd <nomodeline> User DiagnosticsChanged") +end + +--- Add all diagnostics to the quickfix list. +--- +---@param opts table|nil Configuration table with the following keys: +--- - namespace: (number) Only add diagnostics from the given namespace. +--- - open: (boolean, default true) Open quickfix list after setting. +--- - title: (string) Title of quickfix list. Defaults to "Diagnostics". +--- - severity: See |diagnostic-severity|. +function M.setqflist(opts) + set_list(false, opts) +end + +--- Add buffer diagnostics to the location list. +--- +---@param opts table|nil Configuration table with the following keys: +--- - namespace: (number) Only add diagnostics from the given namespace. +--- - winnr: (number, default 0) Window number to set location list for. +--- - open: (boolean, default true) Open the location list after setting. +--- - title: (string) Title of the location list. Defaults to "Diagnostics". +--- - severity: See |diagnostic-severity|. +function M.setloclist(opts) + set_list(true, opts) +end + +--- Disable diagnostics in the given buffer. +--- +---@param bufnr number|nil Buffer number. Defaults to the current buffer. +---@param namespace number|nil Only disable diagnostics for the given namespace. +function M.disable(bufnr, namespace) + vim.validate { bufnr = {bufnr, 'n', true}, namespace = {namespace, 'n', true} } + bufnr = get_bufnr(bufnr) + if namespace == nil then + diagnostic_disabled[bufnr] = true + for ns in pairs(diagnostic_cache[bufnr]) do + M.hide(ns, bufnr) + end + else + if type(diagnostic_disabled[bufnr]) ~= "table" then + diagnostic_disabled[bufnr] = {} + end + diagnostic_disabled[bufnr][namespace] = true + M.hide(namespace, bufnr) + end +end + +--- Enable diagnostics in the given buffer. +--- +---@param bufnr number|nil Buffer number. Defaults to the current buffer. +---@param namespace number|nil Only enable diagnostics for the given namespace. +function M.enable(bufnr, namespace) + vim.validate { bufnr = {bufnr, 'n', true}, namespace = {namespace, 'n', true} } + bufnr = get_bufnr(bufnr) + if namespace == nil then + diagnostic_disabled[bufnr] = nil + for ns in pairs(diagnostic_cache[bufnr]) do + M.show(ns, bufnr) + end + else + if type(diagnostic_disabled[bufnr]) ~= "table" then + return + end + diagnostic_disabled[bufnr][namespace] = nil + M.show(namespace, bufnr) + end +end + +--- Parse a diagnostic from a string. +--- +--- For example, consider a line of output from a linter: +--- <pre> +--- WARNING filename:27:3: Variable 'foo' does not exist +--- </pre> +--- This can be parsed into a diagnostic |diagnostic-structure| +--- with: +--- <pre> +--- local s = "WARNING filename:27:3: Variable 'foo' does not exist" +--- local pattern = "^(%w+) %w+:(%d+):(%d+): (.+)$" +--- local groups = {"severity", "lnum", "col", "message"} +--- vim.diagnostic.match(s, pattern, groups, {WARNING = vim.diagnostic.WARN}) +--- </pre> +--- +---@param str string String to parse diagnostics from. +---@param pat string Lua pattern with capture groups. +---@param groups table List of fields in a |diagnostic-structure| to +--- associate with captures from {pat}. +---@param severity_map table A table mapping the severity field from {groups} +--- with an item from |vim.diagnostic.severity|. +---@param defaults table|nil Table of default values for any fields not listed in {groups}. +--- When omitted, numeric values default to 0 and "severity" defaults to +--- ERROR. +---@return diagnostic |diagnostic-structure| or `nil` if {pat} fails to match {str}. +function M.match(str, pat, groups, severity_map, defaults) + vim.validate { + str = { str, 's' }, + pat = { pat, 's' }, + groups = { groups, 't' }, + severity_map = { severity_map, 't', true }, + defaults = { defaults, 't', true }, + } + + severity_map = severity_map or M.severity + + local diagnostic = {} + local matches = {string.match(str, pat)} + if vim.tbl_isempty(matches) then + return + end + + for i, match in ipairs(matches) do + local field = groups[i] + if field == "severity" then + match = severity_map[match] + elseif field == "lnum" or field == "end_lnum" or field == "col" or field == "end_col" then + match = assert(tonumber(match)) - 1 + end + diagnostic[field] = match + end + + diagnostic = vim.tbl_extend("keep", diagnostic, defaults or {}) + diagnostic.severity = diagnostic.severity or M.severity.ERROR + diagnostic.col = diagnostic.col or 0 + diagnostic.end_lnum = diagnostic.end_lnum or diagnostic.lnum + diagnostic.end_col = diagnostic.end_col or diagnostic.col + return diagnostic +end + +local errlist_type_map = { + [M.severity.ERROR] = 'E', + [M.severity.WARN] = 'W', + [M.severity.INFO] = 'I', + [M.severity.HINT] = 'N', +} + +--- Convert a list of diagnostics to a list of quickfix items that can be +--- passed to |setqflist()| or |setloclist()|. +--- +---@param diagnostics table List of diagnostics |diagnostic-structure|. +---@return array of quickfix list items |setqflist-what| +function M.toqflist(diagnostics) + vim.validate { diagnostics = {diagnostics, 't'} } + + local list = {} + for _, v in ipairs(diagnostics) do + local item = { + bufnr = v.bufnr, + lnum = v.lnum + 1, + col = v.col and (v.col + 1) or nil, + end_lnum = v.end_lnum and (v.end_lnum + 1) or nil, + end_col = v.end_col and (v.end_col + 1) or nil, + text = v.message, + type = errlist_type_map[v.severity] or 'E', + } + table.insert(list, item) + end + table.sort(list, function(a, b) + if a.bufnr == b.bufnr then + return a.lnum < b.lnum + else + return a.bufnr < b.bufnr + end + end) + return list +end + +--- Convert a list of quickfix items to a list of diagnostics. +--- +---@param list table A list of quickfix items from |getqflist()| or +--- |getloclist()|. +---@return array of diagnostics |diagnostic-structure| +function M.fromqflist(list) + vim.validate { list = {list, 't'} } + + local diagnostics = {} + for _, item in ipairs(list) do + if item.valid == 1 then + local lnum = math.max(0, item.lnum - 1) + local col = item.col > 0 and (item.col - 1) or nil + local end_lnum = item.end_lnum > 0 and (item.end_lnum - 1) or lnum + local end_col = item.end_col > 0 and (item.end_col - 1) or col + local severity = item.type ~= "" and M.severity[item.type] or M.severity.ERROR + table.insert(diagnostics, { + bufnr = item.bufnr, + lnum = lnum, + col = col, + end_lnum = end_lnum, + end_col = end_col, + severity = severity, + message = item.text, + }) + end + end + return diagnostics +end + +-- }}} + +return M diff --git a/runtime/lua/vim/highlight.lua b/runtime/lua/vim/highlight.lua index 0012dce081..236f3165f2 100644 --- a/runtime/lua/vim/highlight.lua +++ b/runtime/lua/vim/highlight.lua @@ -2,7 +2,7 @@ local api = vim.api local highlight = {} ---@private +---@private function highlight.create(higroup, hi_info, default) local options = {} -- TODO: Add validation @@ -12,7 +12,7 @@ function highlight.create(higroup, hi_info, default) vim.cmd(string.format([[highlight %s %s %s]], default and "default" or "", higroup, table.concat(options, " "))) end ---@private +---@private function highlight.link(higroup, link_to, force) vim.cmd(string.format([[highlight%s link %s %s]], force and "!" or " default", higroup, link_to)) end @@ -20,11 +20,11 @@ end --- Highlight range between two positions --- ---@param bufnr number of buffer to apply highlighting to ---@param ns namespace to add highlight to ---@param higroup highlight group to use for highlighting ---@param rtype type of range (:help setreg, default charwise) ---@param inclusive boolean indicating whether the range is end-inclusive (default false) +---@param bufnr number of buffer to apply highlighting to +---@param ns namespace to add highlight to +---@param higroup highlight group to use for highlighting +---@param rtype type of range (:help setreg, default charwise) +---@param inclusive boolean indicating whether the range is end-inclusive (default false) function highlight.range(bufnr, ns, higroup, start, finish, rtype, inclusive) rtype = rtype or 'v' inclusive = inclusive or false @@ -85,7 +85,11 @@ function highlight.on_yank(opts) highlight.range(bufnr, yank_ns, higroup, pos1, pos2, event.regtype, event.inclusive) vim.defer_fn( - function() api.nvim_buf_clear_namespace(bufnr, yank_ns, 0, -1) end, + function() + if api.nvim_buf_is_valid(bufnr) then + api.nvim_buf_clear_namespace(bufnr, yank_ns, 0, -1) + end + end, timeout ) end diff --git a/runtime/lua/vim/lsp.lua b/runtime/lua/vim/lsp.lua index 75faf9bcc7..c7a88a0993 100644 --- a/runtime/lua/vim/lsp.lua +++ b/runtime/lua/vim/lsp.lua @@ -55,21 +55,21 @@ lsp._request_name_to_capability = { -- TODO improve handling of scratch buffers with LSP attached. ---@private +---@private --- Concatenates and writes a list of strings to the Vim error buffer. --- ---@param {...} (List of strings) List to write to the buffer +---@param {...} (List of strings) List to write to the buffer local function err_message(...) nvim_err_writeln(table.concat(vim.tbl_flatten{...})) nvim_command("redraw") end ---@private +---@private --- Returns the buffer number for the given {bufnr}. --- ---@param bufnr (number) Buffer number to resolve. Defaults to the current +---@param bufnr (number) Buffer number to resolve. Defaults to the current ---buffer if not given. ---@returns bufnr (number) Number of requested buffer +---@returns bufnr (number) Number of requested buffer local function resolve_bufnr(bufnr) validate { bufnr = { bufnr, 'n', true } } if bufnr == nil or bufnr == 0 then @@ -78,21 +78,21 @@ local function resolve_bufnr(bufnr) return bufnr end ---@private +---@private --- Called by the client when trying to call a method that's not --- supported in any of the servers registered for the current buffer. ---@param method (string) name of the method +---@param method (string) name of the method function lsp._unsupported_method(method) local msg = string.format("method %s is not supported by any of the servers registered for the current buffer", method) log.warn(msg) return lsp.rpc_response_error(protocol.ErrorCodes.MethodNotFound, msg) end ---@private +---@private --- Checks whether a given path is a directory. --- ---@param filename (string) path to check ---@returns true if {filename} exists and is a directory, false otherwise +---@param filename (string) path to check +---@returns true if {filename} exists and is a directory, false otherwise local function is_dir(filename) validate{filename={filename,'s'}} local stat = uv.fs_stat(filename) @@ -108,10 +108,10 @@ local valid_encodings = { } local client_index = 0 ---@private +---@private --- Returns a new, unused client id. --- ---@returns (number) client id +---@returns (number) client id local function next_client_id() client_index = client_index + 1 return client_index @@ -124,11 +124,11 @@ local uninitialized_clients = {} -- Tracks all buffers attached to a client. local all_client_active_buffers = {} ---@private +---@private --- Invokes a function for each LSP client attached to the buffer {bufnr}. --- ---@param bufnr (Number) of buffer ---@param fn (function({client}, {client_id}, {bufnr}) Function to run on +---@param bufnr (Number) of buffer +---@param fn (function({client}, {client_id}, {bufnr}) Function to run on ---each client attached to that buffer. local function for_each_buffer_client(bufnr, fn) validate { @@ -154,11 +154,11 @@ lsp.client_errors = tbl_extend("error", lsp_rpc.client_errors, vim.tbl_add_rever ON_INIT_CALLBACK_ERROR = table.maxn(lsp_rpc.client_errors) + 1; }) ---@private +---@private --- Normalizes {encoding} to valid LSP encoding names. --- ---@param encoding (string) Encoding to normalize ---@returns (string) normalized encoding name +---@param encoding (string) Encoding to normalize +---@returns (string) normalized encoding name local function validate_encoding(encoding) validate { encoding = { encoding, 's' }; @@ -167,13 +167,13 @@ local function validate_encoding(encoding) or error(string.format("Invalid offset encoding %q. Must be one of: 'utf-8', 'utf-16', 'utf-32'", encoding)) end ---@internal +---@internal --- Parses a command invocation into the command itself and its args. If there --- are no arguments, an empty table is returned as the second argument. --- ---@param input (List) ---@returns (string) the command ---@returns (list of strings) its arguments +---@param input (List) +---@returns (string) the command +---@returns (list of strings) its arguments function lsp._cmd_parts(input) vim.validate{cmd={ input, @@ -192,12 +192,12 @@ function lsp._cmd_parts(input) return cmd, cmd_args end ---@private +---@private --- Augments a validator function with support for optional (nil) values. --- ---@param fn (function(v)) The original validator function; should return a +---@param fn (function(v)) The original validator function; should return a ---bool. ---@returns (function(v)) The augmented function. Also returns true if {v} is +---@returns (function(v)) The augmented function. Also returns true if {v} is ---`nil`. local function optional_validator(fn) return function(v) @@ -205,20 +205,20 @@ local function optional_validator(fn) end end ---@private +---@private --- Validates a client configuration as given to |vim.lsp.start_client()|. --- ---@param config (table) ---@returns (table) "Cleaned" config, containing only the command, its +---@param config (table) +---@returns (table) "Cleaned" config, containing only the command, its ---arguments, and a valid encoding. --- ---@see |vim.lsp.start_client()| +---@see |vim.lsp.start_client()| local function validate_client_config(config) validate { config = { config, 't' }; } validate { - root_dir = { config.root_dir, is_dir, "directory" }; + root_dir = { config.root_dir, optional_validator(is_dir), "directory" }; handlers = { config.handlers, "t", true }; capabilities = { config.capabilities, "t", true }; cmd_cwd = { config.cmd_cwd, optional_validator(is_dir), "directory" }; @@ -253,11 +253,11 @@ local function validate_client_config(config) } end ---@private +---@private --- Returns full text of buffer {bufnr} as a string. --- ---@param bufnr (number) Buffer handle, or 0 for current. ---@returns Buffer text as string. +---@param bufnr (number) Buffer handle, or 0 for current. +---@returns Buffer text as string. local function buf_get_full_text(bufnr) local text = table.concat(nvim_buf_get_lines(bufnr, 0, -1, true), '\n') if nvim_buf_get_option(bufnr, 'eol') then @@ -266,14 +266,14 @@ local function buf_get_full_text(bufnr) return text end ---@private +---@private --- Memoizes a function. On first run, the function return value is saved and --- immediately returned on subsequent runs. If the function returns a multival, --- only the first returned value will be memoized and returned. The function will only be run once, --- even if it has side-effects. --- ---@param fn (function) Function to run ---@returns (function) Memoized function +---@param fn (function) Function to run +---@returns (function) Memoized function local function once(fn) local value local ran = false @@ -289,6 +289,7 @@ end local changetracking = {} do + --@private --- client_id → state --- --- state @@ -380,7 +381,7 @@ do end state.pending_change = function() state.pending_change = nil - if client.is_stopped() then + if client.is_stopped() or not vim.api.nvim_buf_is_valid(bufnr) then return end local contentChanges @@ -425,11 +426,11 @@ do end ---@private +---@private --- Default handler for the 'textDocument/didOpen' LSP notification. --- ---@param bufnr (Number) Number of the buffer, or 0 for current ---@param client Client object +---@param bufnr (Number) Number of the buffer, or 0 for current +---@param client Client object local function text_document_did_open_handler(bufnr, client) changetracking.init(client, bufnr) if not client.resolved_capabilities.text_document_open_close then @@ -453,15 +454,7 @@ local function text_document_did_open_handler(bufnr, client) -- Next chance we get, we should re-do the diagnostics vim.schedule(function() - vim.lsp.handlers["textDocument/publishDiagnostics"]( - nil, - "textDocument/publishDiagnostics", - { - diagnostics = vim.lsp.diagnostic.get(bufnr, client.id), - uri = vim.uri_from_bufnr(bufnr), - }, - client.id - ) + vim.lsp.diagnostic.redraw(bufnr, client.id) end) end @@ -555,16 +548,16 @@ end --- --- The following parameters describe fields in the {config} table. --- ---@param root_dir: (required, string) Directory where the LSP server will base +---@param root_dir: (string) Directory where the LSP server will base --- its rootUri on initialization. --- ---@param cmd: (required, string or list treated like |jobstart()|) Base command +---@param cmd: (required, string or list treated like |jobstart()|) Base command --- that initiates the LSP client. --- ---@param cmd_cwd: (string, default=|getcwd()|) Directory to launch +---@param cmd_cwd: (string, default=|getcwd()|) Directory to launch --- the `cmd` process. Not related to `root_dir`. --- ---@param cmd_env: (table) Environment flags to pass to the LSP on +---@param cmd_env: (table) Environment flags to pass to the LSP on --- spawn. Can be specified using keys like a map or as a list with `k=v` --- pairs or both. Non-string values are coerced to string. --- Example: @@ -572,7 +565,7 @@ end --- { "PRODUCTION=true"; "TEST=123"; PORT = 8080; HOST = "0.0.0.0"; } --- </pre> --- ---@param capabilities Map overriding the default capabilities defined by +---@param capabilities Map overriding the default capabilities defined by --- |vim.lsp.protocol.make_client_capabilities()|, passed to the language --- server on initialization. Hint: use make_client_capabilities() and modify --- its result. @@ -580,37 +573,41 @@ end --- `{[vim.type_idx]=vim.types.dictionary}`, else it will be encoded as an --- array. --- ---@param handlers Map of language server method names to |lsp-handler| +---@param handlers Map of language server method names to |lsp-handler| --- ---@param settings Map with language server specific settings. These are +---@param settings Map with language server specific settings. These are --- returned to the language server if requested via `workspace/configuration`. --- Keys are case-sensitive. --- ---@param init_options Values to pass in the initialization request +---@param init_options Values to pass in the initialization request --- as `initializationOptions`. See `initialize` in the LSP spec. --- ---@param name (string, default=client-id) Name in log messages. +---@param name (string, default=client-id) Name in log messages. +-- +---@param workspace_folders (table) List of workspace folders passed to the +--- language server. Defaults to root_dir if not set. See `workspaceFolders` in +--- the LSP spec --- ---@param get_language_id function(bufnr, filetype) -> language ID as string. +---@param get_language_id function(bufnr, filetype) -> language ID as string. --- Defaults to the filetype. --- ---@param offset_encoding (default="utf-16") One of "utf-8", "utf-16", +---@param offset_encoding (default="utf-16") One of "utf-8", "utf-16", --- or "utf-32" which is the encoding that the LSP server expects. Client does --- not verify this is correct. --- ---@param on_error Callback with parameters (code, ...), invoked +---@param on_error Callback with parameters (code, ...), invoked --- when the client operation throws an error. `code` is a number describing --- the error. Other arguments may be passed depending on the error kind. See --- |vim.lsp.client_errors| for possible errors. --- Use `vim.lsp.client_errors[code]` to get human-friendly name. --- ---@param before_init Callback with parameters (initialize_params, config) +---@param before_init Callback with parameters (initialize_params, config) --- invoked before the LSP "initialize" phase, where `params` contains the --- parameters being sent to the server and `config` is the config that was --- passed to |vim.lsp.start_client()|. You can use this to modify parameters before --- they are sent. --- ---@param on_init Callback (client, initialize_result) invoked after LSP +---@param on_init Callback (client, initialize_result) invoked after LSP --- "initialize", where `result` is a table of `capabilities` and anything else --- the server may send. For example, clangd sends --- `initialize_result.offsetEncoding` if `capabilities.offsetEncoding` was @@ -620,24 +617,24 @@ end --- `workspace/didChangeConfiguration` notification should be sent --- to the server during on_init. --- ---@param on_exit Callback (code, signal, client_id) invoked on client +---@param on_exit Callback (code, signal, client_id) invoked on client --- exit. --- - code: exit code of the process --- - signal: number describing the signal used to terminate (if any) --- - client_id: client handle --- ---@param on_attach Callback (client, bufnr) invoked when client +---@param on_attach Callback (client, bufnr) invoked when client --- attaches to a buffer. --- ---@param trace: "off" | "messages" | "verbose" | nil passed directly to the language +---@param trace: "off" | "messages" | "verbose" | nil passed directly to the language --- server in the initialize request. Invalid/empty values will default to "off" ---@param flags: A table with flags for the client. The current (experimental) flags are: +---@param flags: A table with flags for the client. The current (experimental) flags are: --- - allow_incremental_sync (bool, default true): Allow using incremental sync for buffer edits --- - debounce_text_changes (number, default nil): Debounce didChange --- notifications to the server by the given number in milliseconds. No debounce --- occurs if nil --- ---@returns Client id. |vim.lsp.get_client_by_id()| Note: client may not be +---@returns Client id. |vim.lsp.get_client_by_id()| Note: client may not be --- fully initialized. Use `on_init` to do any actions once --- the client has been initialized. function lsp.start_client(config) @@ -660,53 +657,53 @@ function lsp.start_client(config) local dispatch = {} - --@private + ---@private --- Returns the handler associated with an LSP method. --- Returns the default handler if the user hasn't set a custom one. --- - --@param method (string) LSP method name - --@returns (fn) The handler for the given method, if defined, or the default from |vim.lsp.handlers| + ---@param method (string) LSP method name + ---@returns (fn) The handler for the given method, if defined, or the default from |vim.lsp.handlers| local function resolve_handler(method) return handlers[method] or default_handlers[method] end - --@private + ---@private --- Handles a notification sent by an LSP server by invoking the --- corresponding handler. --- - --@param method (string) LSP method name - --@param params (table) The parameters for that method. + ---@param method (string) LSP method name + ---@param params (table) The parameters for that method. function dispatch.notification(method, params) - local _ = log.debug() and log.debug('notification', method, params) + local _ = log.trace() and log.trace('notification', method, params) local handler = resolve_handler(method) if handler then -- Method name is provided here for convenience. - handler(nil, method, params, client_id) + handler(nil, params, {method=method, client_id=client_id}) end end - --@private + ---@private --- Handles a request from an LSP server by invoking the corresponding handler. --- - --@param method (string) LSP method name - --@param params (table) The parameters for that method + ---@param method (string) LSP method name + ---@param params (table) The parameters for that method function dispatch.server_request(method, params) - local _ = log.debug() and log.debug('server_request', method, params) + local _ = log.trace() and log.trace('server_request', method, params) local handler = resolve_handler(method) if handler then - local _ = log.debug() and log.debug("server_request: found handler for", method) - return handler(nil, method, params, client_id) + local _ = log.trace() and log.trace("server_request: found handler for", method) + return handler(nil, params, {method=method, client_id=client_id}) end - local _ = log.debug() and log.debug("server_request: no handler found for", method) + local _ = log.warn() and log.warn("server_request: no handler found for", method) return nil, lsp.rpc_response_error(protocol.ErrorCodes.MethodNotFound) end - --@private + ---@private --- Invoked when the client operation throws an error. --- - --@param code (number) Error code - --@param err (...) Other arguments may be passed depending on the error kind - --@see |vim.lsp.client_errors| for possible errors. Use + ---@param code (number) Error code + ---@param err (...) Other arguments may be passed depending on the error kind + ---@see |vim.lsp.client_errors| for possible errors. Use ---`vim.lsp.client_errors[code]` to get a human-friendly name. function dispatch.on_error(code, err) local _ = log.error() and log.error(log_prefix, "on_error", { code = lsp.client_errors[code], err = err }) @@ -720,11 +717,11 @@ function lsp.start_client(config) end end - --@private + ---@private --- Invoked on client exit. --- - --@param code (number) exit code of the process - --@param signal (number) the signal used to terminate (if any) + ---@param code (number) exit code of the process + ---@param signal (number) the signal used to terminate (if any) function dispatch.on_exit(code, signal) active_clients[client_id] = nil uninitialized_clients[client_id] = nil @@ -769,12 +766,20 @@ function lsp.start_client(config) -- Store the uninitialized_clients for cleanup in case we exit before initialize finishes. uninitialized_clients[client_id] = client; - --@private + ---@private local function initialize() local valid_traces = { off = 'off'; messages = 'messages'; verbose = 'verbose'; } local version = vim.version() + + if config.root_dir and not config.workspace_folders then + config.workspace_folders = {{ + uri = vim.uri_from_fname(config.root_dir); + name = string.format("%s", config.root_dir); + }}; + end + local initialize_params = { -- The process Id of the parent process that started the server. Is null if -- the process has not been started by another process. If the parent @@ -793,7 +798,7 @@ function lsp.start_client(config) rootPath = config.root_dir; -- The rootUri of the workspace. Is null if no folder is open. If both -- `rootPath` and `rootUri` are set `rootUri` wins. - rootUri = vim.uri_from_fname(config.root_dir); + rootUri = config.root_dir and vim.uri_from_fname(config.root_dir); -- User provided initialization options. initializationOptions = config.init_options; -- The capabilities provided by the client (editor or tool) @@ -815,20 +820,17 @@ function lsp.start_client(config) -- -- workspace folder in the user interface. -- name -- } - workspaceFolders = {{ - uri = vim.uri_from_fname(config.root_dir); - name = string.format("%s", config.root_dir); - }}; + workspaceFolders = config.workspace_folders, } if config.before_init then -- TODO(ashkan) handle errors here. pcall(config.before_init, initialize_params, config) end - local _ = log.debug() and log.debug(log_prefix, "initialize_params", initialize_params) + local _ = log.trace() and log.trace(log_prefix, "initialize_params", initialize_params) rpc.request('initialize', initialize_params, function(init_err, result) assert(not init_err, tostring(init_err)) assert(result, "server sent empty result") - rpc.notify('initialized', {[vim.type_idx]=vim.types.dictionary}) + rpc.notify('initialized', vim.empty_dict()) client.initialized = true uninitialized_clients[client_id] = nil client.workspaceFolders = initialize_params.workspaceFolders @@ -867,23 +869,23 @@ function lsp.start_client(config) end) end - --@private + ---@private --- Sends a request to the server. --- --- This is a thin wrapper around {client.rpc.request} with some additional --- checks for capabilities and handler availability. --- - --@param method (string) LSP method name. - --@param params (table) LSP request params. - --@param handler (function, optional) Response |lsp-handler| for this method. - --@param bufnr (number) Buffer handle (0 for current). - --@returns ({status}, [request_id]): {status} is a bool indicating + ---@param method (string) LSP method name. + ---@param params (table) LSP request params. + ---@param handler (function, optional) Response |lsp-handler| for this method. + ---@param bufnr (number) Buffer handle (0 for current). + ---@returns ({status}, [request_id]): {status} is a bool indicating ---whether the request was successful. If it is `false`, then it will ---always be `false` (the client has shutdown). If it was ---successful, then it will return {request_id} as the ---second result. You can use this with `client.cancel_request(request_id)` ---to cancel the-request. - --@see |vim.lsp.buf_request()| + ---@see |vim.lsp.buf_request()| function client.request(method, params, handler, bufnr) if not handler then handler = resolve_handler(method) @@ -894,28 +896,28 @@ function lsp.start_client(config) local _ = log.debug() and log.debug(log_prefix, "client.request", client_id, method, params, handler, bufnr) return rpc.request(method, params, function(err, result) - handler(err, method, result, client_id, bufnr) + handler(err, result, {method=method, client_id=client_id, bufnr=bufnr, params=params}) end) end - --@private + ---@private --- Sends a request to the server and synchronously waits for the response. --- --- This is a wrapper around {client.request} --- - --@param method (string) LSP method name. - --@param params (table) LSP request params. - --@param timeout_ms (number, optional, default=1000) Maximum time in + ---@param method (string) LSP method name. + ---@param params (table) LSP request params. + ---@param timeout_ms (number, optional, default=1000) Maximum time in ---milliseconds to wait for a result. - --@param bufnr (number) Buffer handle (0 for current). - --@returns { err=err, result=result }, a dictionary, where `err` and `result` come from the |lsp-handler|. + ---@param bufnr (number) Buffer handle (0 for current). + ---@returns { err=err, result=result }, a dictionary, where `err` and `result` come from the |lsp-handler|. ---On timeout, cancel or error, returns `(nil, err)` where `err` is a ---string describing the failure reason. If the request was unsuccessful ---returns `nil`. - --@see |vim.lsp.buf_request_sync()| + ---@see |vim.lsp.buf_request_sync()| function client.request_sync(method, params, timeout_ms, bufnr) local request_result = nil - local function _sync_handler(err, _, result) + local function _sync_handler(err, result) request_result = { err = err, result = result } end @@ -934,25 +936,25 @@ function lsp.start_client(config) return request_result end - --@private + ---@private --- Sends a notification to an LSP server. --- - --@param method (string) LSP method name. - --@param params (optional, table) LSP request params. - --@param bufnr (number) Buffer handle, or 0 for current. - --@returns {status} (bool) true if the notification was successful. + ---@param method (string) LSP method name. + ---@param params (optional, table) LSP request params. + ---@param bufnr (number) Buffer handle, or 0 for current. + ---@returns {status} (bool) true if the notification was successful. ---If it is false, then it will always be false ---(the client has shutdown). function client.notify(...) return rpc.notify(...) end - --@private + ---@private --- Cancels a request with a given request id. --- - --@param id (number) id of request to cancel - --@returns true if any client returns true; false otherwise - --@see |vim.lsp.client.notify()| + ---@param id (number) id of request to cancel + ---@returns true if any client returns true; false otherwise + ---@see |vim.lsp.client.notify()| function client.cancel_request(id) validate{id = {id, 'n'}} return rpc.notify("$/cancelRequest", { id = id }) @@ -961,14 +963,14 @@ function lsp.start_client(config) -- Track this so that we can escalate automatically if we've alredy tried a -- graceful shutdown local graceful_shutdown_failed = false - --@private + ---@private --- Stops a client, optionally with force. --- ---By default, it will just ask the - server to shutdown without force. If --- you request to stop a client which has previously been requested to --- shutdown, it will automatically escalate and force shutdown. --- - --@param force (bool, optional) + ---@param force (bool, optional) function client.stop(force) lsp.diagnostic.reset(client_id, all_buffer_active_clients) @@ -998,18 +1000,18 @@ function lsp.start_client(config) end) end - --@private + ---@private --- Checks whether a client is stopped. --- - --@returns (bool) true if client is stopped or in the process of being + ---@returns (bool) true if client is stopped or in the process of being ---stopped; false otherwise function client.is_stopped() return rpc.handle:is_closing() end - --@private + ---@private --- Runs the on_attach function from the client's config if it was defined. - --@param bufnr (number) Buffer number + ---@param bufnr (number) Buffer number function client._on_attach(bufnr) text_document_did_open_handler(bufnr, client) if config.on_attach then @@ -1023,8 +1025,8 @@ function lsp.start_client(config) return client_id end ---@private ---@fn text_document_did_change_handler(_, bufnr, changedtick, firstline, lastline, new_lastline, old_byte_size, old_utf32_size, old_utf16_size) +---@private +---@fn text_document_did_change_handler(_, bufnr, changedtick, firstline, lastline, new_lastline, old_byte_size, old_utf32_size, old_utf16_size) --- Notify all attached clients that a buffer has changed. local text_document_did_change_handler do @@ -1074,8 +1076,8 @@ end --- --- Without calling this, the server won't be notified of changes to a buffer. --- ---- @param bufnr (number) Buffer handle, or 0 for current ---- @param client_id (number) Client id +---@param bufnr (number) Buffer handle, or 0 for current +---@param client_id (number) Client id function lsp.buf_attach_client(bufnr, client_id) validate { bufnr = {bufnr, 'n', true}; @@ -1150,23 +1152,23 @@ end ---@param bufnr (number) Buffer handle, or 0 for current ---@param client_id (number) the client id function lsp.buf_is_attached(bufnr, client_id) - return (all_buffer_active_clients[bufnr] or {})[client_id] == true + return (all_buffer_active_clients[resolve_bufnr(bufnr)] or {})[client_id] == true end --- Gets a client by id, or nil if the id is invalid. --- The returned client may not yet be fully initialized. -- ---@param client_id client id number +---@param client_id client id number --- ---@returns |vim.lsp.client| object, or nil +---@returns |vim.lsp.client| object, or nil function lsp.get_client_by_id(client_id) return active_clients[client_id] or uninitialized_clients[client_id] end --- Returns list of buffers attached to client_id. -- ---@param client_id client id ---@returns list of buffer ids +---@param client_id client id +---@returns list of buffer ids function lsp.get_buffers_by_client_id(client_id) local active_client_buffers = all_client_active_buffers[client_id] if active_client_buffers then @@ -1188,8 +1190,8 @@ end --- By default asks the server to shutdown, unless stop was requested --- already for this client, then force-shutdown is attempted. --- ---@param client_id client id or |vim.lsp.client| object, or list thereof ---@param force boolean (optional) shutdown forcefully +---@param client_id client id or |vim.lsp.client| object, or list thereof +---@param force boolean (optional) shutdown forcefully function lsp.stop_client(client_id, force) local ids = type(client_id) == 'table' and client_id or {client_id} for _, id in ipairs(ids) do @@ -1205,7 +1207,7 @@ end --- Gets all active clients. --- ---@returns Table of |vim.lsp.client| objects +---@returns Table of |vim.lsp.client| objects function lsp.get_active_clients() return vim.tbl_values(active_clients) end @@ -1236,13 +1238,13 @@ nvim_command("autocmd VimLeavePre * lua vim.lsp._vim_exit_handler()") --- Sends an async request for all active clients attached to the --- buffer. --- ---@param bufnr (number) Buffer handle, or 0 for current. ---@param method (string) LSP method name ---@param params (optional, table) Parameters to send to the server ---@param handler (optional, function) See |lsp-handler| +---@param bufnr (number) Buffer handle, or 0 for current. +---@param method (string) LSP method name +---@param params (optional, table) Parameters to send to the server +---@param handler (optional, function) See |lsp-handler| -- If nil, follows resolution strategy defined in |lsp-handler-configuration| -- ---@returns 2-tuple: +---@returns 2-tuple: --- - Map of client-id:request-id pairs for all successful requests. --- - Function which can be used to cancel all the requests. You could instead --- iterate all clients and call their `cancel_request()` methods. @@ -1274,7 +1276,7 @@ function lsp.buf_request(bufnr, method, params, handler) local unsupported_err = lsp._unsupported_method(method) handler = handler or lsp.handlers[method] if handler then - handler(unsupported_err, method, bufnr) + handler(unsupported_err, nil, {method=method, bufnr=bufnr}) end return end @@ -1294,14 +1296,14 @@ end ---Parameters are the same as |vim.lsp.buf_request()| but the return result and callback are ---different. --- ---@param bufnr (number) Buffer handle, or 0 for current. ---@param method (string) LSP method name ---@param params (optional, table) Parameters to send to the server ---@param callback (function) The callback to call when all requests are finished. +---@param bufnr (number) Buffer handle, or 0 for current. +---@param method (string) LSP method name +---@param params (optional, table) Parameters to send to the server +---@param callback (function) The callback to call when all requests are finished. -- Unlike `buf_request`, this will collect all the responses from each server instead of handling them. -- A map of client_id:request_result will be provided to the callback -- ---@returns (function) A function that will cancel all requests which is the same as the one returned from `buf_request`. +---@returns (function) A function that will cancel all requests which is the same as the one returned from `buf_request`. function lsp.buf_request_all(bufnr, method, params, callback) local request_results = {} local result_count = 0 @@ -1314,8 +1316,8 @@ function lsp.buf_request_all(bufnr, method, params, callback) end end) - local function _sync_handler(err, _, result, client_id) - request_results[client_id] = { error = err, result = result } + local function _sync_handler(err, result, ctx) + request_results[ctx.client_id] = { error = err, result = result } result_count = result_count + 1 set_expected_result_count() @@ -1335,13 +1337,13 @@ end --- Parameters are the same as |vim.lsp.buf_request()| but the return result is --- different. Wait maximum of {timeout_ms} (default 1000) ms. --- ---@param bufnr (number) Buffer handle, or 0 for current. ---@param method (string) LSP method name ---@param params (optional, table) Parameters to send to the server ---@param timeout_ms (optional, number, default=1000) Maximum time in +---@param bufnr (number) Buffer handle, or 0 for current. +---@param method (string) LSP method name +---@param params (optional, table) Parameters to send to the server +---@param timeout_ms (optional, number, default=1000) Maximum time in --- milliseconds to wait for a result. --- ---@returns Map of client_id:request_result. On timeout, cancel or error, +---@returns Map of client_id:request_result. On timeout, cancel or error, --- returns `(nil, err)` where `err` is a string describing the failure --- reason. function lsp.buf_request_sync(bufnr, method, params, timeout_ms) @@ -1364,11 +1366,11 @@ function lsp.buf_request_sync(bufnr, method, params, timeout_ms) end --- Send a notification to a server ---@param bufnr [number] (optional): The number of the buffer ---@param method [string]: Name of the request method ---@param params [string]: Arguments to send to the server +---@param bufnr [number] (optional): The number of the buffer +---@param method [string]: Name of the request method +---@param params [string]: Arguments to send to the server --- ---@returns true if any client returns true; false otherwise +---@returns true if any client returns true; false otherwise function lsp.buf_notify(bufnr, method, params) validate { bufnr = { bufnr, 'n', true }; @@ -1383,14 +1385,14 @@ end --- Implements 'omnifunc' compatible LSP completion. --- ---@see |complete-functions| ---@see |complete-items| ---@see |CompleteDone| +---@see |complete-functions| +---@see |complete-items| +---@see |CompleteDone| --- ---@param findstart 0 or 1, decides behavior ---@param base If findstart=0, text to match against +---@param findstart 0 or 1, decides behavior +---@param base If findstart=0, text to match against --- ---@returns (number) Decided by `findstart`: +---@returns (number) Decided by `findstart`: --- - findstart=0: column where the completion starts, or -2 or -3 --- - findstart=1: list of matches (actually just calls |complete()|) function lsp.omnifunc(findstart, base) @@ -1421,7 +1423,7 @@ function lsp.omnifunc(findstart, base) local params = util.make_position_params() local items = {} - lsp.buf_request(bufnr, 'textDocument/completion', params, function(err, _, result) + lsp.buf_request(bufnr, 'textDocument/completion', params, function(err, result) if err or not result or vim.fn.mode() ~= "i" then return end local matches = util.text_document_completion_list_to_complete_items(result, prefix) -- TODO(ashkan): is this the best way to do this? @@ -1436,8 +1438,8 @@ end ---Checks whether a client is stopped. --- ---@param client_id (Number) ---@returns true if client is stopped, false otherwise. +---@param client_id (Number) +---@returns true if client is stopped, false otherwise. function lsp.client_is_stopped(client_id) return active_clients[client_id] == nil end @@ -1445,7 +1447,7 @@ end --- Gets a map of client_id:client pairs for the given buffer, where each value --- is a |vim.lsp.client| object. --- ---@param bufnr (optional, number): Buffer handle, or 0 for current +---@param bufnr (optional, number): Buffer handle, or 0 for current function lsp.buf_get_clients(bufnr) bufnr = resolve_bufnr(bufnr) local result = {} @@ -1470,9 +1472,9 @@ lsp.log_levels = log.levels --- --- Use `lsp.log_levels` for reverse lookup. --- ---@see |vim.lsp.log_levels| +---@see |vim.lsp.log_levels| --- ---@param level [number|string] the case insensitive level name or number +---@param level [number|string] the case insensitive level name or number function lsp.set_log_level(level) if type(level) == 'string' or type(level) == 'number' then log.set_level(level) @@ -1482,7 +1484,7 @@ function lsp.set_log_level(level) end --- Gets the path of the logfile used by the LSP client. ---@returns (String) Path to logfile. +---@returns (String) Path to logfile. function lsp.get_log_path() return log.get_filename() end @@ -1493,11 +1495,11 @@ function lsp.for_each_buffer_client(bufnr, fn) end --- Function to manage overriding defaults for LSP handlers. ---@param handler (function) See |lsp-handler| ---@param override_config (table) Table containing the keys to override behavior of the {handler} +---@param handler (function) See |lsp-handler| +---@param override_config (table) Table containing the keys to override behavior of the {handler} function lsp.with(handler, override_config) - return function(err, method, params, client_id, bufnr, config) - return handler(err, method, params, client_id, bufnr, vim.tbl_deep_extend("force", config or {}, override_config)) + return function(err, result, ctx, config) + return handler(err, result, ctx, vim.tbl_deep_extend("force", config or {}, override_config)) end end @@ -1532,8 +1534,34 @@ function lsp._with_extend(name, options, user_config) return resulting_config end --- Define the LspDiagnostics signs if they're not defined already. -require('vim.lsp.diagnostic')._define_default_signs_and_highlights() + +--- Registry for client side commands. +--- This is an extension point for plugins to handle custom commands which are +--- not part of the core language server protocol specification. +--- +--- The registry is a table where the key is a unique command name, +--- and the value is a function which is called if any LSP action +--- (code action, code lenses, ...) triggers the command. +--- +--- If a LSP response contains a command for which no matching entry is +--- available in this registry, the command will be executed via the LSP server +--- using `workspace/executeCommand`. +--- +--- The first argument to the function will be the `Command`: +-- Command +-- title: String +-- command: String +-- arguments?: any[] +-- +--- The second argument is the `ctx` of |lsp-handler| +lsp.commands = setmetatable({}, { + __newindex = function(tbl, key, value) + assert(type(key) == 'string', "The key for commands in `vim.lsp.commands` must be a string") + assert(type(value) == 'function', "Command added to `vim.lsp.commands` must be a function") + rawset(tbl, key, value) + end; +}) + return lsp -- vim:sw=2 ts=2 et diff --git a/runtime/lua/vim/lsp/_snippet.lua b/runtime/lua/vim/lsp/_snippet.lua new file mode 100644 index 0000000000..0140b0aee3 --- /dev/null +++ b/runtime/lua/vim/lsp/_snippet.lua @@ -0,0 +1,399 @@ +local P = {} + +---Take characters until the target characters (The escape sequence is '\' + char) +---@param targets string[] The character list for stop consuming text. +---@param specials string[] If the character isn't contained in targets/specials, '\' will be left. +P.take_until = function(targets, specials) + targets = targets or {} + specials = specials or {} + + return function(input, pos) + local new_pos = pos + local raw = {} + local esc = {} + while new_pos <= #input do + local c = string.sub(input, new_pos, new_pos) + if c == '\\' then + table.insert(raw, '\\') + new_pos = new_pos + 1 + c = string.sub(input, new_pos, new_pos) + if not vim.tbl_contains(targets, c) and not vim.tbl_contains(specials, c) then + table.insert(esc, '\\') + end + table.insert(raw, c) + table.insert(esc, c) + new_pos = new_pos + 1 + else + if vim.tbl_contains(targets, c) then + break + end + table.insert(raw, c) + table.insert(esc, c) + new_pos = new_pos + 1 + end + end + + if new_pos == pos then + return P.unmatch(pos) + end + + return { + parsed = true, + value = { + raw = table.concat(raw, ''), + esc = table.concat(esc, '') + }, + pos = new_pos, + } + end +end + +P.unmatch = function(pos) + return { + parsed = false, + value = nil, + pos = pos, + } +end + +P.map = function(parser, map) + return function(input, pos) + local result = parser(input, pos) + if result.parsed then + return { + parsed = true, + value = map(result.value), + pos = result.pos, + } + end + return P.unmatch(pos) + end +end + +P.lazy = function(factory) + return function(input, pos) + return factory()(input, pos) + end +end + +P.token = function(token) + return function(input, pos) + local maybe_token = string.sub(input, pos, pos + #token - 1) + if token == maybe_token then + return { + parsed = true, + value = maybe_token, + pos = pos + #token, + } + end + return P.unmatch(pos) + end +end + +P.pattern = function(p) + return function(input, pos) + local maybe_match = string.match(string.sub(input, pos), '^' .. p) + if maybe_match then + return { + parsed = true, + value = maybe_match, + pos = pos + #maybe_match, + } + end + return P.unmatch(pos) + end +end + +P.many = function(parser) + return function(input, pos) + local values = {} + local new_pos = pos + while new_pos <= #input do + local result = parser(input, new_pos) + if not result.parsed then + break + end + table.insert(values, result.value) + new_pos = result.pos + end + if #values > 0 then + return { + parsed = true, + value = values, + pos = new_pos, + } + end + return P.unmatch(pos) + end +end + +P.any = function(...) + local parsers = { ... } + return function(input, pos) + for _, parser in ipairs(parsers) do + local result = parser(input, pos) + if result.parsed then + return result + end + end + return P.unmatch(pos) + end +end + +P.opt = function(parser) + return function(input, pos) + local result = parser(input, pos) + return { + parsed = true, + value = result.value, + pos = result.pos, + } + end +end + +P.seq = function(...) + local parsers = { ... } + return function(input, pos) + local values = {} + local new_pos = pos + for _, parser in ipairs(parsers) do + local result = parser(input, new_pos) + if result.parsed then + table.insert(values, result.value) + new_pos = result.pos + else + return P.unmatch(pos) + end + end + return { + parsed = true, + value = values, + pos = new_pos, + } + end +end + +local Node = {} + +Node.Type = { + SNIPPET = 0, + TABSTOP = 1, + PLACEHOLDER = 2, + VARIABLE = 3, + CHOICE = 4, + TRANSFORM = 5, + FORMAT = 6, + TEXT = 7, +} + +function Node:__tostring() + local insert_text = {} + if self.type == Node.Type.SNIPPET then + for _, c in ipairs(self.children) do + table.insert(insert_text, tostring(c)) + end + elseif self.type == Node.Type.CHOICE then + table.insert(insert_text, self.items[1]) + elseif self.type == Node.Type.PLACEHOLDER then + for _, c in ipairs(self.children or {}) do + table.insert(insert_text, tostring(c)) + end + elseif self.type == Node.Type.TEXT then + table.insert(insert_text, self.esc) + end + return table.concat(insert_text, '') +end + +--@see https://code.visualstudio.com/docs/editor/userdefinedsnippets#_grammar + +local S = {} +S.dollar = P.token('$') +S.open = P.token('{') +S.close = P.token('}') +S.colon = P.token(':') +S.slash = P.token('/') +S.comma = P.token(',') +S.pipe = P.token('|') +S.plus = P.token('+') +S.minus = P.token('-') +S.question = P.token('?') +S.int = P.map(P.pattern('[0-9]+'), function(value) + return tonumber(value, 10) +end) +S.var = P.pattern('[%a_][%w_]+') +S.text = function(targets, specials) + return P.map(P.take_until(targets, specials), function(value) + return setmetatable({ + type = Node.Type.TEXT, + raw = value.raw, + esc = value.esc, + }, Node) + end) +end + +S.toplevel = P.lazy(function() + return P.any(S.placeholder, S.tabstop, S.variable, S.choice) +end) + +S.format = P.any( + P.map(P.seq(S.dollar, S.int), function(values) + return setmetatable({ + type = Node.Type.FORMAT, + capture_index = values[2], + }, Node) + end), + P.map(P.seq(S.dollar, S.open, S.int, S.close), function(values) + return setmetatable({ + type = Node.Type.FORMAT, + capture_index = values[3], + }, Node) + end), + P.map(P.seq(S.dollar, S.open, S.int, S.colon, S.slash, P.any( + P.token('upcase'), + P.token('downcase'), + P.token('capitalize'), + P.token('camelcase'), + P.token('pascalcase') + ), S.close), function(values) + return setmetatable({ + type = Node.Type.FORMAT, + capture_index = values[3], + modifier = values[6], + }, Node) + end), + P.map(P.seq(S.dollar, S.open, S.int, S.colon, P.any( + P.seq(S.question, P.take_until({ ':' }, { '\\' }), S.colon, P.take_until({ '}' }, { '\\' })), + P.seq(S.plus, P.take_until({ '}' }, { '\\' })), + P.seq(S.minus, P.take_until({ '}' }, { '\\' })) + ), S.close), function(values) + return setmetatable({ + type = Node.Type.FORMAT, + capture_index = values[3], + if_text = values[5][2].esc, + else_text = (values[5][4] or {}).esc, + }, Node) + end) +) + +S.transform = P.map(P.seq( + S.slash, + P.take_until({ '/' }, { '\\' }), + S.slash, + P.many(P.any(S.format, S.text({ '$', '/' }, { '\\' }))), + S.slash, + P.opt(P.pattern('[ig]+')) +), function(values) + return setmetatable({ + type = Node.Type.TRANSFORM, + pattern = values[2].raw, + format = values[4], + option = values[6], + }, Node) +end) + +S.tabstop = P.any( + P.map(P.seq(S.dollar, S.int), function(values) + return setmetatable({ + type = Node.Type.TABSTOP, + tabstop = values[2], + }, Node) + end), + P.map(P.seq(S.dollar, S.open, S.int, S.close), function(values) + return setmetatable({ + type = Node.Type.TABSTOP, + tabstop = values[3], + }, Node) + end), + P.map(P.seq(S.dollar, S.open, S.int, S.transform, S.close), function(values) + return setmetatable({ + type = Node.Type.TABSTOP, + tabstop = values[3], + transform = values[4], + }, Node) + end) +) + +S.placeholder = P.any( + P.map(P.seq(S.dollar, S.open, S.int, S.colon, P.many(P.any(S.toplevel, S.text({ '$', '}' }, { '\\' }))), S.close), function(values) + return setmetatable({ + type = Node.Type.PLACEHOLDER, + tabstop = values[3], + children = values[5], + }, Node) + end) +) + +S.choice = P.map(P.seq( + S.dollar, + S.open, + S.int, + S.pipe, + P.many( + P.map(P.seq(S.text({ ',', '|' }), P.opt(S.comma)), function(values) + return values[1].esc + end) + ), + S.pipe, + S.close +), function(values) + return setmetatable({ + type = Node.Type.CHOICE, + tabstop = values[3], + items = values[5], + }, Node) +end) + +S.variable = P.any( + P.map(P.seq(S.dollar, S.var), function(values) + return setmetatable({ + type = Node.Type.VARIABLE, + name = values[2], + }, Node) + end), + P.map(P.seq(S.dollar, S.open, S.var, S.close), function(values) + return setmetatable({ + type = Node.Type.VARIABLE, + name = values[3], + }, Node) + end), + P.map(P.seq(S.dollar, S.open, S.var, S.transform, S.close), function(values) + return setmetatable({ + type = Node.Type.VARIABLE, + name = values[3], + transform = values[4], + }, Node) + end), + P.map(P.seq(S.dollar, S.open, S.var, S.colon, P.many(P.any(S.toplevel, S.text({ '$', '}' }, { '\\' }))), S.close), function(values) + return setmetatable({ + type = Node.Type.VARIABLE, + name = values[3], + children = values[5], + }, Node) + end) +) + +S.snippet = P.map(P.many(P.any(S.toplevel, S.text({ '$' }, { '}', '\\' }))), function(values) + return setmetatable({ + type = Node.Type.SNIPPET, + children = values, + }, Node) +end) + +local M = {} + +---The snippet node type enum +---@types table<string, number> +M.NodeType = Node.Type + +---Parse snippet string and returns the AST +---@param input string +---@return table +function M.parse(input) + local result = S.snippet(input, 1) + if not result.parsed then + error('snippet parsing failed.') + end + return result.value +end + +return M diff --git a/runtime/lua/vim/lsp/buf.lua b/runtime/lua/vim/lsp/buf.lua index b13d662ccb..245f29943e 100644 --- a/runtime/lua/vim/lsp/buf.lua +++ b/runtime/lua/vim/lsp/buf.lua @@ -5,7 +5,7 @@ local util = require 'vim.lsp.util' local M = {} ---@private +---@private --- Returns nil if {status} is false or nil, otherwise returns the rest of the --- arguments. local function ok_or_nil(status, ...) @@ -13,31 +13,31 @@ local function ok_or_nil(status, ...) return ... end ---@private +---@private --- Swallows errors. --- ---@param fn Function to run ---@param ... Function arguments ---@returns Result of `fn(...)` if there are no errors, otherwise nil. +---@param fn Function to run +---@param ... Function arguments +---@returns Result of `fn(...)` if there are no errors, otherwise nil. --- Returns nil if errors occur during {fn}, otherwise returns local function npcall(fn, ...) return ok_or_nil(pcall(fn, ...)) end ---@private +---@private --- Sends an async request to all active clients attached to the current --- buffer. --- ---@param method (string) LSP method name ---@param params (optional, table) Parameters to send to the server ---@param handler (optional, functionnil) See |lsp-handler|. Follows |lsp-handler-resolution| +---@param method (string) LSP method name +---@param params (optional, table) Parameters to send to the server +---@param handler (optional, functionnil) See |lsp-handler|. Follows |lsp-handler-resolution| -- ---@returns 2-tuple: +---@returns 2-tuple: --- - Map of client-id:request-id pairs for all successful requests. --- - Function which can be used to cancel all the requests. You could instead --- iterate all clients and call their `cancel_request()` methods. --- ---@see |vim.lsp.buf_request()| +---@see |vim.lsp.buf_request()| local function request(method, params, handler) validate { method = {method, 's'}; @@ -49,7 +49,7 @@ end --- Checks whether the language servers attached to the current buffer are --- ready. --- ---@returns `true` if server responds. +---@returns `true` if server responds. function M.server_ready() return not not vim.lsp.buf_notify(0, "window/progress", {}) end @@ -62,7 +62,7 @@ function M.hover() end --- Jumps to the declaration of the symbol under the cursor. ---@note Many servers do not implement this method. Generally, see |vim.lsp.buf.definition()| instead. +---@note Many servers do not implement this method. Generally, see |vim.lsp.buf.definition()| instead. --- function M.declaration() local params = util.make_position_params() @@ -100,22 +100,22 @@ end --- Retrieves the completion items at the current cursor position. Can only be --- called in Insert mode. --- ---@param context (context support not yet implemented) Additional information +---@param context (context support not yet implemented) Additional information --- about the context in which a completion was triggered (how it was triggered, --- and by which trigger character, if applicable) --- ---@see |vim.lsp.protocol.constants.CompletionTriggerKind| +---@see |vim.lsp.protocol.constants.CompletionTriggerKind| function M.completion(context) local params = util.make_position_params() params.context = context return request('textDocument/completion', params) end ---@private +---@private --- If there is more than one client that supports the given method, --- asks the user to select one. -- ---@returns The client that the user selected or nil +---@returns The client that the user selected or nil local function select_client(method) local clients = vim.tbl_values(vim.lsp.buf_get_clients()); clients = vim.tbl_filter(function (client) @@ -126,7 +126,7 @@ local function select_client(method) if #clients > 1 then local choices = {} - for k,v in ipairs(clients) do + for k,v in pairs(clients) do table.insert(choices, string.format("%d %s", k, v.name)) end local user_choice = vim.fn.confirm( @@ -146,11 +146,11 @@ end --- Formats the current buffer. --- ---@param options (optional, table) Can be used to specify FormattingOptions. +---@param options (optional, table) Can be used to specify FormattingOptions. --- Some unspecified options will be automatically derived from the current --- Neovim options. -- ---@see https://microsoft.github.io/language-server-protocol/specification#textDocument_formatting +---@see https://microsoft.github.io/language-server-protocol/specification#textDocument_formatting function M.formatting(options) local client = select_client("textDocument/formatting") if client == nil then return end @@ -168,9 +168,9 @@ end --- vim.api.nvim_command[[autocmd BufWritePre <buffer> lua vim.lsp.buf.formatting_sync()]] --- </pre> --- ---@param options Table with valid `FormattingOptions` entries ---@param timeout_ms (number) Request timeout ---@see |vim.lsp.buf.formatting_seq_sync| +---@param options Table with valid `FormattingOptions` entries +---@param timeout_ms (number) Request timeout +---@see |vim.lsp.buf.formatting_seq_sync| function M.formatting_sync(options, timeout_ms) local client = select_client("textDocument/formatting") if client == nil then return end @@ -195,18 +195,18 @@ end --- vim.api.nvim_command[[autocmd BufWritePre <buffer> lua vim.lsp.buf.formatting_seq_sync()]] --- </pre> --- ---@param options (optional, table) `FormattingOptions` entries ---@param timeout_ms (optional, number) Request timeout ---@param order (optional, table) List of client names. Formatting is requested from clients +---@param options (optional, table) `FormattingOptions` entries +---@param timeout_ms (optional, number) Request timeout +---@param order (optional, table) List of client names. Formatting is requested from clients ---in the following order: first all clients that are not in the `order` list, then ---the remaining clients in the order as they occur in the `order` list. function M.formatting_seq_sync(options, timeout_ms, order) local clients = vim.tbl_values(vim.lsp.buf_get_clients()); -- sort the clients according to `order` - for _, client_name in ipairs(order or {}) do + for _, client_name in pairs(order or {}) do -- if the client exists, move to the end of the list - for i, client in ipairs(clients) do + for i, client in pairs(clients) do if client.name == client_name then table.insert(clients, table.remove(clients, i)) break @@ -215,7 +215,7 @@ function M.formatting_seq_sync(options, timeout_ms, order) end -- loop through the clients and make synchronous formatting requests - for _, client in ipairs(clients) do + for _, client in pairs(clients) do if client.resolved_capabilities.document_formatting then local params = util.make_formatting_params(options) local result, err = client.request_sync("textDocument/formatting", params, timeout_ms, vim.api.nvim_get_current_buf()) @@ -230,10 +230,10 @@ end --- Formats a given range. --- ---@param options Table with valid `FormattingOptions` entries. ---@param start_pos ({number, number}, optional) mark-indexed position. +---@param options Table with valid `FormattingOptions` entries. +---@param start_pos ({number, number}, optional) mark-indexed position. ---Defaults to the start of the last visual selection. ---@param end_pos ({number, number}, optional) mark-indexed position. +---@param end_pos ({number, number}, optional) mark-indexed position. ---Defaults to the end of the last visual selection. function M.range_formatting(options, start_pos, end_pos) local client = select_client("textDocument/rangeFormatting") @@ -246,29 +246,49 @@ end --- Renames all references to the symbol under the cursor. --- ---@param new_name (string) If not provided, the user will be prompted for a new +---@param new_name (string) If not provided, the user will be prompted for a new ---name using |input()|. function M.rename(new_name) - -- TODO(ashkan) use prepareRename - -- * result: [`Range`](#range) \| `{ range: Range, placeholder: string }` \| `null` describing the range of the string to rename and optionally a placeholder text of the string content to be renamed. If `null` is returned then it is deemed that a 'textDocument/rename' request is not valid at the given position. local params = util.make_position_params() - new_name = new_name or npcall(vfn.input, "New Name: ", vfn.expand('<cword>')) - if not (new_name and #new_name > 0) then return end - params.newName = new_name - request('textDocument/rename', params) + local function prepare_rename(err, result) + if err == nil and result == nil then + vim.notify('nothing to rename', vim.log.levels.INFO) + return + end + if result and result.placeholder then + new_name = new_name or npcall(vfn.input, "New Name: ", result.placeholder) + elseif result and result.start and result['end'] and + result.start.line == result['end'].line then + local line = vfn.getline(result.start.line+1) + local start_char = result.start.character+1 + local end_char = result['end'].character + new_name = new_name or npcall(vfn.input, "New Name: ", string.sub(line, start_char, end_char)) + else + -- fallback to guessing symbol using <cword> + -- + -- this can happen if the language server does not support prepareRename, + -- returns an unexpected response, or requests for "default behavior" + -- + -- see https://microsoft.github.io/language-server-protocol/specification#textDocument_prepareRename + new_name = new_name or npcall(vfn.input, "New Name: ", vfn.expand('<cword>')) + end + if not (new_name and #new_name > 0) then return end + params.newName = new_name + request('textDocument/rename', params) + end + request('textDocument/prepareRename', params, prepare_rename) end --- Lists all the references to the symbol under the cursor in the quickfix window. --- ---@param context (table) Context for the request ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_references +---@param context (table) Context for the request +---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_references function M.references(context) validate { context = { context, 't', true } } local params = util.make_position_params() params.context = context or { includeDeclaration = true; } - params[vim.type_idx] = vim.types.dictionary request('textDocument/references', params) end @@ -279,14 +299,14 @@ function M.document_symbol() request('textDocument/documentSymbol', params) end ---@private +---@private local function pick_call_hierarchy_item(call_hierarchy_items) if not call_hierarchy_items then return end if #call_hierarchy_items == 1 then return call_hierarchy_items[1] end local items = {} - for i, item in ipairs(call_hierarchy_items) do + for i, item in pairs(call_hierarchy_items) do local entry = item.detail or item.name table.insert(items, string.format("%d. %s", i, entry)) end @@ -297,16 +317,24 @@ local function pick_call_hierarchy_item(call_hierarchy_items) return choice end ---@private +---@private local function call_hierarchy(method) local params = util.make_position_params() - request('textDocument/prepareCallHierarchy', params, function(err, _, result) + request('textDocument/prepareCallHierarchy', params, function(err, result, ctx) if err then vim.notify(err.message, vim.log.levels.WARN) return end local call_hierarchy_item = pick_call_hierarchy_item(result) - vim.lsp.buf_request(0, method, { item = call_hierarchy_item }) + local client = vim.lsp.get_client_by_id(ctx.client_id) + if client then + client.request(method, { item = call_hierarchy_item }, nil, ctx.bufnr) + else + vim.notify(string.format( + 'Client with id=%d disappeared during call hierarchy request', ctx.client_id), + vim.log.levels.WARN + ) + end end) end @@ -328,8 +356,8 @@ end --- function M.list_workspace_folders() local workspace_folders = {} - for _, client in ipairs(vim.lsp.buf_get_clients()) do - for _, folder in ipairs(client.workspaceFolders) do + for _, client in pairs(vim.lsp.buf_get_clients()) do + for _, folder in pairs(client.workspaceFolders) do table.insert(workspace_folders, folder.name) end end @@ -347,9 +375,9 @@ function M.add_workspace_folder(workspace_folder) return end local params = util.make_workspace_params({{uri = vim.uri_from_fname(workspace_folder); name = workspace_folder}}, {{}}) - for _, client in ipairs(vim.lsp.buf_get_clients()) do + for _, client in pairs(vim.lsp.buf_get_clients()) do local found = false - for _, folder in ipairs(client.workspaceFolders) do + for _, folder in pairs(client.workspaceFolders) do if folder.name == workspace_folder then found = true print(workspace_folder, "is already part of this workspace") @@ -371,8 +399,8 @@ function M.remove_workspace_folder(workspace_folder) vim.api.nvim_command("redraw") if not (workspace_folder and #workspace_folder > 0) then return end local params = util.make_workspace_params({{}}, {{uri = vim.uri_from_fname(workspace_folder); name = workspace_folder}}) - for _, client in ipairs(vim.lsp.buf_get_clients()) do - for idx, folder in ipairs(client.workspaceFolders) do + for _, client in pairs(vim.lsp.buf_get_clients()) do + for idx, folder in pairs(client.workspaceFolders) do if folder.name == workspace_folder then vim.lsp.buf_notify(0, 'workspace/didChangeWorkspaceFolders', params) client.workspaceFolders[idx] = nil @@ -389,7 +417,7 @@ end --- call, the user is prompted to enter a string on the command line. An empty --- string means no filtering is done. --- ---@param query (string, optional) +---@param query (string, optional) function M.workspace_symbol(query) query = query or npcall(vfn.input, "Query: ") local params = {query = query} @@ -422,38 +450,157 @@ function M.clear_references() util.buf_clear_references() end ---- Selects a code action from the input list that is available at the current ---- cursor position. + +---@private -- ---@param context: (table, optional) Valid `CodeActionContext` object ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_codeAction +--- This is not public because the main extension point is +--- vim.ui.select which can be overridden independently. +--- +--- Can't call/use vim.lsp.handlers['textDocument/codeAction'] because it expects +--- `(err, CodeAction[] | Command[], ctx)`, but we want to aggregate the results +--- from multiple clients to have 1 single UI prompt for the user, yet we still +--- need to be able to link a `CodeAction|Command` to the right client for +--- `codeAction/resolve` +local function on_code_action_results(results, ctx) + local action_tuples = {} + for client_id, result in pairs(results) do + for _, action in pairs(result.result or {}) do + table.insert(action_tuples, { client_id, action }) + end + end + if #action_tuples == 0 then + vim.notify('No code actions available', vim.log.levels.INFO) + return + end + + ---@private + local function apply_action(action, client) + if action.edit then + util.apply_workspace_edit(action.edit) + end + if action.command then + local command = type(action.command) == 'table' and action.command or action + local fn = vim.lsp.commands[command.command] + if fn then + local enriched_ctx = vim.deepcopy(ctx) + enriched_ctx.client_id = client.id + fn(command, ctx) + else + M.execute_command(command) + end + end + end + + ---@private + local function on_user_choice(action_tuple) + if not action_tuple then + return + end + -- textDocument/codeAction can return either Command[] or CodeAction[] + -- + -- CodeAction + -- ... + -- edit?: WorkspaceEdit -- <- must be applied before command + -- command?: Command + -- + -- Command: + -- title: string + -- command: string + -- arguments?: any[] + -- + local client = vim.lsp.get_client_by_id(action_tuple[1]) + local action = action_tuple[2] + if not action.edit + and client + and type(client.resolved_capabilities.code_action) == 'table' + and client.resolved_capabilities.code_action.resolveProvider then + + client.request('codeAction/resolve', action, function(err, resolved_action) + if err then + vim.notify(err.code .. ': ' .. err.message, vim.log.levels.ERROR) + return + end + apply_action(resolved_action, client) + end) + else + apply_action(action, client) + end + end + + vim.ui.select(action_tuples, { + prompt = 'Code actions:', + format_item = function(action_tuple) + local title = action_tuple[2].title:gsub('\r\n', '\\r\\n') + return title:gsub('\n', '\\n') + end, + }, on_user_choice) +end + + +--- Requests code actions from all clients and calls the handler exactly once +--- with all aggregated results +---@private +local function code_action_request(params) + local bufnr = vim.api.nvim_get_current_buf() + local method = 'textDocument/codeAction' + vim.lsp.buf_request_all(bufnr, method, params, function(results) + on_code_action_results(results, { bufnr = bufnr, method = method, params = params }) + end) +end + +--- Selects a code action available at the current +--- cursor position. +--- +---@param context table|nil `CodeActionContext` of the LSP specification: +--- - diagnostics: (table|nil) +--- LSP `Diagnostic[]`. Inferred from the current +--- position if not provided. +--- - only: (string|nil) +--- LSP `CodeActionKind` used to filter the code actions. +--- Most language servers support values like `refactor` +--- or `quickfix`. +---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_codeAction function M.code_action(context) validate { context = { context, 't', true } } - context = context or { diagnostics = vim.lsp.diagnostic.get_line_diagnostics() } + context = context or {} + if not context.diagnostics then + context.diagnostics = vim.lsp.diagnostic.get_line_diagnostics() + end local params = util.make_range_params() params.context = context - request('textDocument/codeAction', params) + code_action_request(params) end --- Performs |vim.lsp.buf.code_action()| for a given range. --- ---@param context: (table, optional) Valid `CodeActionContext` object ---@param start_pos ({number, number}, optional) mark-indexed position. +--- +---@param context table|nil `CodeActionContext` of the LSP specification: +--- - diagnostics: (table|nil) +--- LSP `Diagnostic[]`. Inferred from the current +--- position if not provided. +--- - only: (string|nil) +--- LSP `CodeActionKind` used to filter the code actions. +--- Most language servers support values like `refactor` +--- or `quickfix`. +---@param start_pos ({number, number}, optional) mark-indexed position. ---Defaults to the start of the last visual selection. ---@param end_pos ({number, number}, optional) mark-indexed position. +---@param end_pos ({number, number}, optional) mark-indexed position. ---Defaults to the end of the last visual selection. function M.range_code_action(context, start_pos, end_pos) validate { context = { context, 't', true } } - context = context or { diagnostics = vim.lsp.diagnostic.get_line_diagnostics() } + context = context or {} + if not context.diagnostics then + context.diagnostics = vim.lsp.diagnostic.get_line_diagnostics() + end local params = util.make_given_range_params(start_pos, end_pos) params.context = context - request('textDocument/codeAction', params) + code_action_request(params) end --- Executes an LSP server command. --- ---@param command A valid `ExecuteCommandParams` object ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#workspace_executeCommand +---@param command A valid `ExecuteCommandParams` object +---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#workspace_executeCommand function M.execute_command(command) validate { command = { command.command, 's' }, diff --git a/runtime/lua/vim/lsp/codelens.lua b/runtime/lua/vim/lsp/codelens.lua index fbd37e3830..20b203fe99 100644 --- a/runtime/lua/vim/lsp/codelens.lua +++ b/runtime/lua/vim/lsp/codelens.lua @@ -22,19 +22,33 @@ local namespaces = setmetatable({}, { end; }) ---@private +---@private M.__namespaces = namespaces ---@private +---@private local function execute_lens(lens, bufnr, client_id) local line = lens.range.start.line api.nvim_buf_clear_namespace(bufnr, namespaces[client_id], line, line + 1) + local command = lens.command + local fn = vim.lsp.commands[command.command] + if fn then + fn(command, { bufnr = bufnr, client_id = client_id }) + return + end -- Need to use the client that returned the lens → must not use buf_request local client = vim.lsp.get_client_by_id(client_id) assert(client, 'Client is required to execute lens, client_id=' .. client_id) - client.request('workspace/executeCommand', lens.command, function(...) + local command_provider = client.server_capabilities.executeCommandProvider + local commands = type(command_provider) == 'table' and command_provider.commands or {} + if not vim.tbl_contains(commands, command.command) then + vim.notify(string.format( + "Language server does not support command `%s`. This command may require a client extension.", command.command), + vim.log.levels.WARN) + return + end + client.request('workspace/executeCommand', command, function(...) local result = vim.lsp.handlers['workspace/executeCommand'](...) M.refresh() return result @@ -44,9 +58,10 @@ end --- Return all lenses for the given buffer --- +---@param bufnr number Buffer number. 0 can be used for the current buffer. ---@return table (`CodeLens[]`) function M.get(bufnr) - local lenses_by_client = lens_cache_by_buf[bufnr] + local lenses_by_client = lens_cache_by_buf[bufnr or 0] if not lenses_by_client then return {} end local lenses = {} for _, client_lenses in pairs(lenses_by_client) do @@ -111,15 +126,19 @@ function M.display(lenses, bufnr, client_id) local ns = namespaces[client_id] local num_lines = api.nvim_buf_line_count(bufnr) for i = 0, num_lines do - local line_lenses = lenses_by_lnum[i] + local line_lenses = lenses_by_lnum[i] or {} api.nvim_buf_clear_namespace(bufnr, ns, i, i + 1) local chunks = {} - for _, lens in pairs(line_lenses or {}) do + local num_line_lenses = #line_lenses + for j, lens in ipairs(line_lenses) do local text = lens.command and lens.command.title or 'Unresolved lens ...' table.insert(chunks, {text, 'LspCodeLens' }) + if j < num_line_lenses then + table.insert(chunks, {' | ', 'LspCodeLensSeparator' }) + end end if #chunks > 0 then - api.nvim_buf_set_virtual_text(bufnr, ns, i, chunks, {}) + api.nvim_buf_set_extmark(bufnr, ns, i, 0, { virt_text = chunks }) end end end @@ -147,7 +166,7 @@ function M.save(lenses, bufnr, client_id) end ---@private +---@private local function resolve_lenses(lenses, bufnr, client_id, callback) lenses = lenses or {} local num_lens = vim.tbl_count(lenses) @@ -156,7 +175,7 @@ local function resolve_lenses(lenses, bufnr, client_id, callback) return end - --@private + ---@private local function countdown() num_lens = num_lens - 1 if num_lens == 0 then @@ -169,18 +188,18 @@ local function resolve_lenses(lenses, bufnr, client_id, callback) if lens.command then countdown() else - client.request('codeLens/resolve', lens, function(_, _, result) + client.request('codeLens/resolve', lens, function(_, result) if result and result.command then lens.command = result.command -- Eager display to have some sort of incremental feedback -- Once all lenses got resolved there will be a full redraw for all lenses -- So that multiple lens per line are properly displayed - api.nvim_buf_set_virtual_text( + api.nvim_buf_set_extmark( bufnr, ns, lens.range.start.line, - {{ lens.command.title, 'LspCodeLens' },}, - {} + 0, + { virt_text = {{ lens.command.title, 'LspCodeLens' }} } ) end countdown() @@ -192,17 +211,17 @@ end --- |lsp-handler| for the method `textDocument/codeLens` --- -function M.on_codelens(err, _, result, client_id, bufnr) +function M.on_codelens(err, result, ctx, _) assert(not err, vim.inspect(err)) - M.save(result, bufnr, client_id) + M.save(result, ctx.bufnr, ctx.client_id) -- Eager display for any resolved (and unresolved) lenses and refresh them -- once resolved. - M.display(result, bufnr, client_id) - resolve_lenses(result, bufnr, client_id, function() - M.display(result, bufnr, client_id) - active_refreshes[bufnr] = nil + M.display(result, ctx.bufnr, ctx.client_id) + resolve_lenses(result, ctx.bufnr, ctx.client_id, function() + M.display(result, ctx.bufnr, ctx.client_id) + active_refreshes[ctx.bufnr] = nil end) end diff --git a/runtime/lua/vim/lsp/diagnostic.lua b/runtime/lua/vim/lsp/diagnostic.lua index 64dde78f17..c6c08a15d3 100644 --- a/runtime/lua/vim/lsp/diagnostic.lua +++ b/runtime/lua/vim/lsp/diagnostic.lua @@ -1,126 +1,19 @@ -local api = vim.api -local validate = vim.validate - -local highlight = vim.highlight -local log = require('vim.lsp.log') -local protocol = require('vim.lsp.protocol') -local util = require('vim.lsp.util') - -local if_nil = vim.F.if_nil - ---@class DiagnosticSeverity -local DiagnosticSeverity = protocol.DiagnosticSeverity - -local to_severity = function(severity) - if not severity then return nil end - return type(severity) == 'string' and DiagnosticSeverity[severity] or severity -end - -local filter_to_severity_limit = function(severity, diagnostics) - local filter_level = to_severity(severity) - if not filter_level then - return diagnostics - end - - return vim.tbl_filter(function(t) return t.severity == filter_level end, diagnostics) -end - -local filter_by_severity_limit = function(severity_limit, diagnostics) - local filter_level = to_severity(severity_limit) - if not filter_level then - return diagnostics - end - - return vim.tbl_filter(function(t) return t.severity <= filter_level end, diagnostics) -end - -local to_position = function(position, bufnr) - vim.validate { position = {position, 't'} } - - return { - position.line, - util._get_line_byte_from_position(bufnr, position) - } -end - - ---@brief lsp-diagnostic --- ---@class Diagnostic ---@field range Range ---@field message string ---@field severity DiagnosticSeverity|nil ---@field code number | string ---@field source string ---@field tags DiagnosticTag[] ---@field relatedInformation DiagnosticRelatedInformation[] +---@class Diagnostic +---@field range Range +---@field message string +---@field severity DiagnosticSeverity|nil +---@field code number | string +---@field source string +---@field tags DiagnosticTag[] +---@field relatedInformation DiagnosticRelatedInformation[] local M = {} --- Diagnostic Highlights {{{ - --- TODO(tjdevries): Determine how to generate documentation for these --- and how to configure them to be easy for users. --- --- For now, just use the following script. It should work pretty good. ---[[ -local levels = {"Error", "Warning", "Information", "Hint" } - -local all_info = { - { "Default", "Used as the base highlight group, other highlight groups link to", }, - { "VirtualText", 'Used for "%s" diagnostic virtual text.\n See |vim.lsp.diagnostic.set_virtual_text()|', }, - { "Underline", 'Used to underline "%s" diagnostics.\n See |vim.lsp.diagnostic.set_underline()|', }, - { "Floating", 'Used to color "%s" diagnostic messages in diagnostics float.\n See |vim.lsp.diagnostic.show_line_diagnostics()|', }, - { "Sign", 'Used for "%s" signs in sing column.\n See |vim.lsp.diagnostic.set_signs()|', }, -} - -local results = {} -for _, info in ipairs(all_info) do - for _, level in ipairs(levels) do - local name = info[1] - local description = info[2] - local fullname = string.format("Lsp%s%s", name, level) - table.insert(results, string.format( - "%78s", string.format("*hl-%s*", fullname)) - ) - - table.insert(results, fullname) - table.insert(results, string.format(" %s", description)) - table.insert(results, "") - end -end - --- print(table.concat(results, '\n')) -vim.fn.setreg("*", table.concat(results, '\n')) ---]] - -local diagnostic_severities = { - [DiagnosticSeverity.Error] = { guifg = "Red" }; - [DiagnosticSeverity.Warning] = { guifg = "Orange" }; - [DiagnosticSeverity.Information] = { guifg = "LightBlue" }; - [DiagnosticSeverity.Hint] = { guifg = "LightGrey" }; -} - --- Make a map from DiagnosticSeverity -> Highlight Name -local make_highlight_map = function(base_name) - local result = {} - for k, _ in pairs(diagnostic_severities) do - result[k] = "LspDiagnostics" .. base_name .. DiagnosticSeverity[k] - end - - return result -end - -local default_highlight_map = make_highlight_map("Default") -local virtual_text_highlight_map = make_highlight_map("VirtualText") -local underline_highlight_map = make_highlight_map("Underline") -local floating_highlight_map = make_highlight_map("Floating") -local sign_highlight_map = make_highlight_map("Sign") - --- }}} --- Diagnostic Namespaces {{{ local DEFAULT_CLIENT_ID = -1 -local get_client_id = function(client_id) +---@private +local function get_client_id(client_id) if client_id == nil then client_id = DEFAULT_CLIENT_ID end @@ -128,472 +21,414 @@ local get_client_id = function(client_id) return client_id end -local get_bufnr = function(bufnr) +---@private +local function get_bufnr(bufnr) if not bufnr then - return api.nvim_get_current_buf() + return vim.api.nvim_get_current_buf() elseif bufnr == 0 then - return api.nvim_get_current_buf() + return vim.api.nvim_get_current_buf() end return bufnr end - ---- Create a namespace table, used to track a client's buffer local items -local _make_namespace_table = function(namespace, api_namespace) - vim.validate { namespace = { namespace, 's' } } - - return setmetatable({ - [DEFAULT_CLIENT_ID] = api.nvim_create_namespace(namespace) - }, { - __index = function(t, client_id) - client_id = get_client_id(client_id) - - if rawget(t, client_id) == nil then - local value = string.format("%s:%s", namespace, client_id) - - if api_namespace then - value = api.nvim_create_namespace(value) - end - - rawset(t, client_id, value) - end - - return rawget(t, client_id) - end - }) +---@private +local function severity_lsp_to_vim(severity) + if type(severity) == 'string' then + severity = vim.lsp.protocol.DiagnosticSeverity[severity] + end + return severity end -local _diagnostic_namespaces = _make_namespace_table("vim_lsp_diagnostics", true) -local _sign_namespaces = _make_namespace_table("vim_lsp_signs", false) - ---@private -function M._get_diagnostic_namespace(client_id) - return _diagnostic_namespaces[client_id] +---@private +local function severity_vim_to_lsp(severity) + if type(severity) == 'string' then + severity = vim.diagnostic.severity[severity] + end + return severity end ---@private -function M._get_sign_namespace(client_id) - return _sign_namespaces[client_id] -end --- }}} --- Diagnostic Buffer & Client metatables {{{ -local bufnr_and_client_cacher_mt = { - __index = function(t, bufnr) - if bufnr == 0 or bufnr == nil then - bufnr = vim.api.nvim_get_current_buf() - end +---@private +local function line_byte_from_position(lines, lnum, col, offset_encoding) + if not lines or offset_encoding == "utf-8" then + return col + end - if rawget(t, bufnr) == nil then - rawset(t, bufnr, {}) - end + local line = lines[lnum + 1] + local ok, result = pcall(vim.str_byteindex, line, col, offset_encoding == "utf-16") + if ok then + return result + end - return rawget(t, bufnr) - end, + return col +end - __newindex = function(t, bufnr, v) - if bufnr == 0 or bufnr == nil then - bufnr = vim.api.nvim_get_current_buf() - end +---@private +local function get_buf_lines(bufnr) + if vim.api.nvim_buf_is_loaded(bufnr) then + return vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + end - rawset(t, bufnr, v) - end, -} --- }}} --- Diagnostic Saving & Caching {{{ -local _diagnostic_cleanup = setmetatable({}, bufnr_and_client_cacher_mt) -local diagnostic_cache = setmetatable({}, bufnr_and_client_cacher_mt) -local diagnostic_cache_lines = setmetatable({}, bufnr_and_client_cacher_mt) -local diagnostic_cache_counts = setmetatable({}, bufnr_and_client_cacher_mt) + local filename = vim.api.nvim_buf_get_name(bufnr) + local f = io.open(filename) + if not f then + return + end -local _bufs_waiting_to_update = setmetatable({}, bufnr_and_client_cacher_mt) + local content = f:read("*a") + if not content then + -- Some LSP servers report diagnostics at a directory level, in which case + -- io.read() returns nil + f:close() + return + end ---- Store Diagnostic[] by line ---- ----@param diagnostics Diagnostic[] ----@return table<number, Diagnostic[]> -local _diagnostic_lines = function(diagnostics) - if not diagnostics then return end + local lines = vim.split(content, "\n") + f:close() + return lines +end - local diagnostics_by_line = {} - for _, diagnostic in ipairs(diagnostics) do +---@private +local function diagnostic_lsp_to_vim(diagnostics, bufnr, client_id) + local buf_lines = get_buf_lines(bufnr) + local client = vim.lsp.get_client_by_id(client_id) + local offset_encoding = client and client.offset_encoding or "utf-16" + return vim.tbl_map(function(diagnostic) 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) + local _end = diagnostic.range["end"] + return { + lnum = start.line, + col = line_byte_from_position(buf_lines, start.line, start.character, offset_encoding), + end_lnum = _end.line, + end_col = line_byte_from_position(buf_lines, _end.line, _end.character, offset_encoding), + severity = severity_lsp_to_vim(diagnostic.severity), + message = diagnostic.message, + source = diagnostic.source, + user_data = { + lsp = { + code = diagnostic.code, + codeDescription = diagnostic.codeDescription, + tags = diagnostic.tags, + relatedInformation = diagnostic.relatedInformation, + data = diagnostic.data, + }, + }, + } + end, diagnostics) +end + +---@private +local function diagnostic_vim_to_lsp(diagnostics) + return vim.tbl_map(function(diagnostic) + return vim.tbl_extend("error", { + range = { + start = { + line = diagnostic.lnum, + character = diagnostic.col, + }, + ["end"] = { + line = diagnostic.end_lnum, + character = diagnostic.end_col, + }, + }, + severity = severity_vim_to_lsp(diagnostic.severity), + message = diagnostic.message, + source = diagnostic.source, + }, diagnostic.user_data and (diagnostic.user_data.lsp or {}) or {}) + end, diagnostics) +end + +local _client_namespaces = {} + +--- Get the diagnostic namespace associated with an LSP client |vim.diagnostic|. +--- +---@param client_id number The id of the LSP client +function M.get_namespace(client_id) + vim.validate { client_id = { client_id, 'n' } } + if not _client_namespaces[client_id] then + local name = string.format("vim.lsp.client-%d", client_id) + _client_namespaces[client_id] = vim.api.nvim_create_namespace(name) end - return diagnostics_by_line + return _client_namespaces[client_id] end ---- Get the count of M by Severity +--- Save diagnostics to the current buffer. --- +--- Handles saving diagnostics from multiple clients in the same buffer. ---@param diagnostics Diagnostic[] ----@return table<DiagnosticSeverity, number> -local _diagnostic_counts = function(diagnostics) - if not diagnostics then return end +---@param bufnr number +---@param client_id number +---@private +function M.save(diagnostics, bufnr, client_id) + local namespace = M.get_namespace(client_id) + vim.diagnostic.set(namespace, bufnr, diagnostic_lsp_to_vim(diagnostics, bufnr, client_id)) +end +-- }}} - local counts = {} - for _, diagnostic in pairs(diagnostics) do - if diagnostic.severity then - local val = counts[diagnostic.severity] - if val == nil then - val = 0 - end +--- |lsp-handler| for the method "textDocument/publishDiagnostics" +--- +--- See |vim.diagnostic.config()| for configuration options. Handler-specific +--- configuration can be set using |vim.lsp.with()|: +--- <pre> +--- vim.lsp.handlers["textDocument/publishDiagnostics"] = vim.lsp.with( +--- vim.lsp.diagnostic.on_publish_diagnostics, { +--- -- Enable underline, use default values +--- underline = true, +--- -- Enable virtual text, override spacing to 4 +--- virtual_text = { +--- spacing = 4, +--- }, +--- -- Use a function to dynamically turn signs off +--- -- and on, using buffer local variables +--- signs = function(bufnr, client_id) +--- return vim.bo[bufnr].show_signs == false +--- end, +--- -- Disable a feature +--- update_in_insert = false, +--- } +--- ) +--- </pre> +--- +---@param config table Configuration table (see |vim.diagnostic.config()|). +function M.on_publish_diagnostics(_, result, ctx, config) + local client_id = ctx.client_id + local uri = result.uri + local bufnr = vim.uri_to_bufnr(uri) - counts[diagnostic.severity] = val + 1 - end + if not bufnr then + return end - return counts -end - ---@private ---- Set the different diagnostic cache after `textDocument/publishDiagnostics` ----@param diagnostics Diagnostic[] ----@param bufnr number ----@param client_id number ----@return nil -local function set_diagnostic_cache(diagnostics, bufnr, client_id) client_id = get_client_id(client_id) - - -- https://microsoft.github.io/language-server-protocol/specifications/specification-current/#diagnostic - -- - -- The diagnostic's severity. Can be omitted. If omitted it is up to the - -- client to interpret diagnostics as error, warning, info or hint. - -- TODO: Replace this with server-specific heuristics to infer severity. - local buf_line_count = vim.api.nvim_buf_line_count(bufnr) - for _, diagnostic in ipairs(diagnostics) do - if diagnostic.severity == nil then - diagnostic.severity = DiagnosticSeverity.Error - end - -- Account for servers that place diagnostics on terminating newline - if buf_line_count > 0 then - diagnostic.range.start.line = math.max(math.min( - diagnostic.range.start.line, buf_line_count - 1 - ), 0) - diagnostic.range["end"].line = math.max(math.min( - diagnostic.range["end"].line, buf_line_count - 1 - ), 0) + local namespace = M.get_namespace(client_id) + local diagnostics = result.diagnostics + + if config then + for _, opt in pairs(config) do + if type(opt) == 'table' then + if not opt.severity and opt.severity_limit then + opt.severity = {min=severity_lsp_to_vim(opt.severity_limit)} + end + end end end - diagnostic_cache[bufnr][client_id] = diagnostics - diagnostic_cache_lines[bufnr][client_id] = _diagnostic_lines(diagnostics) - diagnostic_cache_counts[bufnr][client_id] = _diagnostic_counts(diagnostics) -end - + vim.diagnostic.set(namespace, bufnr, diagnostic_lsp_to_vim(diagnostics, bufnr, client_id), config) ---@private ---- Clear the cached diagnostics ----@param bufnr number ----@param client_id number -local function clear_diagnostic_cache(bufnr, client_id) - client_id = get_client_id(client_id) - - diagnostic_cache[bufnr][client_id] = nil - diagnostic_cache_lines[bufnr][client_id] = nil - diagnostic_cache_counts[bufnr][client_id] = nil + -- Keep old autocmd for back compat. This should eventually be removed. + vim.api.nvim_command("doautocmd <nomodeline> User LspDiagnosticsChanged") end ---- Save diagnostics to the current buffer. +--- Clear diagnotics and diagnostic cache. +--- +--- Diagnostic producers should prefer |vim.diagnostic.reset()|. However, +--- this method signature is still used internally in some parts of the LSP +--- implementation so it's simply marked @private rather than @deprecated. --- ---- Handles saving diagnostics from multiple clients in the same buffer. ----@param diagnostics Diagnostic[] ----@param bufnr number ---@param client_id number -function M.save(diagnostics, bufnr, client_id) - validate { - diagnostics = {diagnostics, 't'}, - bufnr = {bufnr, 'n'}, - client_id = {client_id, 'n', true}, - } - - if not diagnostics then return end - - bufnr = get_bufnr(bufnr) - client_id = get_client_id(client_id) - - if not _diagnostic_cleanup[bufnr][client_id] then - _diagnostic_cleanup[bufnr][client_id] = true - - -- Clean up our data when the buffer unloads. - api.nvim_buf_attach(bufnr, false, { - on_detach = function(_, b) - clear_diagnostic_cache(b, client_id) - _diagnostic_cleanup[b][client_id] = nil +---@param buffer_client_map table map of buffers to active clients +---@private +function M.reset(client_id, buffer_client_map) + buffer_client_map = vim.deepcopy(buffer_client_map) + vim.schedule(function() + for bufnr, client_ids in pairs(buffer_client_map) do + if client_ids[client_id] then + local namespace = M.get_namespace(client_id) + vim.diagnostic.reset(namespace, bufnr) end - }) - end - - set_diagnostic_cache(diagnostics, bufnr, client_id) + end + end) end --- }}} --- Diagnostic Retrieval {{{ +-- Deprecated Functions {{{ --- Get all diagnostics for clients --- +---@deprecated Prefer |vim.diagnostic.get()| +--- ---@param client_id number Restrict included diagnostics to the client --- If nil, diagnostics of all clients are included. ---@return table with diagnostics grouped by bufnr (bufnr: Diagnostic[]) function M.get_all(client_id) - local diagnostics_by_bufnr = {} - for bufnr, buf_diagnostics in pairs(diagnostic_cache) do - diagnostics_by_bufnr[bufnr] = {} - for cid, client_diagnostics in pairs(buf_diagnostics) do - if client_id == nil or cid == client_id then - vim.list_extend(diagnostics_by_bufnr[bufnr], client_diagnostics) - end - end + local result = {} + local namespace + if client_id then + namespace = M.get_namespace(client_id) end - return diagnostics_by_bufnr + for _, bufnr in ipairs(vim.api.nvim_list_bufs()) do + local diagnostics = diagnostic_vim_to_lsp(vim.diagnostic.get(bufnr, {namespace = namespace})) + result[bufnr] = diagnostics + end + return result end --- Return associated diagnostics for bufnr --- +---@deprecated Prefer |vim.diagnostic.get()| +--- ---@param bufnr number ---@param client_id number|nil If nil, then return all of the diagnostics. --- Else, return just the diagnostics associated with the client_id. -function M.get(bufnr, client_id) +---@param predicate function|nil Optional function for filtering diagnostics +function M.get(bufnr, client_id, predicate) + predicate = predicate or function() return true end if client_id == nil then local all_diagnostics = {} - for iter_client_id, _ in pairs(diagnostic_cache[bufnr]) do - local iter_diagnostics = M.get(bufnr, iter_client_id) - + vim.lsp.for_each_buffer_client(bufnr, function(_, iter_client_id, _) + local iter_diagnostics = vim.tbl_filter(predicate, M.get(bufnr, iter_client_id)) for _, diagnostic in ipairs(iter_diagnostics) do table.insert(all_diagnostics, diagnostic) end - end - + end) return all_diagnostics end - return diagnostic_cache[bufnr][client_id] or {} + local namespace = M.get_namespace(client_id) + return diagnostic_vim_to_lsp(vim.tbl_filter(predicate, vim.diagnostic.get(bufnr, {namespace=namespace}))) end --- Get the diagnostics by line --- ----@param bufnr number The buffer number ----@param line_nr number The line number +--- Marked private as this is used internally by the LSP subsystem, but +--- most users should instead prefer |vim.diagnostic.get()|. +--- +---@param bufnr number|nil The buffer number +---@param line_nr number|nil The line number ---@param opts table|nil Configuration keys --- - severity: (DiagnosticSeverity, default nil) --- - Only return diagnostics with this severity. Overrides severity_limit --- - severity_limit: (DiagnosticSeverity, default nil) --- - Limit severity of diagnostics found. E.g. "Warning" means { "Error", "Warning" } will be valid. ----@param client_id number the client id +---@param client_id|nil number the client id ---@return table Table with map of line number to list of diagnostics. --- Structured: { [1] = {...}, [5] = {.... } } +--- Structured: { [1] = {...}, [5] = {.... } } +---@private function M.get_line_diagnostics(bufnr, line_nr, opts, client_id) opts = opts or {} - - bufnr = bufnr or vim.api.nvim_get_current_buf() - line_nr = line_nr or vim.api.nvim_win_get_cursor(0)[1] - 1 - - local client_get_diags = function(iter_client_id) - return (diagnostic_cache_lines[bufnr][iter_client_id] or {})[line_nr] or {} + if opts.severity then + opts.severity = severity_lsp_to_vim(opts.severity) + elseif opts.severity_limit then + opts.severity = {min=severity_lsp_to_vim(opts.severity_limit)} end - local line_diagnostics - if client_id == nil then - line_diagnostics = {} - for iter_client_id, _ in pairs(diagnostic_cache_lines[bufnr]) do - for _, diagnostic in ipairs(client_get_diags(iter_client_id)) do - table.insert(line_diagnostics, diagnostic) - end - end - else - line_diagnostics = vim.deepcopy(client_get_diags(client_id)) + if client_id then + opts.namespace = M.get_namespace(client_id) end - if opts.severity then - line_diagnostics = filter_to_severity_limit(opts.severity, line_diagnostics) - elseif opts.severity_limit then - line_diagnostics = filter_by_severity_limit(opts.severity_limit, line_diagnostics) + if not line_nr then + line_nr = vim.api.nvim_win_get_cursor(0)[1] - 1 end - table.sort(line_diagnostics, function(a, b) return a.severity < b.severity end) + opts.lnum = line_nr - return line_diagnostics + return diagnostic_vim_to_lsp(vim.diagnostic.get(bufnr, opts)) end --- Get the counts for a particular severity --- ---- Useful for showing diagnostic counts in statusline. eg: ---- ---- <pre> ---- function! LspStatus() abort ---- let sl = '' ---- if luaeval('not vim.tbl_isempty(vim.lsp.buf_get_clients(0))') ---- let sl.='%#MyStatuslineLSP#E:' ---- let sl.='%#MyStatuslineLSPErrors#%{luaeval("vim.lsp.diagnostic.get_count(0, [[Error]])")}' ---- let sl.='%#MyStatuslineLSP# W:' ---- let sl.='%#MyStatuslineLSPWarnings#%{luaeval("vim.lsp.diagnostic.get_count(0, [[Warning]])")}' ---- else ---- let sl.='%#MyStatuslineLSPErrors#off' ---- endif ---- return sl ---- endfunction ---- let &l:statusline = '%#MyStatuslineLSP#LSP '.LspStatus() ---- </pre> +---@deprecated Prefer |vim.diagnostic.get_count()| --- ---@param bufnr number The buffer number ---@param severity DiagnosticSeverity ---@param client_id number the client id function M.get_count(bufnr, severity, client_id) - if client_id == nil then - local total = 0 - for iter_client_id, _ in pairs(diagnostic_cache_counts[bufnr]) do - total = total + M.get_count(bufnr, severity, iter_client_id) - end - - return total + severity = severity_lsp_to_vim(severity) + local opts = { severity = severity } + if client_id ~= nil then + opts.namespace = M.get_namespace(client_id) end - return (diagnostic_cache_counts[bufnr][client_id] or {})[DiagnosticSeverity[severity]] or 0 -end - - --- }}} --- Diagnostic Movements {{{ - ---- Helper function to iterate through all of the diagnostic lines ----@return table list of diagnostics -local _iter_diagnostic_lines = function(start, finish, step, bufnr, opts, client_id) - if bufnr == nil then - bufnr = vim.api.nvim_get_current_buf() - end - - local wrap = if_nil(opts.wrap, true) - - local search = function(search_start, search_finish, search_step) - for line_nr = search_start, search_finish, search_step do - local line_diagnostics = M.get_line_diagnostics(bufnr, line_nr, opts, client_id) - if line_diagnostics and not vim.tbl_isempty(line_diagnostics) then - return line_diagnostics - end - end - end - - local result = search(start, finish, step) - - if wrap then - local wrap_start, wrap_finish - if step == 1 then - wrap_start, wrap_finish = 1, start - else - wrap_start, wrap_finish = vim.api.nvim_buf_line_count(bufnr), start - end - - if not result then - result = search(wrap_start, wrap_finish, step) - end - end - - return result -end - ---@private ---- Helper function to ierate through diagnostic lines and return a position ---- ----@return table {row, col} -local function _iter_diagnostic_lines_pos(opts, line_diagnostics) - opts = opts or {} - - local win_id = opts.win_id or vim.api.nvim_get_current_win() - local bufnr = vim.api.nvim_win_get_buf(win_id) - - if line_diagnostics == nil or vim.tbl_isempty(line_diagnostics) then - return false - end - - local iter_diagnostic = line_diagnostics[1] - return to_position(iter_diagnostic.range.start, bufnr) -end - ---@private --- Move to the diagnostic position -local function _iter_diagnostic_move_pos(name, opts, pos) - opts = opts or {} - - local enable_popup = if_nil(opts.enable_popup, true) - local win_id = opts.win_id or vim.api.nvim_get_current_win() - - if not pos then - print(string.format("%s: No more valid diagnostics to move to.", name)) - return - end - - vim.api.nvim_win_set_cursor(win_id, {pos[1] + 1, pos[2]}) - - if enable_popup then - -- This is a bit weird... I'm surprised that we need to wait til the next tick to do this. - vim.schedule(function() - M.show_line_diagnostics(opts.popup_opts, vim.api.nvim_win_get_buf(win_id)) - end) - end + return #vim.diagnostic.get(bufnr, opts) end --- Get the previous diagnostic closest to the cursor_position --- +---@deprecated Prefer |vim.diagnostic.get_prev()| +--- ---@param opts table See |vim.lsp.diagnostic.goto_next()| ---@return table Previous diagnostic function M.get_prev(opts) - opts = opts or {} - - local win_id = opts.win_id or vim.api.nvim_get_current_win() - local bufnr = vim.api.nvim_win_get_buf(win_id) - local cursor_position = opts.cursor_position or vim.api.nvim_win_get_cursor(win_id) - - return _iter_diagnostic_lines(cursor_position[1] - 2, 0, -1, bufnr, opts, opts.client_id) + if opts then + if opts.severity then + opts.severity = severity_lsp_to_vim(opts.severity) + elseif opts.severity_limit then + opts.severity = {min=severity_lsp_to_vim(opts.severity_limit)} + end + end + return diagnostic_vim_to_lsp({vim.diagnostic.get_prev(opts)})[1] end --- Return the pos, {row, col}, for the prev diagnostic in the current buffer. +--- +---@deprecated Prefer |vim.diagnostic.get_prev_pos()| +--- ---@param opts table See |vim.lsp.diagnostic.goto_next()| ---@return table Previous diagnostic position function M.get_prev_pos(opts) - return _iter_diagnostic_lines_pos( - opts, - M.get_prev(opts) - ) + if opts then + if opts.severity then + opts.severity = severity_lsp_to_vim(opts.severity) + elseif opts.severity_limit then + opts.severity = {min=severity_lsp_to_vim(opts.severity_limit)} + end + end + return vim.diagnostic.get_prev_pos(opts) end --- Move to the previous diagnostic +--- +---@deprecated Prefer |vim.diagnostic.goto_prev()| +--- ---@param opts table See |vim.lsp.diagnostic.goto_next()| function M.goto_prev(opts) - return _iter_diagnostic_move_pos( - "DiagnosticPrevious", - opts, - M.get_prev_pos(opts) - ) + if opts then + if opts.severity then + opts.severity = severity_lsp_to_vim(opts.severity) + elseif opts.severity_limit then + opts.severity = {min=severity_lsp_to_vim(opts.severity_limit)} + end + end + return vim.diagnostic.goto_prev(opts) end --- Get the next diagnostic closest to the cursor_position +--- +---@deprecated Prefer |vim.diagnostic.get_next()| +--- ---@param opts table See |vim.lsp.diagnostic.goto_next()| ---@return table Next diagnostic function M.get_next(opts) - opts = opts or {} - - local win_id = opts.win_id or vim.api.nvim_get_current_win() - local bufnr = vim.api.nvim_win_get_buf(win_id) - local cursor_position = opts.cursor_position or vim.api.nvim_win_get_cursor(win_id) - - return _iter_diagnostic_lines(cursor_position[1], vim.api.nvim_buf_line_count(bufnr), 1, bufnr, opts, opts.client_id) + if opts then + if opts.severity then + opts.severity = severity_lsp_to_vim(opts.severity) + elseif opts.severity_limit then + opts.severity = {min=severity_lsp_to_vim(opts.severity_limit)} + end + end + return diagnostic_vim_to_lsp({vim.diagnostic.get_next(opts)})[1] end --- Return the pos, {row, col}, for the next diagnostic in the current buffer. +--- +---@deprecated Prefer |vim.diagnostic.get_next_pos()| +--- ---@param opts table See |vim.lsp.diagnostic.goto_next()| ---@return table Next diagnostic position function M.get_next_pos(opts) - return _iter_diagnostic_lines_pos( - opts, - M.get_next(opts) - ) + if opts then + if opts.severity then + opts.severity = severity_lsp_to_vim(opts.severity) + elseif opts.severity_limit then + opts.severity = {min=severity_lsp_to_vim(opts.severity_limit)} + end + end + return vim.diagnostic.get_next_pos(opts) end --- Move to the next diagnostic +--- +---@deprecated Prefer |vim.diagnostic.goto_next()| +--- ---@param opts table|nil Configuration table. Keys: --- - {client_id}: (number) --- - If nil, will consider all clients attached to buffer. @@ -612,25 +447,20 @@ end --- - {win_id}: (number, default 0) --- - Window ID function M.goto_next(opts) - return _iter_diagnostic_move_pos( - "DiagnosticNext", - opts, - M.get_next_pos(opts) - ) + if opts then + if opts.severity then + opts.severity = severity_lsp_to_vim(opts.severity) + elseif opts.severity_limit then + opts.severity = {min=severity_lsp_to_vim(opts.severity_limit)} + end + end + return vim.diagnostic.goto_next(opts) end --- }}} --- Diagnostic Setters {{{ --- Set signs for given diagnostics --- ---- Sign characters can be customized with the following commands: +---@deprecated Prefer |vim.diagnostic._set_signs()| --- ---- <pre> ---- sign define LspDiagnosticsSignError text=E texthl=LspDiagnosticsSignError linehl= numhl= ---- sign define LspDiagnosticsSignWarning text=W texthl=LspDiagnosticsSignWarning linehl= numhl= ---- sign define LspDiagnosticsSignInformation text=I texthl=LspDiagnosticsSignInformation linehl= numhl= ---- sign define LspDiagnosticsSignHint text=H texthl=LspDiagnosticsSignHint linehl= numhl= ---- </pre> ---@param diagnostics Diagnostic[] ---@param bufnr number The buffer number ---@param client_id number the client id @@ -639,51 +469,18 @@ end --- - priority: Set the priority of the signs. --- - severity_limit (DiagnosticSeverity): --- - Limit severity of diagnostics found. E.g. "Warning" means { "Error", "Warning" } will be valid. -function M.set_signs(diagnostics, bufnr, client_id, sign_ns, opts) - opts = opts or {} - sign_ns = sign_ns or M._get_sign_namespace(client_id) - - if not diagnostics then - diagnostics = diagnostic_cache[bufnr][client_id] - end - - if not diagnostics then - return +function M.set_signs(diagnostics, bufnr, client_id, _, opts) + local namespace = M.get_namespace(client_id) + if opts and not opts.severity and opts.severity_limit then + opts.severity = {min=severity_lsp_to_vim(opts.severity_limit)} end - bufnr = get_bufnr(bufnr) - diagnostics = filter_by_severity_limit(opts.severity_limit, diagnostics) - - local ok = true - for _, diagnostic in ipairs(diagnostics) do - - ok = ok and pcall(vim.fn.sign_place, - 0, - sign_ns, - sign_highlight_map[diagnostic.severity], - bufnr, - { - priority = opts.priority, - lnum = diagnostic.range.start.line + 1 - } - ) - end - - if not ok then - log.debug("Failed to place signs:", diagnostics) - end + vim.diagnostic._set_signs(namespace, bufnr, diagnostic_lsp_to_vim(diagnostics, bufnr, client_id), opts) end --- Set underline for given diagnostics --- ---- Underline highlights can be customized by changing the following |:highlight| groups. ---- ---- <pre> ---- LspDiagnosticsUnderlineError ---- LspDiagnosticsUnderlineWarning ---- LspDiagnosticsUnderlineInformation ---- LspDiagnosticsUnderlineHint ---- </pre> +---@deprecated Prefer |vim.diagnostic._set_underline()| --- ---@param diagnostics Diagnostic[] ---@param bufnr number: The buffer number @@ -692,43 +489,17 @@ end ---@param opts table: Configuration table: --- - severity_limit (DiagnosticSeverity): --- - Limit severity of diagnostics found. E.g. "Warning" means { "Error", "Warning" } will be valid. -function M.set_underline(diagnostics, bufnr, client_id, diagnostic_ns, opts) - opts = opts or {} - - diagnostic_ns = diagnostic_ns or M._get_diagnostic_namespace(client_id) - diagnostics = filter_by_severity_limit(opts.severity_limit, diagnostics) - - for _, diagnostic in ipairs(diagnostics) do - local start = diagnostic.range["start"] - local finish = diagnostic.range["end"] - local higroup = underline_highlight_map[diagnostic.severity] - - if higroup == nil then - -- Default to error if we don't have a highlight associated - higroup = underline_highlight_map[DiagnosticSeverity.Error] - end - - highlight.range( - bufnr, - diagnostic_ns, - higroup, - to_position(start, bufnr), - to_position(finish, bufnr) - ) +function M.set_underline(diagnostics, bufnr, client_id, _, opts) + local namespace = M.get_namespace(client_id) + if opts and not opts.severity and opts.severity_limit then + opts.severity = {min=severity_lsp_to_vim(opts.severity_limit)} end + return vim.diagnostic._set_underline(namespace, bufnr, diagnostic_lsp_to_vim(diagnostics, bufnr, client_id), opts) end --- Virtual Text {{{ --- Set virtual text given diagnostics --- ---- Virtual text highlights can be customized by changing the following |:highlight| groups. ---- ---- <pre> ---- LspDiagnosticsVirtualTextError ---- LspDiagnosticsVirtualTextWarning ---- LspDiagnosticsVirtualTextInformation ---- LspDiagnosticsVirtualTextHint ---- </pre> +---@deprecated Prefer |vim.diagnostic._set_virtual_text()| --- ---@param diagnostics Diagnostic[] ---@param bufnr number @@ -739,460 +510,133 @@ end --- - spacing (number): Number of spaces to insert before virtual text --- - severity_limit (DiagnosticSeverity): --- - Limit severity of diagnostics found. E.g. "Warning" means { "Error", "Warning" } will be valid. -function M.set_virtual_text(diagnostics, bufnr, client_id, diagnostic_ns, opts) - opts = opts or {} - - client_id = get_client_id(client_id) - diagnostic_ns = diagnostic_ns or M._get_diagnostic_namespace(client_id) - - local buffer_line_diagnostics - if diagnostics then - buffer_line_diagnostics = _diagnostic_lines(diagnostics) - else - buffer_line_diagnostics = diagnostic_cache_lines[bufnr][client_id] - end - - if not buffer_line_diagnostics then - return nil - end - - for line, line_diagnostics in pairs(buffer_line_diagnostics) do - line_diagnostics = filter_by_severity_limit(opts.severity_limit, line_diagnostics) - local virt_texts = M.get_virtual_text_chunks_for_line(bufnr, line, line_diagnostics, opts) - - if virt_texts then - api.nvim_buf_set_virtual_text(bufnr, diagnostic_ns, line, virt_texts, {}) - end +function M.set_virtual_text(diagnostics, bufnr, client_id, _, opts) + local namespace = M.get_namespace(client_id) + if opts and not opts.severity and opts.severity_limit then + opts.severity = {min=severity_lsp_to_vim(opts.severity_limit)} end + return vim.diagnostic._set_virtual_text(namespace, bufnr, diagnostic_lsp_to_vim(diagnostics, bufnr, client_id), opts) end ---- Default function to get text chunks to display using `nvim_buf_set_virtual_text`. +--- Default function to get text chunks to display using |nvim_buf_set_extmark()|. +--- +---@deprecated Prefer |vim.diagnostic.get_virt_text_chunks()| +--- ---@param bufnr number The buffer to display the virtual text in ---@param line number The line number to display the virtual text on ---@param line_diags Diagnostic[] The diagnostics associated with the line ---@param opts table See {opts} from |vim.lsp.diagnostic.set_virtual_text()| ----@return table chunks, as defined by |nvim_buf_set_virtual_text()| -function M.get_virtual_text_chunks_for_line(bufnr, line, line_diags, opts) - assert(bufnr or line) - - if #line_diags == 0 then - return nil - end - - opts = opts or {} - local prefix = opts.prefix or "■" - local spacing = opts.spacing or 4 - - -- Create a little more space between virtual text and contents - local virt_texts = {{string.rep(" ", spacing)}} - - for i = 1, #line_diags - 1 do - table.insert(virt_texts, {prefix, virtual_text_highlight_map[line_diags[i].severity]}) - end - local last = line_diags[#line_diags] - - -- TODO(tjdevries): Allow different servers to be shown first somehow? - -- TODO(tjdevries): Display server name associated with these? - if last.message then - table.insert( - virt_texts, - { - string.format("%s %s", prefix, last.message:gsub("\r", ""):gsub("\n", " ")), - virtual_text_highlight_map[last.severity] - } - ) - - return virt_texts - end +---@return an array of [text, hl_group] arrays. This can be passed directly to +--- the {virt_text} option of |nvim_buf_set_extmark()|. +function M.get_virtual_text_chunks_for_line(bufnr, _, line_diags, opts) + return vim.diagnostic._get_virt_text_chunks(diagnostic_lsp_to_vim(line_diags, bufnr), opts) end --- }}} --- }}} --- Diagnostic Clear {{{ ---- Clears the currently displayed diagnostics ----@param bufnr number The buffer number ----@param client_id number the client id ----@param diagnostic_ns number|nil Associated diagnostic namespace ----@param sign_ns number|nil Associated sign namespace -function M.clear(bufnr, client_id, diagnostic_ns, sign_ns) - validate { bufnr = { bufnr, 'n' } } - - bufnr = (bufnr == 0 and api.nvim_get_current_buf()) or bufnr - - if client_id == nil then - return vim.lsp.for_each_buffer_client(bufnr, function(_, iter_client_id, _) - return M.clear(bufnr, iter_client_id) - end) - end - - diagnostic_ns = diagnostic_ns or M._get_diagnostic_namespace(client_id) - sign_ns = sign_ns or M._get_sign_namespace(client_id) - - assert(bufnr, "bufnr is required") - assert(diagnostic_ns, "Need diagnostic_ns, got nil") - assert(sign_ns, string.format("Need sign_ns, got nil %s", sign_ns)) - -- 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 --- }}} --- Diagnostic Insert Leave Handler {{{ - ---- Callback scheduled for after leaving insert mode +--- Open a floating window with the diagnostics from {position} --- ---- Used to handle ---@private -function M._execute_scheduled_display(bufnr, client_id) - local args = _bufs_waiting_to_update[bufnr][client_id] - if not args then - return - end - - -- Clear the args so we don't display unnecessarily. - _bufs_waiting_to_update[bufnr][client_id] = nil - - M.display(nil, bufnr, client_id, args) -end - -local registered = {} - -local make_augroup_key = function(bufnr, client_id) - return string.format("LspDiagnosticInsertLeave:%s:%s", bufnr, client_id) -end - ---- Table of autocmd events to fire the update for displaying new diagnostic information -M.insert_leave_auto_cmds = { "InsertLeave", "CursorHoldI" } - ---- Used to schedule diagnostic updates upon leaving insert mode. +---@deprecated Prefer |vim.diagnostic.show_position_diagnostics()| --- ---- For parameter description, see |M.display()| -function M._schedule_display(bufnr, client_id, args) - _bufs_waiting_to_update[bufnr][client_id] = args - - local key = make_augroup_key(bufnr, client_id) - if not registered[key] then - vim.cmd(string.format("augroup %s", key)) - vim.cmd(" au!") - vim.cmd( - string.format( - [[autocmd %s <buffer=%s> :lua vim.lsp.diagnostic._execute_scheduled_display(%s, %s)]], - table.concat(M.insert_leave_auto_cmds, ","), - bufnr, - bufnr, - client_id - ) - ) - vim.cmd("augroup END") - - registered[key] = true - end -end - - ---- Used in tandem with ---- ---- For parameter description, see |M.display()| -function M._clear_scheduled_display(bufnr, client_id) - local key = make_augroup_key(bufnr, client_id) - - if registered[key] then - vim.cmd(string.format("augroup %s", key)) - vim.cmd(" au!") - vim.cmd("augroup END") - - registered[key] = nil - end -end --- }}} - --- Diagnostic Private Highlight Utilies {{{ ---- Get the severity highlight name ---@private -function M._get_severity_highlight_name(severity) - return virtual_text_highlight_map[severity] -end - ---- Get floating severity highlight name ---@private -function M._get_floating_severity_highlight_name(severity) - return floating_highlight_map[severity] -end - ---- This should be called to update the highlights for the LSP client. -function M._define_default_signs_and_highlights() - --@private - local function define_default_sign(name, properties) - if vim.tbl_isempty(vim.fn.sign_getdefined(name)) then - vim.fn.sign_define(name, properties) +---@param opts table|nil Configuration keys +--- - severity: (DiagnosticSeverity, default nil) +--- - Only return diagnostics with this severity. Overrides severity_limit +--- - severity_limit: (DiagnosticSeverity, default nil) +--- - Limit severity of diagnostics found. E.g. "Warning" means { "Error", "Warning" } will be valid. +--- - all opts for |show_diagnostics()| can be used here +---@param buf_nr number|nil The buffer number +---@param position table|nil The (0,0)-indexed position +---@return table {popup_bufnr, win_id} +function M.show_position_diagnostics(opts, buf_nr, position) + if opts then + if opts.severity then + opts.severity = severity_lsp_to_vim(opts.severity) + elseif opts.severity_limit then + opts.severity = {min=severity_lsp_to_vim(opts.severity_limit)} end end - - -- Initialize default diagnostic highlights - for severity, hi_info in pairs(diagnostic_severities) do - local default_highlight_name = default_highlight_map[severity] - highlight.create(default_highlight_name, hi_info, true) - - -- Default link all corresponding highlights to the default highlight - highlight.link(virtual_text_highlight_map[severity], default_highlight_name, false) - highlight.link(floating_highlight_map[severity], default_highlight_name, false) - highlight.link(sign_highlight_map[severity], default_highlight_name, false) - end - - -- Create all signs - for severity, sign_hl_name in pairs(sign_highlight_map) do - local severity_name = DiagnosticSeverity[severity] - - define_default_sign(sign_hl_name, { - text = (severity_name or 'U'):sub(1, 1), - texthl = sign_hl_name, - linehl = '', - numhl = '', - }) - end - - -- Initialize Underline highlights - for severity, underline_highlight_name in pairs(underline_highlight_map) do - highlight.create(underline_highlight_name, { - cterm = 'underline', - gui = 'underline', - guisp = diagnostic_severities[severity].guifg - }, true) - end + return vim.diagnostic.show_position_diagnostics(opts, buf_nr, position) end --- }}} --- Diagnostic Display {{{ ---- |lsp-handler| for the method "textDocument/publishDiagnostics" +--- Open a floating window with the diagnostics from {line_nr} --- ----@note Each of the configuration options accepts: ---- - `false`: Disable this feature ---- - `true`: Enable this feature, use default settings. ---- - `table`: Enable this feature, use overrides. ---- - `function`: Function with signature (bufnr, client_id) that returns any of the above. ---- <pre> ---- vim.lsp.handlers["textDocument/publishDiagnostics"] = vim.lsp.with( ---- vim.lsp.diagnostic.on_publish_diagnostics, { ---- -- Enable underline, use default values ---- underline = true, ---- -- Enable virtual text, override spacing to 4 ---- virtual_text = { ---- spacing = 4, ---- }, ---- -- Use a function to dynamically turn signs off ---- -- and on, using buffer local variables ---- signs = function(bufnr, client_id) ---- return vim.bo[bufnr].show_signs == false ---- end, ---- -- Disable a feature ---- update_in_insert = false, ---- } ---- ) ---- </pre> +---@deprecated Prefer |vim.diagnostic.show_line_diagnostics()| --- ----@param config table Configuration table. ---- - underline: (default=true) ---- - Apply underlines to diagnostics. ---- - See |vim.lsp.diagnostic.set_underline()| ---- - virtual_text: (default=true) ---- - Apply virtual text to line endings. ---- - See |vim.lsp.diagnostic.set_virtual_text()| ---- - signs: (default=true) ---- - Apply signs for diagnostics. ---- - See |vim.lsp.diagnostic.set_signs()| ---- - update_in_insert: (default=false) ---- - Update diagnostics in InsertMode or wait until InsertLeave ---- - severity_sort: (default=false) ---- - Sort diagnostics (and thus signs and virtual text) -function M.on_publish_diagnostics(_, _, params, client_id, _, config) - local uri = params.uri - local bufnr = vim.uri_to_bufnr(uri) - - if not bufnr then - return - end - - local diagnostics = params.diagnostics - - if config and if_nil(config.severity_sort, false) then - table.sort(diagnostics, function(a, b) return a.severity > b.severity end) - end - - -- Always save the diagnostics, even if the buf is not loaded. - -- Language servers may report compile or build errors via diagnostics - -- Users should be able to find these, even if they're in files which - -- are not loaded. - M.save(diagnostics, bufnr, client_id) - - -- Unloaded buffers should not handle diagnostics. - -- When the buffer is loaded, we'll call on_attach, which sends textDocument/didOpen. - -- This should trigger another publish of the diagnostics. - -- - -- In particular, this stops a ton of spam when first starting a server for current - -- unloaded buffers. - if not api.nvim_buf_is_loaded(bufnr) then - return +---@param opts table Configuration table +--- - all opts for |vim.lsp.diagnostic.get_line_diagnostics()| and +--- |show_diagnostics()| can be used here +---@param buf_nr number|nil The buffer number +---@param line_nr number|nil The line number +---@param client_id number|nil the client id +---@return table {popup_bufnr, win_id} +function M.show_line_diagnostics(opts, buf_nr, line_nr, client_id) + if client_id then + opts = opts or {} + opts.namespace = M.get_namespace(client_id) end - - M.display(diagnostics, bufnr, client_id, config) + return vim.diagnostic.show_line_diagnostics(opts, buf_nr, line_nr) end ---@private ---- Display diagnostics for the buffer, given a configuration. -function M.display(diagnostics, bufnr, client_id, config) - config = vim.lsp._with_extend('vim.lsp.diagnostic.on_publish_diagnostics', { - signs = true, - underline = true, - virtual_text = true, - update_in_insert = false, - severity_sort = false, - }, config) - - -- TODO(tjdevries): Consider how we can make this a "standardized" kind of thing for |lsp-handlers|. - -- It seems like we would probably want to do this more often as we expose more of them. - -- It provides a very nice functional interface for people to override configuration. - local resolve_optional_value = function(option) - local enabled_val = {} - - if not option then - return false - elseif option == true then - return enabled_val - elseif type(option) == 'function' then - local val = option(bufnr, client_id) - if val == true then - return enabled_val - else - return val - end - elseif type(option) == 'table' then - return option - else - error("Unexpected option type: " .. vim.inspect(option)) - end - end - - if resolve_optional_value(config.update_in_insert) then - M._clear_scheduled_display(bufnr, client_id) - else - local mode = vim.api.nvim_get_mode() - - if string.sub(mode.mode, 1, 1) == 'i' then - M._schedule_display(bufnr, client_id, config) - return - end - end - - M.clear(bufnr, client_id) - - diagnostics = diagnostics or M.get(bufnr, client_id) - - vim.api.nvim_command("doautocmd <nomodeline> User LspDiagnosticsChanged") - - if not diagnostics or vim.tbl_isempty(diagnostics) then - return - end - - local underline_opts = resolve_optional_value(config.underline) - if underline_opts then - M.set_underline(diagnostics, bufnr, client_id, nil, underline_opts) - end - - local virtual_text_opts = resolve_optional_value(config.virtual_text) - if virtual_text_opts then - M.set_virtual_text(diagnostics, bufnr, client_id, nil, virtual_text_opts) +--- Redraw diagnostics for the given buffer and client +--- +---@deprecated Prefer |vim.diagnostic.redraw()| +--- +--- This calls the "textDocument/publishDiagnostics" handler manually using +--- the cached diagnostics already received from the server. This can be useful +--- for redrawing diagnostics after making changes in diagnostics +--- configuration. |lsp-handler-configuration| +--- +---@param bufnr (optional, number): Buffer handle, defaults to current +---@param client_id (optional, number): Redraw diagnostics for the given +--- client. The default is to redraw diagnostics for all attached +--- clients. +function M.redraw(bufnr, client_id) + bufnr = get_bufnr(bufnr) + if not client_id then + return vim.lsp.for_each_buffer_client(bufnr, function(client) + M.redraw(bufnr, client.id) + end) end - local signs_opts = resolve_optional_value(config.signs) - if signs_opts then - M.set_signs(diagnostics, bufnr, client_id, nil, signs_opts) - end + local namespace = M.get_namespace(client_id) + return vim.diagnostic.show(namespace, bufnr) end --- }}} --- Diagnostic User Functions {{{ ---- Open a floating window with the diagnostics from {line_nr} +--- Sets the quickfix list --- ---- The floating window can be customized with the following highlight groups: ---- <pre> ---- LspDiagnosticsFloatingError ---- LspDiagnosticsFloatingWarning ---- LspDiagnosticsFloatingInformation ---- LspDiagnosticsFloatingHint ---- </pre> ----@param opts table Configuration table ---- - show_header (boolean, default true): Show "Diagnostics:" header. ---- - Plus all the opts for |vim.lsp.diagnostic.get_line_diagnostics()| ---- and |vim.lsp.util.open_floating_preview()| can be used here. ----@param bufnr number The buffer number ----@param line_nr number The line number ----@param client_id number|nil the client id ----@return table {popup_bufnr, win_id} -function M.show_line_diagnostics(opts, bufnr, line_nr, client_id) +---@deprecated Prefer |vim.diagnostic.setqflist()| +--- +---@param opts table|nil Configuration table. Keys: +--- - {open}: (boolean, default true) +--- - Open quickfix list after set +--- - {client_id}: (number) +--- - If nil, will consider all clients attached to buffer. +--- - {severity}: (DiagnosticSeverity) +--- - Exclusive severity to consider. Overrides {severity_limit} +--- - {severity_limit}: (DiagnosticSeverity) +--- - Limit severity of diagnostics found. E.g. "Warning" means { "Error", "Warning" } will be valid. +--- - {workspace}: (boolean, default true) +--- - Set the list with workspace diagnostics +function M.set_qflist(opts) opts = opts or {} - - local show_header = if_nil(opts.show_header, true) - - bufnr = bufnr or 0 - line_nr = line_nr or (vim.api.nvim_win_get_cursor(0)[1] - 1) - - local lines = {} - local highlights = {} - if show_header then - table.insert(lines, "Diagnostics:") - table.insert(highlights, {0, "Bold"}) - end - - local line_diagnostics = M.get_line_diagnostics(bufnr, line_nr, opts, client_id) - if vim.tbl_isempty(line_diagnostics) then return end - - for i, diagnostic in ipairs(line_diagnostics) do - local prefix = string.format("%d. ", i) - local hiname = M._get_floating_severity_highlight_name(diagnostic.severity) - assert(hiname, 'unknown severity: ' .. tostring(diagnostic.severity)) - - local message_lines = vim.split(diagnostic.message, '\n', true) - table.insert(lines, prefix..message_lines[1]) - table.insert(highlights, {#prefix, hiname}) - for j = 2, #message_lines do - table.insert(lines, message_lines[j]) - table.insert(highlights, {0, hiname}) - end + if opts.severity then + opts.severity = severity_lsp_to_vim(opts.severity) + elseif opts.severity_limit then + opts.severity = {min=severity_lsp_to_vim(opts.severity_limit)} end - - opts.focus_id = "line_diagnostics" - local popup_bufnr, winnr = util.open_floating_preview(lines, 'plaintext', opts) - 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) + if opts.client_id then + opts.client_id = nil + opts.namespace = M.get_namespace(opts.client_id) end - - return popup_bufnr, winnr -end - - ---- Clear diagnotics and diagnostic cache ---- ---- Handles saving diagnostics from multiple clients in the same buffer. ----@param client_id number ----@param buffer_client_map table map of buffers to active clients -function M.reset(client_id, buffer_client_map) - buffer_client_map = vim.deepcopy(buffer_client_map) - vim.schedule(function() - for bufnr, client_ids in pairs(buffer_client_map) do - if client_ids[client_id] then - clear_diagnostic_cache(bufnr, client_id) - M.clear(bufnr, client_id) - end - end - end) + local workspace = vim.F.if_nil(opts.workspace, true) + opts.bufnr = not workspace and 0 + return vim.diagnostic.setqflist(opts) end --- Sets the location list +--- +---@deprecated Prefer |vim.diagnostic.setloclist()| +--- ---@param opts table|nil Configuration table. Keys: ---- - {open_loclist}: (boolean, default true) +--- - {open}: (boolean, default true) --- - Open loclist after set --- - {client_id}: (number) --- - If nil, will consider all clients attached to buffer. @@ -1204,29 +648,65 @@ end --- - Set the list with workspace diagnostics function M.set_loclist(opts) opts = opts or {} - local open_loclist = if_nil(opts.open_loclist, true) - local current_bufnr = api.nvim_get_current_buf() - local diags = opts.workspace and M.get_all(opts.client_id) or { - [current_bufnr] = M.get(current_bufnr, opts.client_id) - } - local predicate = function(d) - local severity = to_severity(opts.severity) - if severity then - return d.severity == severity - end - local severity_limit = to_severity(opts.severity_limit) - if severity_limit then - return d.severity <= severity_limit - end - return true + if opts.severity then + opts.severity = severity_lsp_to_vim(opts.severity) + elseif opts.severity_limit then + opts.severity = {min=severity_lsp_to_vim(opts.severity_limit)} end - local items = util.diagnostics_to_items(diags, predicate) - local win_id = vim.api.nvim_get_current_win() - util.set_loclist(items, win_id) - if open_loclist then - vim.cmd [[lopen]] + if opts.client_id then + opts.client_id = nil + opts.namespace = M.get_namespace(opts.client_id) end + local workspace = vim.F.if_nil(opts.workspace, false) + opts.bufnr = not workspace and 0 + return vim.diagnostic.setloclist(opts) end + +--- Disable diagnostics for the given buffer and client +--- +---@deprecated Prefer |vim.diagnostic.disable()| +--- +---@param bufnr (optional, number): Buffer handle, defaults to current +---@param client_id (optional, number): Disable diagnostics for the given +--- client. The default is to disable diagnostics for all attached +--- clients. +-- Note that when diagnostics are disabled for a buffer, the server will still +-- send diagnostic information and the client will still process it. The +-- diagnostics are simply not displayed to the user. +function M.disable(bufnr, client_id) + if not client_id then + return vim.lsp.for_each_buffer_client(bufnr, function(client) + M.disable(bufnr, client.id) + end) + end + + bufnr = get_bufnr(bufnr) + local namespace = M.get_namespace(client_id) + return vim.diagnostic.disable(bufnr, namespace) +end + +--- Enable diagnostics for the given buffer and client +--- +---@deprecated Prefer |vim.diagnostic.enable()| +--- +---@param bufnr (optional, number): Buffer handle, defaults to current +---@param client_id (optional, number): Enable diagnostics for the given +--- client. The default is to enable diagnostics for all attached +--- clients. +function M.enable(bufnr, client_id) + if not client_id then + return vim.lsp.for_each_buffer_client(bufnr, function(client) + M.enable(bufnr, client.id) + end) + end + + bufnr = get_bufnr(bufnr) + local namespace = M.get_namespace(client_id) + return vim.diagnostic.enable(bufnr, namespace) +end + -- }}} return M + +-- vim: fdm=marker diff --git a/runtime/lua/vim/lsp/handlers.lua b/runtime/lua/vim/lsp/handlers.lua index 41852b9d88..eff27807be 100644 --- a/runtime/lua/vim/lsp/handlers.lua +++ b/runtime/lua/vim/lsp/handlers.lua @@ -3,37 +3,34 @@ local protocol = require 'vim.lsp.protocol' local util = require 'vim.lsp.util' local vim = vim local api = vim.api -local buf = require 'vim.lsp.buf' local M = {} -- FIXME: DOC: Expose in vimdocs ---@private +---@private --- Writes to error buffer. ---@param ... (table of strings) Will be concatenated before being written +---@param ... (table of strings) Will be concatenated before being written local function err_message(...) vim.notify(table.concat(vim.tbl_flatten{...}), vim.log.levels.ERROR) api.nvim_command("redraw") end ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#workspace_executeCommand -M['workspace/executeCommand'] = function(err, _) - if err then - error("Could not execute code action: "..err.message) - end +--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#workspace_executeCommand +M['workspace/executeCommand'] = function(_, _, _, _) + -- Error handling is done implicitly by wrapping all handlers; see end of this file end --- @msg of type ProgressParams --- Basically a token of type number/string -local function progress_handler(_, _, params, client_id) +---@private +local function progress_handler(_, result, ctx, _) + local client_id = ctx.client_id local client = vim.lsp.get_client_by_id(client_id) local client_name = client and client.name or string.format("id=%d", client_id) if not client then err_message("LSP[", client_name, "] client has shut down after sending the message") end - local val = params.value -- unspecified yet - local token = params.token -- string or number + local val = result.value -- unspecified yet + local token = result.token -- string or number if val.kind then @@ -61,13 +58,14 @@ local function progress_handler(_, _, params, client_id) vim.api.nvim_command("doautocmd <nomodeline> User LspProgressUpdate") end ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#progress +--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#progress M['$/progress'] = progress_handler ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#window_workDoneProgress_create -M['window/workDoneProgress/create'] = function(_, _, params, client_id) +--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#window_workDoneProgress_create +M['window/workDoneProgress/create'] = function(_, result, ctx) + local client_id = ctx.client_id local client = vim.lsp.get_client_by_id(client_id) - local token = params.token -- string or number + local token = result.token -- string or number local client_name = client and client.name or string.format("id=%d", client_id) if not client then err_message("LSP[", client_name, "] client has shut down after sending the message") @@ -76,12 +74,12 @@ M['window/workDoneProgress/create'] = function(_, _, params, client_id) return vim.NIL end ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#window_showMessageRequest -M['window/showMessageRequest'] = function(_, _, params) +--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#window_showMessageRequest +M['window/showMessageRequest'] = function(_, result) - local actions = params.actions - print(params.message) - local option_strings = {params.message, "\nRequest Actions:"} + local actions = result.actions + print(result.message) + local option_strings = {result.message, "\nRequest Actions:"} for i, action in ipairs(actions) do local title = action.title:gsub('\r\n', '\\r\\n') title = title:gsub('\n', '\\n') @@ -97,8 +95,9 @@ M['window/showMessageRequest'] = function(_, _, params) end end ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#client_registerCapability -M['client/registerCapability'] = function(_, _, _, client_id) +--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#client_registerCapability +M['client/registerCapability'] = function(_, _, ctx) + local client_id = ctx.client_id local warning_tpl = "The language server %s triggers a registerCapability ".. "handler despite dynamicRegistration set to false. ".. "Report upstream, this warning is harmless" @@ -109,42 +108,8 @@ M['client/registerCapability'] = function(_, _, _, client_id) return vim.NIL end ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_codeAction -M['textDocument/codeAction'] = function(_, _, actions) - if actions == nil or vim.tbl_isempty(actions) then - print("No code actions available") - return - end - - local option_strings = {"Code Actions:"} - for i, action in ipairs(actions) do - local title = action.title:gsub('\r\n', '\\r\\n') - title = title:gsub('\n', '\\n') - table.insert(option_strings, string.format("%d. %s", i, title)) - end - - local choice = vim.fn.inputlist(option_strings) - if choice < 1 or choice > #actions then - return - end - local action_chosen = actions[choice] - -- textDocument/codeAction can return either Command[] or CodeAction[]. - -- If it is a CodeAction, it can have either an edit, a command or both. - -- Edits should be executed first - if action_chosen.edit or type(action_chosen.command) == "table" then - if action_chosen.edit then - util.apply_workspace_edit(action_chosen.edit) - end - if type(action_chosen.command) == "table" then - buf.execute_command(action_chosen.command) - end - else - buf.execute_command(action_chosen) - end -end - ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#workspace_applyEdit -M['workspace/applyEdit'] = function(_, _, workspace_edit) +--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#workspace_applyEdit +M['workspace/applyEdit'] = function(_, workspace_edit) if not workspace_edit then return end -- TODO(ashkan) Do something more with label? if workspace_edit.label then @@ -157,30 +122,30 @@ M['workspace/applyEdit'] = function(_, _, workspace_edit) } end ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#workspace_configuration -M['workspace/configuration'] = function(err, _, params, client_id) +--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#workspace_configuration +M['workspace/configuration'] = function(_, result, ctx) + local client_id = ctx.client_id local client = vim.lsp.get_client_by_id(client_id) if not client then err_message("LSP[id=", client_id, "] client has shut down after sending the message") return end - if err then error(vim.inspect(err)) end - if not params.items then + if not result.items then return {} end - local result = {} - for _, item in ipairs(params.items) do + local response = {} + for _, item in ipairs(result.items) do if item.section then local value = util.lookup_section(client.config.settings, item.section) or vim.NIL -- For empty sections with no explicit '' key, return settings as is if value == vim.NIL and item.section == '' then value = client.config.settings or vim.NIL end - table.insert(result, value) + table.insert(response, value) end end - return result + return response end M['textDocument/publishDiagnostics'] = function(...) @@ -191,51 +156,71 @@ M['textDocument/codeLens'] = function(...) return require('vim.lsp.codelens').on_codelens(...) end ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_references -M['textDocument/references'] = function(_, _, result) - if not result then return end - util.set_qflist(util.locations_to_items(result)) - api.nvim_command("copen") -end ---@private ---- Prints given list of symbols to the quickfix list. ---@param _ (not used) ---@param _ (not used) ---@param result (list of Symbols) LSP method name ---@param result (table) result of LSP method; a location or a list of locations. ----(`textDocument/definition` can return `Location` or `Location[]` -local symbol_handler = function(_, _, result, _, bufnr) - if not result or vim.tbl_isempty(result) then return end - util.set_qflist(util.symbols_to_items(result, bufnr)) - api.nvim_command("copen") +---@private +--- Return a function that converts LSP responses to list items and opens the list +--- +--- The returned function has an optional {config} parameter that accepts a table +--- with the following keys: +--- +--- loclist: (boolean) use the location list (default is to use the quickfix list) +--- +---@param map_result function `((resp, bufnr) -> list)` to convert the response +---@param entity name of the resource used in a `not found` error message +local function response_to_list(map_result, entity) + return function(_,result, ctx, config) + if not result or vim.tbl_isempty(result) then + vim.notify('No ' .. entity .. ' found') + else + config = config or {} + if config.loclist then + vim.fn.setloclist(0, {}, ' ', { + title = 'Language Server'; + items = map_result(result, ctx.bufnr); + }) + api.nvim_command("lopen") + else + vim.fn.setqflist({}, ' ', { + title = 'Language Server'; + items = map_result(result, ctx.bufnr); + }) + api.nvim_command("copen") + end + end + end end ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_documentSymbol -M['textDocument/documentSymbol'] = symbol_handler ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#workspace_symbol -M['workspace/symbol'] = symbol_handler ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_rename -M['textDocument/rename'] = function(_, _, result) + +--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_references +M['textDocument/references'] = response_to_list(util.locations_to_items, 'references') + +--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_documentSymbol +M['textDocument/documentSymbol'] = response_to_list(util.symbols_to_items, 'document symbols') + +--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#workspace_symbol +M['workspace/symbol'] = response_to_list(util.symbols_to_items, 'symbols') + +--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_rename +M['textDocument/rename'] = function(_, result, _) if not result then return end util.apply_workspace_edit(result) end ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_rangeFormatting -M['textDocument/rangeFormatting'] = function(_, _, result) +--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_rangeFormatting +M['textDocument/rangeFormatting'] = function(_, result, ctx, _) if not result then return end - util.apply_text_edits(result) + util.apply_text_edits(result, ctx.bufnr) end ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_formatting -M['textDocument/formatting'] = function(_, _, result) +--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_formatting +M['textDocument/formatting'] = function(_, result, ctx, _) if not result then return end - util.apply_text_edits(result) + util.apply_text_edits(result, ctx.bufnr) end ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_completion -M['textDocument/completion'] = function(_, _, result) +--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_completion +M['textDocument/completion'] = function(_, result, _, _) if vim.tbl_isempty(result or {}) then return end local row, col = unpack(api.nvim_win_get_cursor(0)) local line = assert(api.nvim_buf_get_lines(0, row-1, row, false)[1]) @@ -260,9 +245,9 @@ end --- - border: (default=nil) --- - Add borders to the floating window --- - See |vim.api.nvim_open_win()| -function M.hover(_, method, result, _, _, config) +function M.hover(_, result, ctx, config) config = config or {} - config.focus_id = method + config.focus_id = ctx.method if not (result and result.contents) then -- return { 'No information available' } return @@ -276,18 +261,18 @@ function M.hover(_, method, result, _, _, config) return util.open_floating_preview(markdown_lines, "markdown", config) end ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_hover +--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_hover M['textDocument/hover'] = M.hover ---@private +---@private --- Jumps to a location. Used as a handler for multiple LSP methods. ---@param _ (not used) ---@param method (string) LSP method name ---@param result (table) result of LSP method; a location or a list of locations. +---@param _ (not used) +---@param result (table) result of LSP method; a location or a list of locations. +---@param ctx (table) table containing the context of the request, including the method ---(`textDocument/definition` can return `Location` or `Location[]` -local function location_handler(_, method, result) +local function location_handler(_, result, ctx, _) if result == nil or vim.tbl_isempty(result) then - local _ = log.info() and log.info(method, 'No location found') + local _ = log.info() and log.info(ctx.method, 'No location found') return nil end @@ -306,16 +291,17 @@ local function location_handler(_, method, result) end end ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_declaration +--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_declaration M['textDocument/declaration'] = location_handler ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_definition +--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_definition M['textDocument/definition'] = location_handler ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_typeDefinition +--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_typeDefinition M['textDocument/typeDefinition'] = location_handler ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_implementation +--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_implementation M['textDocument/implementation'] = location_handler ---- |lsp-handler| for the method "textDocument/signatureHelp" +--- |lsp-handler| for the method "textDocument/signatureHelp". +--- The active parameter is highlighted with |hl-LspSignatureActiveParameter|. --- <pre> --- vim.lsp.handlers["textDocument/signatureHelp"] = vim.lsp.with( --- vim.lsp.handlers.signature_help, { @@ -328,43 +314,53 @@ M['textDocument/implementation'] = location_handler --- - border: (default=nil) --- - Add borders to the floating window --- - See |vim.api.nvim_open_win()| -function M.signature_help(_, method, result, _, bufnr, config) +function M.signature_help(_, result, ctx, config) config = config or {} - config.focus_id = method + config.focus_id = ctx.method -- When use `autocmd CompleteDone <silent><buffer> lua vim.lsp.buf.signature_help()` to call signatureHelp handler -- If the completion item doesn't have signatures It will make noise. Change to use `print` that can use `<silent>` to ignore if not (result and result.signatures and result.signatures[1]) then - print('No signature help available') + if config.silent ~= true then + print('No signature help available') + end return end - local ft = api.nvim_buf_get_option(bufnr, 'filetype') - local lines = util.convert_signature_help_to_markdown_lines(result, ft) + local client = vim.lsp.get_client_by_id(ctx.client_id) + local triggers = client.resolved_capabilities.signature_help_trigger_characters + local ft = api.nvim_buf_get_option(ctx.bufnr, 'filetype') + local lines, hl = util.convert_signature_help_to_markdown_lines(result, ft, triggers) lines = util.trim_empty_lines(lines) if vim.tbl_isempty(lines) then - print('No signature help available') + if config.silent ~= true then + print('No signature help available') + end return end - return util.open_floating_preview(lines, "markdown", config) + local fbuf, fwin = util.open_floating_preview(lines, "markdown", config) + if hl then + api.nvim_buf_add_highlight(fbuf, -1, "LspSignatureActiveParameter", 0, unpack(hl)) + end + return fbuf, fwin end ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_signatureHelp +--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_signatureHelp M['textDocument/signatureHelp'] = M.signature_help ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_documentHighlight -M['textDocument/documentHighlight'] = function(_, _, result, _, bufnr, _) +--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_documentHighlight +M['textDocument/documentHighlight'] = function(_, result, ctx, _) if not result then return end - util.buf_highlight_references(bufnr, result) + util.buf_highlight_references(ctx.bufnr, result) end ---@private +---@private --- --- Displays call hierarchy in the quickfix window. --- ---@param direction `"from"` for incoming calls and `"to"` for outgoing calls ---@returns `CallHierarchyIncomingCall[]` if {direction} is `"from"`, ---@returns `CallHierarchyOutgoingCall[]` if {direction} is `"to"`, +---@param direction `"from"` for incoming calls and `"to"` for outgoing calls +---@returns `CallHierarchyIncomingCall[]` if {direction} is `"from"`, +---@returns `CallHierarchyOutgoingCall[]` if {direction} is `"to"`, local make_call_hierarchy_handler = function(direction) - return function(_, _, result) + return function(_, result) if not result then return end local items = {} for _, call_hierarchy_call in pairs(result) do @@ -383,16 +379,17 @@ local make_call_hierarchy_handler = function(direction) end end ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#callHierarchy/incomingCalls +--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#callHierarchy_incomingCalls M['callHierarchy/incomingCalls'] = make_call_hierarchy_handler('from') ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#callHierarchy/outgoingCalls +--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#callHierarchy_outgoingCalls M['callHierarchy/outgoingCalls'] = make_call_hierarchy_handler('to') ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#window/logMessage -M['window/logMessage'] = function(_, _, result, client_id) +--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#window_logMessage +M['window/logMessage'] = function(_, result, ctx, _) local message_type = result.type local message = result.message + local client_id = ctx.client_id local client = vim.lsp.get_client_by_id(client_id) local client_name = client and client.name or string.format("id=%d", client_id) if not client then @@ -402,7 +399,7 @@ M['window/logMessage'] = function(_, _, result, client_id) log.error(message) elseif message_type == protocol.MessageType.Warning then log.warn(message) - elseif message_type == protocol.MessageType.Info then + elseif message_type == protocol.MessageType.Info or message_type == protocol.MessageType.Log then log.info(message) else log.debug(message) @@ -410,10 +407,11 @@ M['window/logMessage'] = function(_, _, result, client_id) return result end ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#window/showMessage -M['window/showMessage'] = function(_, _, result, client_id) +--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#window_showMessage +M['window/showMessage'] = function(_, result, ctx, _) local message_type = result.type local message = result.message + local client_id = ctx.client_id local client = vim.lsp.get_client_by_id(client_id) local client_name = client and client.name or string.format("id=%d", client_id) if not client then @@ -430,16 +428,23 @@ end -- Add boilerplate error validation and logging for all of these. for k, fn in pairs(M) do - M[k] = function(err, method, params, client_id, bufnr, config) - local _ = log.debug() and log.debug('default_handler', method, { - params = params, client_id = client_id, err = err, bufnr = bufnr, config = config + M[k] = function(err, result, ctx, config) + local _ = log.trace() and log.trace('default_handler', ctx.method, { + err = err, result = result, ctx=vim.inspect(ctx), config = config }) if err then - return err_message(tostring(err)) + local client = vim.lsp.get_client_by_id(ctx.client_id) + local client_name = client and client.name or string.format("client_id=%d", ctx.client_id) + -- LSP spec: + -- interface ResponseError: + -- code: integer; + -- message: string; + -- data?: string | number | boolean | array | object | null; + return err_message(client_name .. ': ' .. tostring(err.code) .. ': ' .. err.message) end - return fn(err, method, params, client_id, bufnr, config) + return fn(err, result, ctx, config) end end diff --git a/runtime/lua/vim/lsp/health.lua b/runtime/lua/vim/lsp/health.lua new file mode 100644 index 0000000000..855679a2df --- /dev/null +++ b/runtime/lua/vim/lsp/health.lua @@ -0,0 +1,27 @@ +local M = {} + +--- Performs a healthcheck for LSP +function M.check_health() + local report_info = vim.fn['health#report_info'] + local report_warn = vim.fn['health#report_warn'] + + local log = require('vim.lsp.log') + local current_log_level = log.get_level() + local log_level_string = log.levels[current_log_level] + report_info(string.format("LSP log level : %s", log_level_string)) + + if current_log_level < log.levels.WARN then + report_warn(string.format("Log level %s will cause degraded performance and high disk usage", log_level_string)) + end + + local log_path = vim.lsp.get_log_path() + report_info(string.format("Log path: %s", log_path)) + + local log_size = vim.loop.fs_stat(log_path).size + + local report_fn = (log_size / 1000000 > 100 and report_warn or report_info) + report_fn(string.format("Log size: %d KB", log_size / 1000 )) +end + +return M + diff --git a/runtime/lua/vim/lsp/log.lua b/runtime/lua/vim/lsp/log.lua index 471a311c16..4597f1919a 100644 --- a/runtime/lua/vim/lsp/log.lua +++ b/runtime/lua/vim/lsp/log.lua @@ -14,26 +14,38 @@ log.levels = vim.deepcopy(vim.log.levels) -- Default log level is warn. local current_log_level = log.levels.WARN -local log_date_format = "%FT%H:%M:%S%z" +local log_date_format = "%F %H:%M:%S" +local format_func = function(arg) return vim.inspect(arg, {newline=''}) end do - local path_sep = vim.loop.os_uname().sysname == "Windows" and "\\" or "/" - --@private + local path_sep = vim.loop.os_uname().version:match("Windows") and "\\" or "/" + ---@private local function path_join(...) return table.concat(vim.tbl_flatten{...}, path_sep) end local logfilename = path_join(vim.fn.stdpath('cache'), 'lsp.log') --- Returns the log filename. - --@returns (string) log filename + ---@returns (string) log filename function log.get_filename() return logfilename end vim.fn.mkdir(vim.fn.stdpath('cache'), "p") local logfile = assert(io.open(logfilename, "a+")) + + local log_info = vim.loop.fs_stat(logfilename) + if log_info and log_info.size > 1e9 then + local warn_msg = string.format( + "LSP client log is large (%d MB): %s", + log_info.size / (1000 * 1000), + logfilename + ) + vim.notify(warn_msg) + end + -- Start message for logging - logfile:write(string.format("[ START ] %s ] LSP logging initiated\n", os.date(log_date_format))) + logfile:write(string.format("[START][%s] LSP logging initiated\n", os.date(log_date_format))) for level, levelnr in pairs(log.levels) do -- Also export the log level on the root object. log[level] = levelnr @@ -56,14 +68,14 @@ do if levelnr < current_log_level then return false end if argc == 0 then return true end local info = debug.getinfo(2, "Sl") - local fileinfo = string.format("%s:%s", info.short_src, info.currentline) - local parts = { table.concat({"[", level, "]", os.date(log_date_format), "]", fileinfo, "]"}, " ") } + local header = string.format("[%s][%s] ...%s:%s", level, os.date(log_date_format), string.sub(info.short_src, #info.short_src - 15), info.currentline) + local parts = { header } for i = 1, argc do local arg = select(i, ...) if arg == nil then table.insert(parts, "nil") else - table.insert(parts, vim.inspect(arg, {newline=''})) + table.insert(parts, format_func(arg)) end end logfile:write(table.concat(parts, '\t'), "\n") @@ -77,7 +89,7 @@ end vim.tbl_add_reverse_lookup(log.levels) --- Sets the current log level. ---@param level (string or number) One of `vim.lsp.log.levels` +---@param level (string or number) One of `vim.lsp.log.levels` function log.set_level(level) if type(level) == 'string' then current_log_level = assert(log.levels[level:upper()], string.format("Invalid log level: %q", level)) @@ -88,9 +100,21 @@ function log.set_level(level) end end +--- Gets the current log level. +function log.get_level() + return current_log_level +end + +--- Sets formatting function used to format logs +---@param handle function function to apply to logging arguments, pass vim.inspect for multi-line formatting +function log.set_format_func(handle) + assert(handle == vim.inspect or type(handle) == 'function', "handle must be a function") + format_func = handle +end + --- Checks whether the level is sufficient for logging. ---@param level number log level ---@returns (bool) true if would log, false if not +---@param level number log level +---@returns (bool) true if would log, false if not function log.should_log(level) return level >= current_log_level end diff --git a/runtime/lua/vim/lsp/protocol.lua b/runtime/lua/vim/lsp/protocol.lua index 7e43eb84de..b3aa8b934f 100644 --- a/runtime/lua/vim/lsp/protocol.lua +++ b/runtime/lua/vim/lsp/protocol.lua @@ -5,14 +5,14 @@ local if_nil = vim.F.if_nil local protocol = {} --[=[ ---@private +---@private --- Useful for interfacing with: --- https://github.com/microsoft/language-server-protocol/raw/gh-pages/_specifications/specification-3-14.md function transform_schema_comments() nvim.command [[silent! '<,'>g/\/\*\*\|\*\/\|^$/d]] nvim.command [[silent! '<,'>s/^\(\s*\) \* \=\(.*\)/\1--\2/]] end ---@private +---@private function transform_schema_to_table() transform_schema_comments() nvim.command [[silent! '<,'>s/: \S\+//]] @@ -645,6 +645,10 @@ function protocol.make_client_capabilities() end)(); }; }; + dataSupport = true; + resolveSupport = { + properties = { 'edit', } + }; }; completion = { dynamicRegistration = false; @@ -691,10 +695,11 @@ function protocol.make_client_capabilities() signatureHelp = { dynamicRegistration = false; signatureInformation = { + activeParameterSupport = true; documentationFormat = { protocol.MarkupKind.Markdown; protocol.MarkupKind.PlainText }; - -- parameterInformation = { - -- labelOffsetSupport = false; - -- }; + parameterInformation = { + labelOffsetSupport = true; + }; }; }; references = { @@ -1002,8 +1007,7 @@ function protocol.resolve_capabilities(server_capabilities) elseif type(server_capabilities.declarationProvider) == 'boolean' then general_properties.declaration = server_capabilities.declarationProvider elseif type(server_capabilities.declarationProvider) == 'table' then - -- TODO: support more detailed declarationProvider options. - general_properties.declaration = false + general_properties.declaration = server_capabilities.declarationProvider else error("The server sent invalid declarationProvider") end @@ -1013,8 +1017,7 @@ function protocol.resolve_capabilities(server_capabilities) elseif type(server_capabilities.typeDefinitionProvider) == 'boolean' then general_properties.type_definition = server_capabilities.typeDefinitionProvider elseif type(server_capabilities.typeDefinitionProvider) == 'table' then - -- TODO: support more detailed typeDefinitionProvider options. - general_properties.type_definition = false + general_properties.type_definition = server_capabilities.typeDefinitionProvider else error("The server sent invalid typeDefinitionProvider") end @@ -1024,8 +1027,7 @@ function protocol.resolve_capabilities(server_capabilities) elseif type(server_capabilities.implementationProvider) == 'boolean' then general_properties.implementation = server_capabilities.implementationProvider elseif type(server_capabilities.implementationProvider) == 'table' then - -- TODO(ashkan) support more detailed implementation options. - general_properties.implementation = false + general_properties.implementation = server_capabilities.implementationProvider else error("The server sent invalid implementationProvider") end diff --git a/runtime/lua/vim/lsp/rpc.lua b/runtime/lua/vim/lsp/rpc.lua index 4c5f02af9d..255eb65dfe 100644 --- a/runtime/lua/vim/lsp/rpc.lua +++ b/runtime/lua/vim/lsp/rpc.lua @@ -4,38 +4,10 @@ local log = require('vim.lsp.log') local protocol = require('vim.lsp.protocol') local validate, schedule, schedule_wrap = vim.validate, vim.schedule, vim.schedule_wrap --- TODO replace with a better implementation. ---@private ---- Encodes to JSON. ---- ---@param data (table) Data to encode ---@returns (string) Encoded object -local function json_encode(data) - local status, result = pcall(vim.fn.json_encode, data) - if status then - return result - else - return nil, result - end -end ---@private ---- Decodes from JSON. ---- ---@param data (string) Data to decode ---@returns (table) Decoded JSON object -local function json_decode(data) - local status, result = pcall(vim.fn.json_decode, data) - if status then - return result - else - return nil, result - end -end - ---@private +---@private --- Checks whether a given path exists and is a directory. ---@param filename (string) path to check ---@returns (bool) +---@param filename (string) path to check +---@returns (bool) local function is_dir(filename) local stat = vim.loop.fs_stat(filename) return stat and stat.type == 'directory' or false @@ -43,7 +15,7 @@ end local NIL = vim.NIL ---@private +---@private local recursive_convert_NIL recursive_convert_NIL = function(v, tbl_processed) if v == NIL then @@ -63,15 +35,15 @@ recursive_convert_NIL = function(v, tbl_processed) return v end ---@private +---@private --- Returns its argument, but converts `vim.NIL` to Lua `nil`. ---@param v (any) Argument ---@returns (any) +---@param v (any) Argument +---@returns (any) local function convert_NIL(v) return recursive_convert_NIL(v, {}) end ---@private +---@private --- Merges current process env with the given env and returns the result as --- a list of "k=v" strings. --- @@ -81,8 +53,8 @@ end --- in: { PRODUCTION="false", PATH="/usr/bin/", PORT=123, HOST="0.0.0.0", } --- out: { "PRODUCTION=false", "PATH=/usr/bin/", "PORT=123", "HOST=0.0.0.0", } --- </pre> ---@param env (table) table of environment variable assignments ---@returns (table) list of `"k=v"` strings +---@param env (table) table of environment variable assignments +---@returns (table) list of `"k=v"` strings local function env_merge(env) if env == nil then return env @@ -97,11 +69,11 @@ local function env_merge(env) return final_env end ---@private +---@private --- Embeds the given string into a table and correctly computes `Content-Length`. --- ---@param encoded_message (string) ---@returns (table) table containing encoded message and `Content-Length` attribute +---@param encoded_message (string) +---@returns (table) table containing encoded message and `Content-Length` attribute local function format_message_with_content_length(encoded_message) return table.concat { 'Content-Length: '; tostring(#encoded_message); '\r\n\r\n'; @@ -109,11 +81,11 @@ local function format_message_with_content_length(encoded_message) } end ---@private +---@private --- Parses an LSP Message's header --- ---@param header: The header to parse. ---@returns Parsed headers +---@param header: The header to parse. +---@returns Parsed headers local function parse_headers(header) if type(header) ~= 'string' then return nil @@ -141,7 +113,7 @@ end -- case insensitive pattern. local header_start_pattern = ("content"):gsub("%w", function(c) return "["..c..c:upper().."]" end) ---@private +---@private --- The actual workhorse. local function request_parser_loop() local buffer = '' -- only for header part @@ -203,8 +175,8 @@ local client_errors = vim.tbl_add_reverse_lookup { --- Constructs an error message from an LSP error object. --- ---@param err (table) The error object ---@returns (string) The formatted error message +---@param err (table) The error object +---@returns (string) The formatted error message local function format_rpc_error(err) validate { err = { err, 't' }; @@ -233,9 +205,9 @@ end --- Creates an RPC response object/table. --- ---@param code RPC error code defined in `vim.lsp.protocol.ErrorCodes` ---@param message (optional) arbitrary message to send to server ---@param data (optional) arbitrary data to send to server +---@param code RPC error code defined in `vim.lsp.protocol.ErrorCodes` +---@param message (optional) arbitrary message to send to server +---@param data (optional) arbitrary data to send to server local function rpc_response_error(code, message, data) -- TODO should this error or just pick a sane error (like InternalError)? local code_name = assert(protocol.ErrorCodes[code], 'Invalid RPC error code') @@ -250,38 +222,38 @@ end local default_dispatchers = {} ---@private +---@private --- Default dispatcher for notifications sent to an LSP server. --- ---@param method (string) The invoked LSP method ---@param params (table): Parameters for the invoked LSP method +---@param method (string) The invoked LSP method +---@param params (table): Parameters for the invoked LSP method function default_dispatchers.notification(method, params) local _ = log.debug() and log.debug('notification', method, params) end ---@private +---@private --- Default dispatcher for requests sent to an LSP server. --- ---@param method (string) The invoked LSP method ---@param params (table): Parameters for the invoked LSP method ---@returns `nil` and `vim.lsp.protocol.ErrorCodes.MethodNotFound`. +---@param method (string) The invoked LSP method +---@param params (table): Parameters for the invoked LSP method +---@returns `nil` and `vim.lsp.protocol.ErrorCodes.MethodNotFound`. function default_dispatchers.server_request(method, params) local _ = log.debug() and log.debug('server_request', method, params) return nil, rpc_response_error(protocol.ErrorCodes.MethodNotFound) end ---@private +---@private --- Default dispatcher for when a client exits. --- ---@param code (number): Exit code ---@param signal (number): Number describing the signal used to terminate (if +---@param code (number): Exit code +---@param signal (number): Number describing the signal used to terminate (if ---any) function default_dispatchers.on_exit(code, signal) local _ = log.info() and log.info("client_exit", { code = code, signal = signal }) end ---@private +---@private --- Default dispatcher for client errors. --- ---@param code (number): Error code ---@param err (any): Details about the error +---@param code (number): Error code +---@param err (any): Details about the error ---any) function default_dispatchers.on_error(code, err) local _ = log.error() and log.error('client_error:', client_errors[code], err) @@ -290,25 +262,25 @@ end --- Starts an LSP server process and create an LSP RPC client object to --- interact with it. --- ---@param cmd (string) Command to start the LSP server. ---@param cmd_args (table) List of additional string arguments to pass to {cmd}. ---@param dispatchers (table, optional) Dispatchers for LSP message types. Valid +---@param cmd (string) Command to start the LSP server. +---@param cmd_args (table) List of additional string arguments to pass to {cmd}. +---@param dispatchers (table, optional) Dispatchers for LSP message types. Valid ---dispatcher names are: --- - `"notification"` --- - `"server_request"` --- - `"on_error"` --- - `"on_exit"` ---@param extra_spawn_params (table, optional) Additional context for the LSP +---@param extra_spawn_params (table, optional) Additional context for the LSP --- server process. May contain: --- - {cwd} (string) Working directory for the LSP server process --- - {env} (table) Additional environment variables for LSP server process ---@returns Client RPC object. +---@returns Client RPC object. --- ---@returns Methods: +---@returns Methods: --- - `notify()` |vim.lsp.rpc.notify()| --- - `request()` |vim.lsp.rpc.request()| --- ---@returns Members: +---@returns Members: --- - {pid} (number) The LSP server's PID. --- - {handle} A handle for low-level interaction with the LSP server process --- |vim.loop|. @@ -358,10 +330,10 @@ local function start(cmd, cmd_args, dispatchers, extra_spawn_params) local handle, pid do - --@private + ---@private --- Callback for |vim.loop.spawn()| Closes all streams and runs the `on_exit` dispatcher. - --@param code (number) Exit code - --@param signal (number) Signal that was used to terminate (if any) + ---@param code (number) Exit code + ---@param signal (number) Signal that was used to terminate (if any) local function onexit(code, signal) stdin:close() stdout:close() @@ -385,20 +357,17 @@ local function start(cmd, cmd_args, dispatchers, extra_spawn_params) end end - --@private + ---@private --- Encodes {payload} into a JSON-RPC message and sends it to the remote --- process. --- - --@param payload (table) Converted into a JSON string, see |json_encode()| - --@returns true if the payload could be scheduled, false if the main event-loop is in the process of closing. + ---@param payload table + ---@returns true if the payload could be scheduled, false if the main event-loop is in the process of closing. local function encode_and_send(payload) - local _ = log.debug() and log.debug("rpc.send.payload", payload) + local _ = log.debug() and log.debug("rpc.send", payload) if handle == nil or handle:is_closing() then return false end - -- TODO(ashkan) remove this once we have a Lua json_encode - schedule(function() - local encoded = assert(json_encode(payload)) - stdin:write(format_message_with_content_length(encoded)) - end) + local encoded = vim.json.encode(payload) + stdin:write(format_message_with_content_length(encoded)) return true end @@ -406,9 +375,9 @@ local function start(cmd, cmd_args, dispatchers, extra_spawn_params) -- `start()` -- --- Sends a notification to the LSP server. - --@param method (string) The invoked LSP method - --@param params (table): Parameters for the invoked LSP method - --@returns (bool) `true` if notification could be sent, `false` if not + ---@param method (string) The invoked LSP method + ---@param params (table): Parameters for the invoked LSP method + ---@returns (bool) `true` if notification could be sent, `false` if not local function notify(method, params) return encode_and_send { jsonrpc = "2.0"; @@ -417,7 +386,7 @@ local function start(cmd, cmd_args, dispatchers, extra_spawn_params) } end - --@private + ---@private --- sends an error object to the remote LSP process. local function send_response(request_id, err, result) return encode_and_send { @@ -433,10 +402,10 @@ local function start(cmd, cmd_args, dispatchers, extra_spawn_params) -- --- Sends a request to the LSP server and runs {callback} upon response. --- - --@param method (string) The invoked LSP method - --@param params (table) Parameters for the invoked LSP method - --@param callback (function) Callback to invoke - --@returns (bool, number) `(true, message_id)` if request could be sent, `false` if not + ---@param method (string) The invoked LSP method + ---@param params (table) Parameters for the invoked LSP method + ---@param callback (function) Callback to invoke + ---@returns (bool, number) `(true, message_id)` if request could be sent, `false` if not local function request(method, params, callback) validate { callback = { callback, 'f' }; @@ -463,13 +432,13 @@ local function start(cmd, cmd_args, dispatchers, extra_spawn_params) end end) - --@private + ---@private local function on_error(errkind, ...) assert(client_errors[errkind]) -- TODO what to do if this fails? pcall(dispatchers.on_error, errkind, ...) end - --@private + ---@private local function pcall_handler(errkind, status, head, ...) if not status then on_error(errkind, head, ...) @@ -477,7 +446,7 @@ local function start(cmd, cmd_args, dispatchers, extra_spawn_params) end return status, head, ... end - --@private + ---@private local function try_call(errkind, fn, ...) return pcall_handler(errkind, pcall(fn, ...)) end @@ -486,16 +455,17 @@ local function start(cmd, cmd_args, dispatchers, extra_spawn_params) -- time and log them. This would require storing the timestamp. I could call -- them with an error then, perhaps. - --@private + ---@private local function handle_body(body) - local decoded, err = json_decode(body) - if not decoded then - -- on_error(client_errors.INVALID_SERVER_JSON, err) + local ok, decoded = pcall(vim.json.decode, body) + if not ok then + on_error(client_errors.INVALID_SERVER_JSON, decoded) return end - local _ = log.debug() and log.debug("decoded", decoded) + local _ = log.debug() and log.debug("rpc.receive", decoded) if type(decoded.method) == 'string' and decoded.id then + local err -- Server Request decoded.params = convert_NIL(decoded.params) -- Schedule here so that the users functions don't trigger an error and @@ -582,8 +552,6 @@ local function start(cmd, cmd_args, dispatchers, extra_spawn_params) on_error(client_errors.INVALID_SERVER_MESSAGE, decoded) end end - -- TODO(ashkan) remove this once we have a Lua json_decode - handle_body = schedule_wrap(handle_body) local request_parser = coroutine.wrap(request_parser_loop) request_parser() diff --git a/runtime/lua/vim/lsp/util.lua b/runtime/lua/vim/lsp/util.lua index 195e3a0e65..e95f170427 100644 --- a/runtime/lua/vim/lsp/util.lua +++ b/runtime/lua/vim/lsp/util.lua @@ -1,4 +1,5 @@ local protocol = require 'vim.lsp.protocol' +local snippet = require 'vim.lsp._snippet' local vim = vim local validate = vim.validate local api = vim.api @@ -30,20 +31,12 @@ local default_border = { {" ", "NormalFloat"}, } - -local DiagnosticSeverity = protocol.DiagnosticSeverity -local loclist_type_map = { - [DiagnosticSeverity.Error] = 'E', - [DiagnosticSeverity.Warning] = 'W', - [DiagnosticSeverity.Information] = 'I', - [DiagnosticSeverity.Hint] = 'I', -} - - ---@private --- Check the border given by opts or the default border for the additional --- size it adds to a float. ---@returns size of border in height and width +---@private +--- 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 +---@returns (table) size of border in the form of { height = height, width = width } local function get_border_size(opts) local border = opts and opts.border or default_border local height = 0 @@ -52,12 +45,16 @@ local function get_border_size(opts) 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("floating preview border is not correct. Please refer to the docs |vim.api.nvim_open_win()|" - .. vim.inspect(border)) + error(string.format("invalid floating preview border: %s. :help vim.api.nvim_open_win()", vim.inspect(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 + ---@private 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]) @@ -65,9 +62,11 @@ local function get_border_size(opts) -- border specified as a list of border characters return vim.fn.strdisplaywidth(border[id]) end - error("floating preview border is not correct. Please refer to the docs |vim.api.nvim_open_win()|" .. vim.inspect(border)) + error(string.format("invalid floating preview border: %s. :help vim.api.nvim_open_win()", vim.inspect(border))) end + ---@private 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 @@ -75,7 +74,7 @@ local function get_border_size(opts) -- border specified as a list of border characters return #border[id] > 0 and 1 or 0 end - error("floating preview border is not correct. Please refer to the docs |vim.api.nvim_open_win()|" .. vim.inspect(border)) + error(string.format("invalid floating preview border: %s. :help vim.api.nvim_open_win()", vim.inspect(border))) end height = height + border_height(2) -- top height = height + border_height(6) -- bottom @@ -86,7 +85,7 @@ local function get_border_size(opts) return { height = height, width = width } end ---@private +---@private local function split_lines(value) return split(value, '\n', true) end @@ -95,11 +94,11 @@ end --- --- CAUTION: Changes in-place! --- ---@param lines (table) Original list of strings ---@param A (table) Start position; a 2-tuple of {line, col} numbers ---@param B (table) End position; a 2-tuple of {line, col} numbers ---@param new_lines A list of strings to replace the original ---@returns (table) The modified {lines} object +---@param lines (table) Original list of strings +---@param A (table) Start position; a 2-tuple of {line, col} numbers +---@param B (table) End position; a 2-tuple of {line, col} numbers +---@param new_lines A list of strings to replace the original +---@returns (table) The modified {lines} object function M.set_lines(lines, A, B, new_lines) -- 0-indexing to 1-indexing local i_0 = A[1] + 1 @@ -133,7 +132,7 @@ function M.set_lines(lines, A, B, new_lines) return lines end ---@private +---@private local function sort_by_key(fn) return function(a,b) local ka, kb = fn(a), fn(b) @@ -147,12 +146,8 @@ local function sort_by_key(fn) return false end end ---@private -local edit_sort_key = sort_by_key(function(e) - return {e.A[1], e.A[2], e.i} -end) ---@private +---@private --- 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 @@ -175,6 +170,7 @@ local function get_line_byte_from_position(bufnr, position) if ok then return result end + return math.min(#lines[1], col) end end return col @@ -238,53 +234,119 @@ function M.get_progress_messages() end --- Applies a list of text edits to a buffer. ---@param text_edits (table) list of `TextEdit` objects ---@param buf_nr (number) Buffer id +---@param text_edits table list of `TextEdit` objects +---@param bufnr number Buffer id +---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textEdit 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 == 0 or #lines[#lines] ~= 0) then - table.insert(lines, '') + -- Fix reversed range and indexing each text_edits + local index = 0 + text_edits = vim.tbl_map(function(text_edit) + index = index + 1 + text_edit._index = index + + if text_edit.range.start.line > text_edit.range['end'].line or text_edit.range.start.line == text_edit.range['end'].line and text_edit.range.start.character > text_edit.range['end'].character then + local start = text_edit.range.start + text_edit.range.start = text_edit.range['end'] + text_edit.range['end'] = start + end + return text_edit + end, text_edits) + + -- Sort text_edits + table.sort(text_edits, function(a, b) + if a.range.start.line ~= b.range.start.line then + return a.range.start.line > b.range.start.line + end + if a.range.start.character ~= b.range.start.character then + return a.range.start.character > b.range.start.character + end + if a._index ~= b._index then + return a._index > b._index + end + end) + + -- 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. + local has_eol_text_edit = false + local max = vim.api.nvim_buf_line_count(bufnr) + local len = vim.str_utfindex(vim.api.nvim_buf_get_lines(bufnr, -2, -1, false)[1] or '') + text_edits = vim.tbl_map(function(text_edit) + if max <= text_edit.range.start.line then + text_edit.range.start.line = max - 1 + text_edit.range.start.character = len + text_edit.newText = '\n' .. text_edit.newText + has_eol_text_edit = true + end + if max <= text_edit.range['end'].line then + text_edit.range['end'].line = max - 1 + text_edit.range['end'].character = len + has_eol_text_edit = true + end + return text_edit + end, text_edits) + + -- Some LSP servers are depending on the VSCode behavior. + -- The VSCode will re-locate the cursor position after applying TextEdit so we also do it. + local is_current_buf = vim.api.nvim_get_current_buf() == bufnr + local cursor = (function() + if not is_current_buf then + return { + row = -1, + col = -1, + } + end + local cursor = vim.api.nvim_win_get_cursor(0) + return { + row = cursor[1] - 1, + col = cursor[2], + } + end)() + + -- Apply text edits. + local is_cursor_fixed = false + for _, text_edit in ipairs(text_edits) do + local e = { + start_row = text_edit.range.start.line, + start_col = get_line_byte_from_position(bufnr, text_edit.range.start), + end_row = text_edit.range['end'].line, + end_col = get_line_byte_from_position(bufnr, text_edit.range['end']), + text = vim.split(text_edit.newText, '\n', true), + } + vim.api.nvim_buf_set_text(bufnr, e.start_row, e.start_col, e.end_row, e.end_col, e.text) + + local row_count = (e.end_row - e.start_row) + 1 + if e.end_row < cursor.row then + cursor.row = cursor.row + (#e.text - row_count) + is_cursor_fixed = true + elseif e.end_row == cursor.row and e.end_col <= cursor.col then + cursor.row = cursor.row + (#e.text - row_count) + cursor.col = #e.text[#e.text] + (cursor.col - e.end_col) + if #e.text == 1 then + cursor.col = cursor.col + e.start_col + end + is_cursor_fixed = true + end 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) + if is_cursor_fixed then + vim.api.nvim_win_set_cursor(0, { + cursor.row + 1, + math.min(cursor.col, #(vim.api.nvim_buf_get_lines(bufnr, cursor.row, cursor.row + 1, false)[1] or '')) + }) end - if set_eol and #lines[#lines] == 0 then - table.remove(lines) + + -- Remove final line if needed + local fix_eol = has_eol_text_edit + fix_eol = fix_eol and api.nvim_buf_get_option(bufnr, 'fixeol') + fix_eol = fix_eol and (vim.api.nvim_buf_get_lines(bufnr, -2, -1, false)[1] or '') == '' + if fix_eol then + vim.api.nvim_buf_set_lines(bufnr, -2, -1, false, {}) end - api.nvim_buf_set_lines(bufnr, start_line, finish_line + 1, false, lines) end -- local valid_windows_path_characters = "[^<>:\"/\\|?*]" @@ -294,11 +356,11 @@ end -- function M.glob_to_regex(glob) -- end ---@private +---@private --- Finds the first line and column of the difference between old and new lines ---@param old_lines table list of lines ---@param new_lines table list of lines ---@returns (int, int) start_line_idx and start_col_idx of range +---@param old_lines table list of lines +---@param new_lines table list of lines +---@returns (int, int) start_line_idx and start_col_idx of range local function first_difference(old_lines, new_lines, start_line_idx) local line_count = math.min(#old_lines, #new_lines) if line_count == 0 then return 1, 1 end @@ -324,12 +386,12 @@ local function first_difference(old_lines, new_lines, start_line_idx) end ---@private +---@private --- Finds the last line and column of the differences between old and new lines ---@param old_lines table list of lines ---@param new_lines table list of lines ---@param start_char integer First different character idx of range ---@returns (int, int) end_line_idx and end_col_idx of range +---@param old_lines table list of lines +---@param new_lines table list of lines +---@param start_char integer First different character idx of range +---@returns (int, int) end_line_idx and end_col_idx of range local function last_difference(old_lines, new_lines, start_char, end_line_idx) local line_count = math.min(#old_lines, #new_lines) if line_count == 0 then return 0,0 end @@ -368,14 +430,14 @@ local function last_difference(old_lines, new_lines, start_char, end_line_idx) end ---@private +---@private --- Get the text of the range defined by start and end line/column ---@param lines table list of lines ---@param start_char integer First different character idx of range ---@param end_char integer Last different character idx of range ---@param start_line integer First different line idx of range ---@param end_line integer Last different line idx of range ---@returns string text extracted from defined region +---@param lines table list of lines +---@param start_char integer First different character idx of range +---@param end_char integer Last different character idx of range +---@param start_line integer First different line idx of range +---@param end_line integer Last different line idx of range +---@returns string text extracted from defined region local function extract_text(lines, start_line, start_char, end_line, end_char) if start_line == #lines + end_line + 1 then if end_line == 0 then return '' end @@ -395,14 +457,14 @@ local function extract_text(lines, start_line, start_char, end_line, end_char) return result end ---@private +---@private --- Compute the length of the substituted range ---@param lines table list of lines ---@param start_char integer First different character idx of range ---@param end_char integer Last different character idx of range ---@param start_line integer First different line idx of range ---@param end_line integer Last different line idx of range ---@returns (int, int) end_line_idx and end_col_idx of range +---@param lines table list of lines +---@param start_char integer First different character idx of range +---@param end_char integer Last different character idx of range +---@param start_line integer First different line idx of range +---@param end_line integer Last different line idx of range +---@returns (int, int) end_line_idx and end_col_idx of range local function compute_length(lines, start_line, start_char, end_line, end_char) local adj_end_line = #lines + end_line + 1 local adj_end_char @@ -423,12 +485,12 @@ local function compute_length(lines, start_line, start_char, end_line, end_char) end --- Returns the range table for the difference between old and new lines ---@param old_lines table list of lines ---@param new_lines table list of lines ---@param start_line_idx int line to begin search for first difference ---@param end_line_idx int line to begin search for last difference ---@param offset_encoding string encoding requested by language server ---@returns table start_line_idx and start_col_idx of range +---@param old_lines table list of lines +---@param new_lines table list of lines +---@param start_line_idx int line to begin search for first difference +---@param end_line_idx int line to begin search for last difference +---@param offset_encoding string encoding requested by language server +---@returns table start_line_idx and start_col_idx of range function M.compute_diff(old_lines, new_lines, start_line_idx, end_line_idx, offset_encoding) local start_line, start_char = first_difference(old_lines, new_lines, start_line_idx) local end_line, end_char = last_difference(vim.list_slice(old_lines, start_line, #old_lines), @@ -468,9 +530,9 @@ end --- Can be used to extract the completion items from a --- `textDocument/completion` request, which may return one of --- `CompletionItem[]`, `CompletionList` or null. ---@param result (table) The result of a `textDocument/completion` request ---@returns (table) List of completion items ---@see https://microsoft.github.io/language-server-protocol/specification#textDocument_completion +---@param result (table) The result of a `textDocument/completion` request +---@returns (table) List of completion items +---@see https://microsoft.github.io/language-server-protocol/specification#textDocument_completion function M.extract_completion_items(result) if type(result) == 'table' and result.items then -- result is a `CompletionList` @@ -514,90 +576,34 @@ function M.apply_text_document_edit(text_document_edit, index) M.apply_text_edits(text_document_edit.edits, bufnr) end ---@private ---- Recursively parses snippets in a completion entry. ---- ---@param input (string) Snippet text to parse for snippets ---@param inner (bool) Whether this function is being called recursively ---@returns 2-tuple of strings: The first is the parsed result, the second is the ----unparsed rest of the input -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 - --- Parses snippets in a completion entry. --- ---@param input (string) unparsed snippet ---@returns (string) parsed snippet +---@param input string unparsed snippet +---@returns string parsed snippet function M.parse_snippet(input) - local res, _ = parse_snippet_rec(input, false) - - return res + local ok, parsed = pcall(function() + return tostring(snippet.parse(input)) + end) + if not ok then + return input + end + return parsed end ---@private +---@private --- Sorts by CompletionItem.sortText. --- ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_completion +--see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_completion local function sort_completion_items(items) table.sort(items, function(a, b) return (a.sortText or a.label) < (b.sortText or b.label) end) end ---@private +---@private --- Returns text that should be inserted when selecting completion item. The --- precedence is as follows: textEdit.newText > insertText > label ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_completion +--see 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 and item.textEdit.newText ~= "" then local insert_text_format = protocol.InsertTextFormat[item.insertTextFormat] @@ -617,7 +623,7 @@ local function get_completion_word(item) return item.label end ---@private +---@private --- Some language servers return complementary candidates whose prefixes do not --- match are also returned. So we exclude completion candidates whose prefix --- does not match. @@ -632,9 +638,9 @@ end --- the client must handle it properly even if it receives a value outside the --- specification. --- ---@param completion_item_kind (`vim.lsp.protocol.completionItemKind`) ---@returns (`vim.lsp.protocol.completionItemKind`) ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_completion +---@param completion_item_kind (`vim.lsp.protocol.completionItemKind`) +---@returns (`vim.lsp.protocol.completionItemKind`) +---@see 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 @@ -642,12 +648,12 @@ end --- Turns the result of a `textDocument/completion` request into vim-compatible --- |complete-items|. --- ---@param result The result of a `textDocument/completion` call, e.g. from +---@param result The result of a `textDocument/completion` call, e.g. from ---|vim.lsp.buf.completion()|, which may be one of `CompletionItem[]`, --- `CompletionList` or `null` ---@param prefix (string) the prefix to filter the completion items ---@returns { matches = complete-items table, incomplete = bool } ---@see |complete-items| +---@param prefix (string) the prefix to filter the completion items +---@returns { matches = complete-items table, incomplete = bool } +---@see |complete-items| function M.text_document_completion_list_to_complete_items(result, prefix) local items = M.extract_completion_items(result) if vim.tbl_isempty(items) then @@ -697,8 +703,8 @@ end --- Rename old_fname to new_fname --- ---@param opts (table) +--- +---@param opts (table) -- overwrite? bool -- ignoreIfExists? bool function M.rename(old_fname, new_fname, opts) @@ -753,8 +759,8 @@ end --- Applies a `WorkspaceEdit`. --- ---@param workspace_edit (table) `WorkspaceEdit` --- @see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#workspace_applyEdit +---@param workspace_edit (table) `WorkspaceEdit` +--see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#workspace_applyEdit function M.apply_workspace_edit(workspace_edit) if workspace_edit.documentChanges then for idx, change in ipairs(workspace_edit.documentChanges) do @@ -793,10 +799,10 @@ end --- window for `textDocument/hover`, for parsing the result of --- `textDocument/signatureHelp`, and potentially others. --- ---@param input (`MarkedString` | `MarkedString[]` | `MarkupContent`) ---@param contents (table, optional, default `{}`) List of strings to extend with converted lines ---@returns {contents}, extended with lines of converted markdown. ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_hover +---@param input (`MarkedString` | `MarkedString[]` | `MarkupContent`) +---@param contents (table, optional, default `{}`) List of strings to extend with converted lines +---@returns {contents}, 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) contents = contents or {} -- MarkedString variation 1 @@ -810,16 +816,16 @@ function M.convert_input_to_markdown_lines(input, contents) -- If it's plaintext, then wrap it in a <text></text> block -- Some servers send input.value as empty, so let's ignore this :( - input.value = input.value or '' + local value = input.value or '' if input.kind == "plaintext" then -- wrap this in a <text></text> block so that stylize_markdown -- can properly process it as plaintext - input.value = string.format("<text>\n%s\n</text>", input.value or "") + value = string.format("<text>\n%s\n</text>", value) end - -- assert(type(input.value) == 'string') - list_extend(contents, split_lines(input.value)) + -- assert(type(value) == 'string') + list_extend(contents, split_lines(value)) -- MarkupString variation 2 elseif input.language then -- Some servers send input.value as empty, so let's ignore this :( @@ -843,11 +849,12 @@ end --- Converts `textDocument/SignatureHelp` response to markdown lines. --- ---@param signature_help Response of `textDocument/SignatureHelp` ---@param ft optional filetype that will be use as the `lang` for the label markdown code block ---@returns list of lines of converted markdown. ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_signatureHelp -function M.convert_signature_help_to_markdown_lines(signature_help, ft) +---@param signature_help Response of `textDocument/SignatureHelp` +---@param ft optional filetype that will be use as the `lang` for the label markdown code block +---@param triggers optional list of trigger characters from the lsp server. used to better determine parameter offsets +---@returns list of lines of converted markdown. +---@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 @@ -856,6 +863,7 @@ function M.convert_signature_help_to_markdown_lines(signature_help, ft) --=== 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 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 @@ -875,11 +883,17 @@ function M.convert_signature_help_to_markdown_lines(signature_help, ft) M.convert_input_to_markdown_lines(signature.documentation, contents) end if signature.parameters and #signature.parameters > 0 then - local active_parameter = signature_help.activeParameter or 0 - -- If the activeParameter is not inside the valid range, then clip it. + local active_parameter = (signature.activeParameter or signature_help.activeParameter or 0) + if active_parameter < 0 + then active_parameter = 0 + end + + -- 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 = 0 + active_parameter = #signature.parameters - 1 end + local parameter = signature.parameters[active_parameter + 1] if parameter then --[=[ @@ -900,22 +914,44 @@ function M.convert_signature_help_to_markdown_lines(signature_help, ft) documentation?: string | MarkupContent; } --]=] - -- TODO highlight parameter + 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 + end + end if parameter.documentation then M.convert_input_to_markdown_lines(parameter.documentation, contents) end end end - return contents + return contents, active_hl end --- Creates a table with sensible default options for a floating window. The --- table can be passed to |nvim_open_win()|. --- ---@param width (number) window width (in character cells) ---@param height (number) window height (in character cells) ---@param opts (table, optional) ---@returns (table) Options +---@param width (number) window width (in character cells) +---@param height (number) window height (in character cells) +---@param opts (table, optional) +---@returns (table) Options function M.make_floating_popup_options(width, height, opts) validate { opts = { opts, 't', true }; @@ -942,7 +978,7 @@ function M.make_floating_popup_options(width, height, opts) row = -get_border_size(opts).height end - if vim.fn.wincol() + width <= api.nvim_get_option('columns') then + if vim.fn.wincol() + width + (opts.offset_x or 0) <= api.nvim_get_option('columns') then anchor = anchor..'W' col = 0 else @@ -960,13 +996,14 @@ function M.make_floating_popup_options(width, height, opts) style = 'minimal', width = width, border = opts.border or default_border, + zindex = opts.zindex or 50, } end --- Jumps to a location. --- ---@param location (`Location`|`LocationLink`) ---@returns `true` if the jump succeeded +---@param location (`Location`|`LocationLink`) +---@returns `true` if the jump succeeded function M.jump_to_location(location) -- location may be Location or LocationLink local uri = location.uri or location.targetUri @@ -996,8 +1033,8 @@ end --- - 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` ---@returns (bufnr,winnr) buffer and window number of floating window or nil +---@param location a single `Location` or `LocationLink` +---@returns (bufnr,winnr) buffer and window number of floating window or nil function M.preview_location(location, opts) -- location may be LocationLink or Location (more useful for the former) local uri = location.targetUri or location.uri @@ -1020,7 +1057,7 @@ function M.preview_location(location, opts) return M.open_floating_preview(contents, syntax, opts) end ---@private +---@private 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 @@ -1056,10 +1093,10 @@ function M._trim(contents, opts) return contents end --- Generates a table mapping markdown code block lang to vim syntax, --- based on g:markdown_fenced_languages --- @return a table of lang -> syntax mappings --- @private +--- Generates a table mapping markdown code block lang to vim syntax, +--- based on g:markdown_fenced_languages +---@return a table of lang -> syntax mappings +---@private local function get_markdown_fences() local fences = {} for _, fence in pairs(vim.g.markdown_fenced_languages or {}) do @@ -1129,6 +1166,8 @@ function M.stylize_markdown(bufnr, contents, opts) -- Clean up contents = M._trim(contents, opts) + -- Insert blank line separator after code block? + local add_sep = opts.separator == nil and true or opts.separator local stripped = {} local highlights = {} -- keep track of lnums that contain markdown @@ -1155,9 +1194,24 @@ function M.stylize_markdown(bufnr, contents, opts) start = start + 1; finish = #stripped; }) + -- add a separator, but not on the last line + if add_sep and i < #contents then + table.insert(stripped, "---") + markdown_lines[#stripped] = true + end else - table.insert(stripped, line) - markdown_lines[#stripped] = true + -- strip any emty 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 @@ -1165,7 +1219,7 @@ function M.stylize_markdown(bufnr, 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(stripped, opts) + local width = M._make_floating_popup_size(stripped, opts) local sep_line = string.rep("─", math.min(width, opts.wrap_at or width)) @@ -1175,30 +1229,10 @@ function M.stylize_markdown(bufnr, contents, opts) end end - -- Insert blank line separator after code block - local insert_separator = opts.separator - if insert_separator == nil then insert_separator = true end - if insert_separator then - local offset = 0 - for _, h in ipairs(highlights) do - h.start = h.start + offset - h.finish = h.finish + offset - -- check if a seperator already exists and use that one instead of creating a new one - if h.finish + 1 <= #stripped then - if stripped[h.finish + 1] ~= sep_line then - table.insert(stripped, h.finish + 1, sep_line) - offset = offset + 1 - height = height + 1 - end - end - end - end - - vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, stripped) local idx = 1 - --@private + ---@private -- keep track of syntaxes we already inlcuded. -- no need to include the same syntax more than once local langs = {} @@ -1247,26 +1281,26 @@ end --- Creates autocommands to close a preview window when events happen. --- ---@param events (table) list of events ---@param winnr (number) window id of preview window ---@see |autocmd-events| +---@param events (table) list of events +---@param winnr (number) window id of preview window +---@see |autocmd-events| function M.close_preview_autocmd(events, winnr) if #events > 0 then api.nvim_command("autocmd "..table.concat(events, ',').." <buffer> ++once lua pcall(vim.api.nvim_win_close, "..winnr..", true)") end end ---@internal +---@internal --- Computes 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 ---@returns width,height size of float +---@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 +---@returns width,height size of float function M._make_floating_popup_size(contents, opts) validate { contents = { contents, 't' }; @@ -1333,9 +1367,9 @@ end --- Shows contents in a floating window. --- ---@param contents table of lines to show in window ---@param syntax string of syntax to set for opened buffer ---@param opts dictionary with optional fields +---@param contents table of lines to show in window +---@param syntax string of syntax to set for opened buffer +---@param opts dictionary with optional fields --- - height of floating window --- - width of floating window --- - wrap boolean enable wrapping of long lines (defaults to true) @@ -1349,7 +1383,7 @@ end --- - focus_id if a popup with this id is opened, then focus it --- - close_events list of events that closes the floating window --- - focusable (boolean, default true): Make float focusable ---@returns bufnr,winnr buffer and window number of the newly created floating +---@returns bufnr,winnr buffer and window number of the newly created floating ---preview window function M.open_floating_preview(contents, syntax, opts) validate { @@ -1445,7 +1479,7 @@ do --[[ References ]] --- Removes document highlights from a buffer. --- - --@param bufnr buffer id + ---@param bufnr buffer id function M.buf_clear_references(bufnr) validate { bufnr = {bufnr, 'n', true} } api.nvim_buf_clear_namespace(bufnr, reference_ns, 0, -1) @@ -1453,8 +1487,9 @@ do --[[ References ]] --- Shows a list of document highlights for a certain buffer. --- - --@param bufnr buffer id - --@param references List of `DocumentHighlight` objects to highlight + ---@param bufnr buffer id + ---@param references List of `DocumentHighlight` objects to highlight + ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-3-17/#documentHighlight function M.buf_highlight_references(bufnr, references) validate { bufnr = {bufnr, 'n', true} } for _, reference in ipairs(references) do @@ -1475,24 +1510,24 @@ local position_sort = sort_by_key(function(v) return {v.start.line, v.start.character} end) --- Gets the zero-indexed line from the given uri. +--- Gets the zero-indexed line from the given uri. +---@param uri string uri of the resource to get the line from +---@param row number zero-indexed line number +---@return string the line at row in filename -- For non-file uris, we load the buffer and get the line. -- If a loaded buffer exists, then that is used. -- Otherwise we get the line using libuv which is a lot faster than loading the buffer. ---@param uri string uri of the resource to get the line from ---@param row number zero-indexed line number ---@return string the line at row in filename function M.get_line(uri, row) return M.get_lines(uri, { row })[row] end --- Gets the zero-indexed lines from the given uri. +--- Gets the zero-indexed lines from the given uri. +---@param uri string uri of the resource to get the lines from +---@param rows number[] zero-indexed line numbers +---@return table<number string> a table mapping rows to lines -- For non-file uris, we load the buffer and get the lines. -- If a loaded buffer exists, then that is used. -- Otherwise we get the lines using libuv which is a lot faster than loading the buffer. ---@param uri string uri of the resource to get the lines from ---@param rows number[] zero-indexed line numbers ---@return table<number string> a table mapping rows to lines function M.get_lines(uri, rows) rows = type(rows) == "table" and rows or { rows } @@ -1560,8 +1595,11 @@ end --- Returns the items with the byte position calculated correctly and in sorted --- order, for display in quickfix and location lists. --- ---@param locations (table) list of `Location`s or `LocationLink`s ---@returns (table) list of items +--- The result can be passed to the {list} argument of |setqflist()| or +--- |setloclist()|. +--- +---@param locations (table) list of `Location`s or `LocationLink`s +---@returns (table) list of items function M.locations_to_items(locations) local items = {} local grouped = setmetatable({}, { @@ -1618,7 +1656,9 @@ end --- Can be obtained with e.g. |vim.lsp.util.locations_to_items()|. --- Defaults to current window. --- ---@param items (table) list of items +---@deprecated Use |setloclist()| +--- +---@param items (table) list of items function M.set_loclist(items, win_id) vim.fn.setloclist(win_id or 0, {}, ' ', { title = 'Language Server'; @@ -1629,7 +1669,9 @@ end --- Fills quickfix list with given list of items. --- Can be obtained with e.g. |vim.lsp.util.locations_to_items()|. --- ---@param items (table) list of items +---@deprecated Use |setqflist()| +--- +---@param items (table) list of items function M.set_qflist(items) vim.fn.setqflist({}, ' ', { title = 'Language Server'; @@ -1646,9 +1688,9 @@ end --- Converts symbols to quickfix list items. --- ---@param symbols DocumentSymbol[] or SymbolInformation[] +---@param symbols DocumentSymbol[] or SymbolInformation[] function M.symbols_to_items(symbols, bufnr) - --@private + ---@private local function _symbols_to_items(_symbols, _items, _bufnr) for _, symbol in ipairs(_symbols) do if symbol.location then -- SymbolInformation type @@ -1684,19 +1726,19 @@ function M.symbols_to_items(symbols, bufnr) end --- Removes empty lines from the beginning and end. ---@param lines (table) list of lines to trim ---@returns (table) trimmed list of lines +---@param lines (table) list of lines to trim +---@returns (table) trimmed list of lines function M.trim_empty_lines(lines) local start = 1 for i = 1, #lines do - if #lines[i] > 0 then + if lines[i] ~= nil and #lines[i] > 0 then start = i break end end local finish = 1 for i = #lines, 1, -1 do - if #lines[i] > 0 then + if lines[i] ~= nil and #lines[i] > 0 then finish = i break end @@ -1709,8 +1751,8 @@ end --- --- CAUTION: Modifies the input in-place! --- ---@param lines (table) list of lines ---@returns (string) filetype or 'markdown' if it was unchanged. +---@param lines (table) list of lines +---@returns (string) 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 @@ -1733,7 +1775,7 @@ function M.try_trim_markdown_code_blocks(lines) end local str_utfindex = vim.str_utfindex ---@private +---@private local function make_position_param() local row, col = unpack(api.nvim_win_get_cursor(0)) row = row - 1 @@ -1747,8 +1789,8 @@ end --- Creates a `TextDocumentPositionParams` object for the current buffer and cursor position. --- ---@returns `TextDocumentPositionParams` object ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocumentPositionParams +---@returns `TextDocumentPositionParams` object +---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocumentPositionParams function M.make_position_params() return { textDocument = M.make_text_document_params(); @@ -1761,7 +1803,7 @@ end --- `textDocument/codeAction`, `textDocument/colorPresentation`, --- `textDocument/rangeFormatting`. --- ---@returns { textDocument = { uri = `current_file_uri` }, range = { start = +---@returns { textDocument = { uri = `current_file_uri` }, range = { start = ---`current_position`, end = `current_position` } } function M.make_range_params() local position = make_position_param() @@ -1774,11 +1816,11 @@ 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 ({number, number}, optional) mark-indexed position. +---@param start_pos ({number, number}, optional) mark-indexed position. ---Defaults to the start of the last visual selection. ---@param end_pos ({number, number}, optional) mark-indexed position. +---@param end_pos ({number, number}, optional) mark-indexed position. ---Defaults to the end of the last visual selection. ---@returns { textDocument = { uri = `current_file_uri` }, range = { start = +---@returns { textDocument = { uri = `current_file_uri` }, range = { start = ---`start_position`, end = `end_position` } } function M.make_given_range_params(start_pos, end_pos) validate { @@ -1814,23 +1856,23 @@ end --- Creates a `TextDocumentIdentifier` object for the current buffer. --- ---@returns `TextDocumentIdentifier` ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocumentIdentifier +---@returns `TextDocumentIdentifier` +---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocumentIdentifier function M.make_text_document_params() return { uri = vim.uri_from_bufnr(0) } end --- Create the workspace params ---@param added ---@param removed +---@param added +---@param removed function M.make_workspace_params(added, removed) return { event = { added = added; removed = removed; } } end --- Returns visual width of tabstop. --- ---@see |softtabstop| ---@param bufnr (optional, number): Buffer handle, defaults to current ---@returns (number) tabstop visual width +---@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 @@ -1838,11 +1880,11 @@ function M.get_effective_tabstop(bufnr) return (sts > 0 and sts) or (sts < 0 and bo.shiftwidth) or bo.tabstop end ---- Creates a `FormattingOptions` object for the current buffer and cursor position. +--- Creates a `DocumentFormattingParams` object for the current buffer and cursor position. --- ---@param options Table with valid `FormattingOptions` entries ---@returns `FormattingOptions object ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_formatting +---@param options Table with valid `FormattingOptions` entries +---@returns `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} } options = vim.tbl_extend('keep', options or {}, { @@ -1857,10 +1899,10 @@ end --- Returns the UTF-32 and UTF-16 offsets for a position in a certain buffer. --- ---@param buf buffer id (0 for current) ---@param row 0-indexed line ---@param col 0-indexed byte offset in line ---@returns (number, number) UTF-32 and UTF-16 index of the character in line {row} column {col} in buffer {buf} +---@param buf buffer id (0 for current) +---@param row 0-indexed line +---@param col 0-indexed byte offset in line +---@returns (number, number) UTF-32 and UTF-16 index of the character in line {row} column {col} in buffer {buf} function M.character_offset(bufnr, row, col) local uri = vim.uri_from_bufnr(bufnr) local line = M.get_line(uri, row) @@ -1873,9 +1915,9 @@ end --- Helper function to return nested values in language server settings --- ---@param settings a table of language server settings ---@param section a string indicating the field of the settings table ---@returns (table or string) The value of settings accessed via section +---@param settings a table of language server settings +---@param section a string indicating the field of the settings table +---@returns (table or string) The value of settings accessed via section function M.lookup_section(settings, section) for part in vim.gsplit(section, '.', true) do settings = settings[part] @@ -1886,40 +1928,6 @@ function M.lookup_section(settings, section) return settings end - ---- Convert diagnostics grouped by bufnr to a list of items for use in the ---- quickfix or location list. ---- ---@param diagnostics_by_bufnr table bufnr -> Diagnostic[] ---@param predicate an optional function to filter the diagnostics. --- If present, only diagnostic items matching will be included. ---@return table (A list of items) -function M.diagnostics_to_items(diagnostics_by_bufnr, predicate) - local items = {} - for bufnr, diagnostics in pairs(diagnostics_by_bufnr or {}) do - for _, d in pairs(diagnostics) do - if not predicate or predicate(d) then - table.insert(items, { - bufnr = bufnr, - lnum = d.range.start.line + 1, - col = d.range.start.character + 1, - text = d.message, - type = loclist_type_map[d.severity or DiagnosticSeverity.Error] or 'E' - }) - end - end - end - table.sort(items, function(a, b) - if a.bufnr == b.bufnr then - return a.lnum < b.lnum - else - return a.bufnr < b.bufnr - end - end) - return items -end - - M._get_line_byte_from_position = get_line_byte_from_position M._warn_once = warn_once diff --git a/runtime/lua/vim/shared.lua b/runtime/lua/vim/shared.lua index 0a663628a5..18c1e21049 100644 --- a/runtime/lua/vim/shared.lua +++ b/runtime/lua/vim/shared.lua @@ -12,8 +12,8 @@ local vim = vim or {} --- same functions as those in the input table. Userdata and threads are not --- copied and will throw an error. --- ---@param orig Table to copy ---@returns New table of copied keys and (nested) values. +---@param orig Table to copy +---@returns New table of copied keys and (nested) values. function vim.deepcopy(orig) end -- luacheck: no unused vim.deepcopy = (function() local function _id(v) @@ -52,14 +52,14 @@ end)() --- Splits a string at each instance of a separator. --- ---@see |vim.split()| ---@see https://www.lua.org/pil/20.2.html ---@see http://lua-users.org/wiki/StringLibraryTutorial +---@see |vim.split()| +---@see https://www.lua.org/pil/20.2.html +---@see http://lua-users.org/wiki/StringLibraryTutorial --- ---@param s String to split ---@param sep Separator string or pattern ---@param plain If `true` use `sep` literally (passed to String.find) ---@returns Iterator over the split components +---@param s String to split +---@param sep Separator string or pattern +---@param plain If `true` use `sep` literally (passed to String.find) +---@returns Iterator over the split components function vim.gsplit(s, sep, plain) vim.validate{s={s,'s'},sep={sep,'s'},plain={plain,'b',true}} @@ -101,12 +101,12 @@ end --- split(x*yz*o, "*", true) --> {'x','yz','o'} --- </pre> -- ---@see |vim.gsplit()| +---@see |vim.gsplit()| --- ---@param s String to split ---@param sep Separator string or pattern ---@param plain If `true` use `sep` literally (passed to String.find) ---@returns List-like table of the split components. +---@param s String to split +---@param sep Separator string or pattern +---@param plain If `true` use `sep` literally (passed to String.find) +---@returns List-like table of the split components. function vim.split(s,sep,plain) local t={} for c in vim.gsplit(s, sep, plain) do table.insert(t,c) end return t @@ -115,10 +115,10 @@ end --- Return a list of all keys used in a table. --- However, the order of the return table of keys is not guaranteed. --- ---@see From https://github.com/premake/premake-core/blob/master/src/base/table.lua +---@see From https://github.com/premake/premake-core/blob/master/src/base/table.lua --- ---@param t Table ---@returns list of keys +---@param t Table +---@returns list of keys function vim.tbl_keys(t) assert(type(t) == 'table', string.format("Expected table, got %s", type(t))) @@ -132,8 +132,8 @@ end --- Return a list of all values used in a table. --- However, the order of the return table of values is not guaranteed. --- ---@param t Table ---@returns list of values +---@param t Table +---@returns list of values function vim.tbl_values(t) assert(type(t) == 'table', string.format("Expected table, got %s", type(t))) @@ -146,8 +146,8 @@ end --- Apply a function to all values of a table. --- ---@param func function or callable table ---@param t table +---@param func function or callable table +---@param t table function vim.tbl_map(func, t) vim.validate{func={func,'c'},t={t,'t'}} @@ -160,8 +160,8 @@ end --- Filter a table using a predicate function --- ---@param func function or callable table ---@param t table +---@param func function or callable table +---@param t table function vim.tbl_filter(func, t) vim.validate{func={func,'c'},t={t,'t'}} @@ -176,9 +176,9 @@ end --- Checks if a list-like (vector) table contains `value`. --- ---@param t Table to check ---@param value Value to compare ---@returns true if `t` contains `value` +---@param t Table to check +---@param value Value to compare +---@returns true if `t` contains `value` function vim.tbl_contains(t, value) vim.validate{t={t,'t'}} @@ -192,14 +192,20 @@ end --- Checks if a table is empty. --- ---@see https://github.com/premake/premake-core/blob/master/src/base/table.lua +---@see https://github.com/premake/premake-core/blob/master/src/base/table.lua --- ---@param t Table to check +---@param t Table to check function vim.tbl_isempty(t) assert(type(t) == 'table', string.format("Expected table, got %s", type(t))) return next(t) == nil end +--- we only merge empty tables or tables that are not a list +---@private +local function can_merge(v) + return type(v) == "table" and (vim.tbl_isempty(v) or not vim.tbl_islist(v)) +end + local function tbl_extend(behavior, deep_extend, ...) if (behavior ~= 'error' and behavior ~= 'keep' and behavior ~= 'force') then error('invalid "behavior": '..tostring(behavior)) @@ -219,8 +225,8 @@ local function tbl_extend(behavior, deep_extend, ...) vim.validate{["after the second argument"] = {tbl,'t'}} if tbl then for k, v in pairs(tbl) do - if type(v) == 'table' and deep_extend and not vim.tbl_islist(v) then - ret[k] = tbl_extend(behavior, true, ret[k] or vim.empty_dict(), v) + if deep_extend and can_merge(v) and can_merge(ret[k]) then + ret[k] = tbl_extend(behavior, true, ret[k], v) elseif behavior ~= 'force' and ret[k] ~= nil then if behavior == 'error' then error('key found in more than one map: '..k) @@ -236,43 +242,48 @@ end --- Merges two or more map-like tables. --- ---@see |extend()| +---@see |extend()| --- ---@param behavior Decides what to do if a key is found in more than one map: +---@param behavior Decides what to do if a key is found in more than one map: --- - "error": raise an error --- - "keep": use value from the leftmost map --- - "force": use value from the rightmost map ---@param ... Two or more map-like tables. +---@param ... Two or more map-like tables. function vim.tbl_extend(behavior, ...) return tbl_extend(behavior, false, ...) end --- Merges recursively two or more map-like tables. --- ---@see |tbl_extend()| +---@see |tbl_extend()| --- ---@param behavior Decides what to do if a key is found in more than one map: +---@param behavior Decides what to do if a key is found in more than one map: --- - "error": raise an error --- - "keep": use value from the leftmost map --- - "force": use value from the rightmost map ---@param ... Two or more map-like tables. +---@param ... Two or more map-like tables. function vim.tbl_deep_extend(behavior, ...) return tbl_extend(behavior, true, ...) end --- Deep compare values for equality +--- +--- Tables are compared recursively unless they both provide the `eq` methamethod. +--- All other types are compared using the equality `==` operator. +---@param a first value +---@param b second value +---@returns `true` if values are equals, else `false`. function vim.deep_equal(a, b) if a == b then return true end if type(a) ~= type(b) then return false end if type(a) == 'table' then - -- TODO improve this algorithm's performance. for k, v in pairs(a) do if not vim.deep_equal(v, b[k]) then return false end end - for k, v in pairs(b) do - if not vim.deep_equal(v, a[k]) then + for k, _ in pairs(b) do + if a[k] == nil then return false end end @@ -286,7 +297,7 @@ end --- `tbl_add_reverse_lookup { A = 1 } == { [1] = 'A', A = 1 }` -- --Do note that it *modifies* the input. ---@param o table The table to add the reverse to. +---@param o table The table to add the reverse to. function vim.tbl_add_reverse_lookup(o) local keys = vim.tbl_keys(o) for _, k in ipairs(keys) do @@ -303,13 +314,13 @@ end --- --- NOTE: This mutates dst! --- ---@see |vim.tbl_extend()| +---@see |vim.tbl_extend()| --- ---@param dst list which will be modified and appended to. ---@param src list from which values will be inserted. ---@param start Start index on src. defaults to 1 ---@param finish Final index on src. defaults to #src ---@returns dst +---@param dst list which will be modified and appended to. +---@param src list from which values will be inserted. +---@param start Start index on src. defaults to 1 +---@param finish Final index on src. defaults to #src +---@returns dst function vim.list_extend(dst, src, start, finish) vim.validate { dst = {dst, 't'}; @@ -326,10 +337,10 @@ end --- Creates a copy of a list-like table such that any nested tables are --- "unrolled" and appended to the result. --- ---@see From https://github.com/premake/premake-core/blob/master/src/base/table.lua +---@see From https://github.com/premake/premake-core/blob/master/src/base/table.lua --- ---@param t List-like table ---@returns Flattened copy of the given list-like table. +---@param t List-like table +---@returns Flattened copy of the given list-like table. function vim.tbl_flatten(t) local result = {} local function _tbl_flatten(_t) @@ -353,8 +364,8 @@ end --- |vim.empty_dict()| or returned as a dict-like |API| or Vimscript result, --- for example from |rpcrequest()| or |vim.fn|. --- ---@param t Table ---@returns `true` if array-like table, else `false`. +---@param t Table +---@returns `true` if array-like table, else `false`. function vim.tbl_islist(t) if type(t) ~= 'table' then return false @@ -389,9 +400,9 @@ end --- vim.tbl_count({ 1, 2 }) => 2 --- </pre> --- ---@see https://github.com/Tieske/Penlight/blob/master/lua/pl/tablex.lua ---@param t Table ---@returns Number that is the number of the value in table +---@see https://github.com/Tieske/Penlight/blob/master/lua/pl/tablex.lua +---@param t Table +---@returns Number that is the number of the value in table function vim.tbl_count(t) vim.validate{t={t,'t'}} @@ -402,10 +413,10 @@ end --- Creates a copy of a table containing only elements from start to end (inclusive) --- ---@param list table table ---@param start integer Start range of slice ---@param finish integer End range of slice ---@returns Copy of table sliced from start to finish (inclusive) +---@param list table table +---@param start integer Start range of slice +---@param finish integer End range of slice +---@returns Copy of table sliced from start to finish (inclusive) function vim.list_slice(list, start, finish) local new_list = {} for i = start or 1, finish or #list do @@ -416,9 +427,9 @@ end --- Trim whitespace (Lua pattern "%s") from both sides of a string. --- ---@see https://www.lua.org/pil/20.2.html ---@param s String to trim ---@returns String with whitespace removed from its beginning and end +---@see https://www.lua.org/pil/20.2.html +---@param s String to trim +---@returns String with whitespace removed from its beginning and end function vim.trim(s) vim.validate{s={s,'s'}} return s:match('^%s*(.*%S)') or '' @@ -426,9 +437,9 @@ end --- Escapes magic chars in a Lua pattern. --- ---@see https://github.com/rxi/lume ---@param s String to escape ---@returns %-escaped pattern string +---@see https://github.com/rxi/lume +---@param s String to escape +---@returns %-escaped pattern string function vim.pesc(s) vim.validate{s={s,'s'}} return s:gsub('[%(%)%.%%%+%-%*%?%[%]%^%$]', '%%%1') @@ -436,9 +447,9 @@ end --- Tests if `s` starts with `prefix`. --- ---@param s (string) a string ---@param prefix (string) a prefix ---@return (boolean) true if `prefix` is a prefix of s +---@param s (string) a string +---@param prefix (string) a prefix +---@return (boolean) true if `prefix` is a prefix of s function vim.startswith(s, prefix) vim.validate { s = {s, 's'}; prefix = {prefix, 's'}; } return s:sub(1, #prefix) == prefix @@ -446,9 +457,9 @@ end --- Tests if `s` ends with `suffix`. --- ---@param s (string) a string ---@param suffix (string) a suffix ---@return (boolean) true if `suffix` is a suffix of s +---@param s (string) a string +---@param suffix (string) a suffix +---@return (boolean) true if `suffix` is a suffix of s function vim.endswith(s, suffix) vim.validate { s = {s, 's'}; suffix = {suffix, 's'}; } return #suffix == 0 or s:sub(-#suffix) == suffix @@ -480,7 +491,7 @@ end --- => error('arg1: expected even number, got 3') --- </pre> --- ---@param opt Map of parameter names to validations. Each key is a parameter +---@param opt Map of parameter names to validations. Each key is a parameter --- name; each value is a tuple in one of these forms: --- 1. (arg_value, type_name, optional) --- - arg_value: argument value @@ -564,8 +575,8 @@ do end --- Returns true if object `f` can be called as a function. --- ---@param f Any object ---@return true if `f` is callable, else false +---@param f Any object +---@return true if `f` is callable, else false function vim.is_callable(f) if type(f) == 'function' then return true end local m = getmetatable(f) diff --git a/runtime/lua/vim/treesitter.lua b/runtime/lua/vim/treesitter.lua index de997b2d86..66999c5f7f 100644 --- a/runtime/lua/vim/treesitter.lua +++ b/runtime/lua/vim/treesitter.lua @@ -20,6 +20,9 @@ setmetatable(M, { elseif k == "language" then t[k] = require"vim.treesitter.language" return t[k] + elseif k == "query" then + t[k] = require"vim.treesitter.query" + return t[k] end end }) @@ -28,9 +31,9 @@ setmetatable(M, { --- --- It is not recommended to use this, use vim.treesitter.get_parser() instead. --- ---- @param bufnr The buffer the parser will be tied to ---- @param lang The language of the parser ---- @param opts Options to pass to the created language tree +---@param bufnr The buffer the parser will be tied to +---@param lang The language of the parser +---@param opts Options to pass to the created language tree function M._create_parser(bufnr, lang, opts) language.require_language(lang) if bufnr == 0 then @@ -71,11 +74,11 @@ end --- If needed this will create the parser. --- Unconditionnally attach the provided callback --- ---- @param bufnr The buffer the parser should be tied to ---- @param lang The filetype of this parser ---- @param opts Options object to pass to the created language tree +---@param bufnr The buffer the parser should be tied to +---@param lang The filetype of this parser +---@param opts Options object to pass to the created language tree --- ---- @returns The parser +---@returns The parser function M.get_parser(bufnr, lang, opts) opts = opts or {} @@ -97,9 +100,9 @@ end --- Gets a string parser --- ---- @param str The string to parse ---- @param lang The language of this string ---- @param opts Options to pass to the created language tree +---@param str The string to parse +---@param lang The language of this string +---@param opts Options to pass to the created language tree function M.get_string_parser(str, lang, opts) vim.validate { str = { str, 'string' }, diff --git a/runtime/lua/vim/treesitter/highlighter.lua b/runtime/lua/vim/treesitter/highlighter.lua index 84b6a5f135..22b528838c 100644 --- a/runtime/lua/vim/treesitter/highlighter.lua +++ b/runtime/lua/vim/treesitter/highlighter.lua @@ -22,8 +22,6 @@ local _link_default_highlight_once = function(from, to) return from end --- These are conventions defined by nvim-treesitter, though it --- needs to be user extensible also. TSHighlighter.hl_map = { ["error"] = "Error", @@ -87,8 +85,10 @@ function TSHighlighterQuery.new(lang, query_string) hl = _link_default_highlight_once(lang .. hl, hl) end - rawset(table, capture, hl) - return hl + local id = a.nvim_get_hl_id_by_name(hl) + + rawset(table, capture, id) + return id end }) @@ -116,14 +116,14 @@ function TSHighlighterQuery:_get_hl_from_capture(capture) -- From "Normal.left" only keep "Normal" return vim.split(name, '.', true)[1], true else - return TSHighlighter.hl_map[name] or name, false + return TSHighlighter.hl_map[name] or 0, false end end --- Creates a new highlighter using @param tree --- ---- @param tree The language tree to use for highlighting ---- @param opts Table used to configure the highlighter +---@param tree The language tree to use for highlighting +---@param opts Table used to configure the highlighter --- - queries: Table to overwrite queries used by the highlighter function TSHighlighter.new(tree, opts) local self = setmetatable({}, TSHighlighter) @@ -217,7 +217,7 @@ end --- Gets the query used for @param lang --- ---- @param lang A language used by the highlighter. +---@param lang A language used by the highlighter. function TSHighlighter:get_query(lang) if not self._queries[lang] then self._queries[lang] = TSHighlighterQuery.new(lang) @@ -248,7 +248,7 @@ local function on_line_impl(self, buf, line) end while line >= state.next_row do - local capture, node = state.iter() + local capture, node, metadata = state.iter() if capture == nil then break end @@ -260,7 +260,7 @@ local function on_line_impl(self, buf, line) { end_line = end_row, end_col = end_col, hl_group = hl, ephemeral = true, - priority = 100 -- Low but leaves room below + priority = tonumber(metadata.priority) or 100 -- Low but leaves room below }) end if start_row > line then diff --git a/runtime/lua/vim/treesitter/language.lua b/runtime/lua/vim/treesitter/language.lua index 6dc37c7848..89ddd6cd5a 100644 --- a/runtime/lua/vim/treesitter/language.lua +++ b/runtime/lua/vim/treesitter/language.lua @@ -6,9 +6,9 @@ local M = {} --- --- Parsers are searched in the `parser` runtime directory. --- ---- @param lang The language the parser should parse ---- @param path Optional path the parser is located at ---- @param silent Don't throw an error if language not found +---@param lang The language the parser should parse +---@param path Optional path the parser is located at +---@param silent Don't throw an error if language not found function M.require_language(lang, path, silent) if vim._ts_has_language(lang) then return true @@ -40,7 +40,7 @@ end --- --- Inspecting provides some useful informations on the language like node names, ... --- ---- @param lang The language. +---@param lang The language. function M.inspect_language(lang) M.require_language(lang) return vim._ts_inspect_language(lang) diff --git a/runtime/lua/vim/treesitter/languagetree.lua b/runtime/lua/vim/treesitter/languagetree.lua index 899d90e464..7e392f72a4 100644 --- a/runtime/lua/vim/treesitter/languagetree.lua +++ b/runtime/lua/vim/treesitter/languagetree.lua @@ -9,12 +9,12 @@ LanguageTree.__index = LanguageTree --- The language can contain child languages with in its range, --- hence the tree. --- ---- @param source Can be a bufnr or a string of text to parse ---- @param lang The language this tree represents ---- @param opts Options table ---- @param opts.injections A table of language to injection query strings. ---- This is useful for overriding the built-in runtime file ---- searching for the injection language query per language. +---@param source Can be a bufnr or a string of text to parse +---@param lang The language this tree represents +---@param opts Options table +---@param opts.injections A table of language to injection query strings. +--- This is useful for overriding the built-in runtime file +--- searching for the injection language query per language. function LanguageTree.new(source, lang, opts) language.require_language(lang) opts = opts or {} @@ -171,8 +171,8 @@ end --- Invokes the callback for each LanguageTree and it's children recursively --- ---- @param fn The function to invoke. This is invoked with arguments (tree: LanguageTree, lang: string) ---- @param include_self Whether to include the invoking tree in the results. +---@param fn The function to invoke. This is invoked with arguments (tree: LanguageTree, lang: string) +---@param include_self Whether to include the invoking tree in the results. function LanguageTree:for_each_child(fn, include_self) if include_self then fn(self, self._lang) @@ -187,8 +187,8 @@ end --- --- Note, this includes the invoking language tree's trees as well. --- ---- @param fn The callback to invoke. The callback is invoked with arguments ---- (tree: TSTree, languageTree: LanguageTree) +---@param fn The callback to invoke. The callback is invoked with arguments +--- (tree: TSTree, languageTree: LanguageTree) function LanguageTree:for_each_tree(fn) for _, tree in ipairs(self._trees) do fn(tree, self) @@ -203,7 +203,7 @@ end --- --- If the language already exists as a child, it will first be removed. --- ---- @param lang The language to add. +---@param lang The language to add. function LanguageTree:add_child(lang) if self._children[lang] then self:remove_child(lang) @@ -219,7 +219,7 @@ end --- Removes a child language from this tree. --- ---- @param lang The language to remove. +---@param lang The language to remove. function LanguageTree:remove_child(lang) local child = self._children[lang] @@ -259,7 +259,7 @@ end --- --- Note, this call invalidates the tree and requires it to be parsed again. --- ---- @param regions A list of regions this tree should manage and parse. +---@param regions A list of regions this tree should manage and parse. function LanguageTree:set_included_regions(regions) -- TODO(vigoux): I don't think string parsers are useful for now if type(self._source) == "number" then @@ -299,7 +299,7 @@ end --- --- TODO: Allow for an offset predicate to tailor the injection range --- instead of using the entire nodes range. ---- @private +---@private function LanguageTree:_get_injections() if not self._injection_query then return {} end @@ -449,7 +449,7 @@ function LanguageTree:_on_detach(...) end --- Registers callbacks for the parser ---- @param cbs An `nvim_buf_attach`-like table argument with the following keys : +---@param cbs An `nvim_buf_attach`-like table argument with the following keys : --- `on_bytes` : see `nvim_buf_attach`, but this will be called _after_ the parsers callback. --- `on_changedtree` : a callback that will be called every time the tree has syntactical changes. --- it will only be passed one argument, that is a table of the ranges (as node ranges) that @@ -497,7 +497,7 @@ end --- --- This goes down the tree to recursively check childs. --- ---- @param range A range, that is a `{ start_line, start_col, end_line, end_col }` table. +---@param range A range, that is a `{ start_line, start_col, end_line, end_col }` table. function LanguageTree:contains(range) for _, tree in pairs(self._trees) do if tree_contains(tree, range) then @@ -510,7 +510,7 @@ end --- Gets the appropriate language that contains @param range --- ---- @param range A text range, see |LanguageTree:contains| +---@param range A text range, see |LanguageTree:contains| function LanguageTree:language_for_range(range) for _, child in pairs(self._children) do if child:contains(range) then diff --git a/runtime/lua/vim/treesitter/query.lua b/runtime/lua/vim/treesitter/query.lua index b81eb18945..66da179ea3 100644 --- a/runtime/lua/vim/treesitter/query.lua +++ b/runtime/lua/vim/treesitter/query.lua @@ -36,9 +36,9 @@ end --- Gets the list of files used to make up a query --- ---- @param lang The language ---- @param query_name The name of the query to load ---- @param is_included Internal parameter, most of the time left as `nil` +---@param lang The language +---@param query_name The name of the query to load +---@param is_included Internal parameter, most of the time left as `nil` function M.get_query_files(lang, query_name, is_included) local query_path = string.format('queries/%s/%s.scm', lang, query_name) local lang_files = dedupe_files(a.nvim_get_runtime_file(query_path, true)) @@ -112,19 +112,19 @@ local explicit_queries = setmetatable({}, { --- This allows users to override any runtime files and/or configuration --- set by plugins. --- ---- @param lang string: The language to use for the query ---- @param query_name string: The name of the query (i.e. "highlights") ---- @param text string: The query text (unparsed). +---@param lang string: The language to use for the query +---@param query_name string: The name of the query (i.e. "highlights") +---@param text string: The query text (unparsed). function M.set_query(lang, query_name, text) explicit_queries[lang][query_name] = M.parse_query(lang, text) end --- Returns the runtime query {query_name} for {lang}. --- ---- @param lang The language to use for the query ---- @param query_name The name of the query (i.e. "highlights") +---@param lang The language to use for the query +---@param query_name The name of the query (i.e. "highlights") --- ---- @return The corresponding query, parsed. +---@return The corresponding query, parsed. function M.get_query(lang, query_name) if explicit_queries[lang][query_name] then return explicit_queries[lang][query_name] @@ -151,10 +151,10 @@ end --- -` info.captures` also points to `captures`. --- - `info.patterns` contains information about predicates. --- ---- @param lang The language ---- @param query A string containing the query (s-expr syntax) +---@param lang The language +---@param query A string containing the query (s-expr syntax) --- ---- @returns The query +---@returns The query function M.parse_query(lang, query) language.require_language(lang) local self = setmetatable({}, Query) @@ -168,8 +168,8 @@ end --- Gets the text corresponding to a given node --- ---- @param node the node ---- @param bsource The buffer or string from which the node is extracted +---@param node the node +---@param bsource The buffer or string from which the node is extracted function M.get_node_text(node, source) local start_row, start_col, start_byte = node:start() local end_row, end_col, end_byte = node:end_() @@ -327,9 +327,9 @@ local directive_handlers = { --- Adds a new predicate to be used in queries --- ---- @param name the name of the predicate, without leading # ---- @param handler the handler function to be used ---- signature will be (match, pattern, bufnr, predicate) +---@param name the name of the predicate, without leading # +---@param handler the handler function to be used +--- signature will be (match, pattern, bufnr, predicate) function M.add_predicate(name, handler, force) if predicate_handlers[name] and not force then error(string.format("Overriding %s", name)) @@ -340,9 +340,9 @@ end --- Adds a new directive to be used in queries --- ---- @param name the name of the directive, without leading # ---- @param handler the handler function to be used ---- signature will be (match, pattern, bufnr, predicate) +---@param name the name of the directive, without leading # +---@param handler the handler function to be used +--- signature will be (match, pattern, bufnr, predicate) function M.add_directive(name, handler, force) if directive_handlers[name] and not force then error(string.format("Overriding %s", name)) @@ -351,7 +351,12 @@ function M.add_directive(name, handler, force) directive_handlers[name] = handler end ---- Returns the list of currently supported predicates +---@return The list of supported directives. +function M.list_directives() + return vim.tbl_keys(directive_handlers) +end + +---@return The list of supported predicates. function M.list_predicates() return vim.tbl_keys(predicate_handlers) end @@ -460,13 +465,13 @@ end --- end --- </pre> --- ---- @param node The node under which the search will occur ---- @param source The source buffer or string to exctract text from ---- @param start The starting line of the search ---- @param stop The stopping line of the search (end-exclusive) +---@param node The node under which the search will occur +---@param source The source buffer or string to exctract text from +---@param start The starting line of the search +---@param stop The stopping line of the search (end-exclusive) --- ---- @returns The matching capture id ---- @returns The captured node +---@returns The matching capture id +---@returns The captured node function Query:iter_captures(node, source, start, stop) if type(source) == "number" and source == 0 then source = vim.api.nvim_get_current_buf() @@ -517,13 +522,13 @@ end --- end --- </pre> --- ---- @param node The node under which the search will occur ---- @param source The source buffer or string to search ---- @param start The starting line of the search ---- @param stop The stopping line of the search (end-exclusive) +---@param node The node under which the search will occur +---@param source The source buffer or string to search +---@param start The starting line of the search +---@param stop The stopping line of the search (end-exclusive) --- ---- @returns The matching pattern id ---- @returns The matching match +---@returns The matching pattern id +---@returns The matching match function Query:iter_matches(node, source, start, stop) if type(source) == "number" and source == 0 then source = vim.api.nvim_get_current_buf() diff --git a/runtime/lua/vim/ui.lua b/runtime/lua/vim/ui.lua new file mode 100644 index 0000000000..5eab20fc54 --- /dev/null +++ b/runtime/lua/vim/ui.lua @@ -0,0 +1,36 @@ +local M = {} + +--- Prompts the user to pick a single item from a collection of entries +--- +---@param items table Arbitrary items +---@param opts table Additional options +--- - prompt (string|nil) +--- Text of the prompt. Defaults to `Select one of:` +--- - format_item (function item -> text) +--- Function to format an +--- individual item from `items`. Defaults to `tostring`. +---@param on_choice function ((item|nil, idx|nil) -> ()) +--- Called once the user made a choice. +--- `idx` is the 1-based index of `item` within `item`. +--- `nil` if the user aborted the dialog. +function M.select(items, opts, on_choice) + vim.validate { + items = { items, 'table', false }, + on_choice = { on_choice, 'function', false }, + } + opts = opts or {} + local choices = {opts.prompt or 'Select one of:'} + local format_item = opts.format_item or tostring + for i, item in pairs(items) do + table.insert(choices, string.format('%d: %s', i, format_item(item))) + end + local choice = vim.fn.inputlist(choices) + if choice < 1 or choice > #items then + on_choice(nil, nil) + else + on_choice(items[choice], choice) + end +end + + +return M diff --git a/runtime/lua/vim/uri.lua b/runtime/lua/vim/uri.lua index f1a12c72ec..a3e79a0f2b 100644 --- a/runtime/lua/vim/uri.lua +++ b/runtime/lua/vim/uri.lua @@ -9,7 +9,7 @@ do local schar = string.char --- Convert hex to char - --@private + ---@private local function hex_to_char(hex) return schar(tonumber(hex, 16)) end @@ -38,7 +38,7 @@ do tohex = function(b) return string.format("%02x", b) end end - --@private + ---@private local function percent_encode_char(char) return "%"..tohex(sbyte(char), 2) end @@ -50,14 +50,14 @@ do end ---@private +---@private local function is_windows_file_uri(uri) - return uri:match('^file:///[a-zA-Z]:') ~= nil + return uri:match('^file:/+[a-zA-Z]:') ~= nil end --- Get a URI from a file path. ---@param path (string): Path to file ---@return URI +---@param path (string): Path to file +---@return URI local function uri_from_fname(path) local volume_path, fname = path:match("^([a-zA-Z]:)(.*)") local is_windows = volume_path ~= nil @@ -74,11 +74,11 @@ local function uri_from_fname(path) return table.concat(uri_parts) end -local URI_SCHEME_PATTERN = '^([a-zA-Z]+[a-zA-Z0-9+-.]*)://.*' +local URI_SCHEME_PATTERN = '^([a-zA-Z]+[a-zA-Z0-9+-.]*):.*' --- Get a URI from a bufnr ---@param bufnr (number): Buffer number ---@return URI +---@param bufnr (number): Buffer number +---@return URI local function uri_from_bufnr(bufnr) local fname = vim.api.nvim_buf_get_name(bufnr) local scheme = fname:match(URI_SCHEME_PATTERN) @@ -90,8 +90,8 @@ local function uri_from_bufnr(bufnr) end --- Get a filename from a URI ---@param uri (string): The URI ---@return Filename +---@param uri (string): The URI +---@return Filename local function uri_to_fname(uri) local scheme = assert(uri:match(URI_SCHEME_PATTERN), 'URI must contain a scheme: ' .. uri) if scheme ~= 'file' then @@ -100,18 +100,18 @@ local function uri_to_fname(uri) uri = uri_decode(uri) -- TODO improve this. if is_windows_file_uri(uri) then - uri = uri:gsub('^file:///', '') + uri = uri:gsub('^file:/+', '') uri = uri:gsub('/', '\\') else - uri = uri:gsub('^file://', '') + uri = uri:gsub('^file:/+', '/') end return uri end --- Return or create a buffer for a uri. ---@param uri (string): The URI ---@return bufnr. ---@note Creates buffer but does not load it +---@param uri (string): The URI +---@return bufnr. +---@note Creates buffer but does not load it local function uri_to_bufnr(uri) local scheme = assert(uri:match(URI_SCHEME_PATTERN), 'URI must contain a scheme: ' .. uri) if scheme == 'file' then |