diff options
Diffstat (limited to 'runtime/lua')
-rw-r--r-- | runtime/lua/health.lua | 23 | ||||
-rw-r--r-- | runtime/lua/vim/diagnostic.lua | 543 | ||||
-rw-r--r-- | runtime/lua/vim/lsp.lua | 172 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/buf.lua | 137 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/codelens.lua | 42 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/diagnostic.lua | 47 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/handlers.lua | 35 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/health.lua | 2 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/protocol.lua | 4 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/rpc.lua | 87 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/util.lua | 22 | ||||
-rw-r--r-- | runtime/lua/vim/shared.lua | 46 | ||||
-rw-r--r-- | runtime/lua/vim/treesitter/health.lua | 2 | ||||
-rw-r--r-- | runtime/lua/vim/ui.lua | 36 | ||||
-rw-r--r-- | runtime/lua/vim/uri.lua | 11 |
15 files changed, 801 insertions, 408 deletions
diff --git a/runtime/lua/health.lua b/runtime/lua/health.lua new file mode 100644 index 0000000000..142a353bf2 --- /dev/null +++ b/runtime/lua/health.lua @@ -0,0 +1,23 @@ +local M = {} + +function M.report_start(msg) + vim.fn['health#report_start'](msg) +end + +function M.report_info(msg) + vim.fn['health#report_info'](msg) +end + +function M.report_ok(msg) + vim.fn['health#report_ok'](msg) +end + +function M.report_warn(msg, ...) + vim.fn['health#report_warn'](msg, ...) +end + +function M.report_error(msg, ...) + vim.fn['health#report_error'](msg, ...) +end + +return M diff --git a/runtime/lua/vim/diagnostic.lua b/runtime/lua/vim/diagnostic.lua index c977fe0109..4cf22282a2 100644 --- a/runtime/lua/vim/diagnostic.lua +++ b/runtime/lua/vim/diagnostic.lua @@ -19,6 +19,7 @@ local global_diagnostic_options = { signs = true, underline = true, virtual_text = true, + float = true, update_in_insert = false, severity_sort = false, } @@ -27,7 +28,10 @@ local global_diagnostic_options = { ---@private local function to_severity(severity) - return type(severity) == 'string' and M.severity[string.upper(severity)] or severity + if type(severity) == 'string' then + return assert(M.severity[string.upper(severity)], string.format("Invalid severity: %s", severity)) + end + return severity end ---@private @@ -48,25 +52,46 @@ local function filter_by_severity(severity, diagnostics) end ---@private -local function resolve_optional_value(option, namespace, bufnr) - local enabled_val = {} +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 - 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 + return vim.tbl_map(function(d) + if not d.source then + return d end - elseif type(option) == 'table' then - return option - else - error("Unexpected option type: " .. vim.inspect(option)) + + 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 local all_namespaces = {} @@ -82,9 +107,7 @@ local function get_namespace(ns) end end - if not name then - return vim.notify("namespace does not exist or is anonymous", vim.log.levels.ERROR) - end + assert(name, "namespace does not exist or is anonymous") all_namespaces[ns] = { name = name, @@ -96,12 +119,47 @@ local function get_namespace(ns) end ---@private +local function enabled_value(option, namespace) + local ns = namespace and get_namespace(namespace) or {} + if ns.opts and type(ns.opts[option]) == "table" then + return ns.opts[option] + end + + if type(global_diagnostic_options[option]) == "table" then + return global_diagnostic_options[option] + end + + return {} +end + +---@private +local function resolve_optional_value(option, value, namespace, bufnr) + if not value then + return false + elseif value == true then + return enabled_value(option, namespace) + elseif type(value) == 'function' then + local val = value(namespace, bufnr) + if val == true then + return enabled_value(option, namespace) + else + return val + end + elseif type(value) == 'table' then + return value + else + error("Unexpected option type: " .. vim.inspect(value)) + end +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) + local ns = namespace and get_namespace(namespace) or {} + -- Do not use tbl_deep_extend so that an empty table can be used to reset to default values + local resolved = vim.tbl_extend('keep', opts or {}, ns.opts or {}, 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) + resolved[k] = resolve_optional_value(k, resolved[k], namespace, bufnr) end end return resolved @@ -206,7 +264,7 @@ end ---@private local function diagnostic_lines(diagnostics) if not diagnostics then - return + return {} end local diagnostics_by_line = {} @@ -222,26 +280,14 @@ local function diagnostic_lines(diagnostics) end ---@private -local function set_diagnostic_cache(namespace, diagnostics, bufnr) - local buf_line_count = vim.api.nvim_buf_line_count(bufnr) +local function set_diagnostic_cache(namespace, bufnr, diagnostics) for _, diagnostic in ipairs(diagnostics) do - if diagnostic.severity == nil then - diagnostic.severity = M.severity.ERROR - end - + 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 - - if buf_line_count > 0 then - 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 - diagnostic_cache[bufnr][namespace] = diagnostics end @@ -310,19 +356,15 @@ local function schedule_display(namespace, bufnr, 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") - + vim.cmd(string.format([[augroup %s + au! + autocmd %s <buffer=%s> lua vim.diagnostic._execute_scheduled_display(%s, %s) + augroup END]], + key, + table.concat(insert_leave_auto_cmds, ","), + bufnr, + namespace, + bufnr)) registered_autocmds[key] = true end end @@ -332,56 +374,14 @@ 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") - + vim.cmd(string.format([[augroup %s + au! + augroup END]], key)) 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 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 - - 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) @@ -404,12 +404,28 @@ local function set_list(loclist, opts) end ---@private +--- To (slightly) improve performance, modifies diagnostics in place. +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 = bufnr or vim.api.nvim_get_current_buf() + bufnr = get_bufnr(bufnr) local wrap = vim.F.if_nil(opts.wrap, true) local line_count = vim.api.nvim_buf_line_count(bufnr) - opts.namespace = namespace + 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 @@ -419,9 +435,7 @@ local function next_diagnostic(position, search_forward, bufnr, opts, namespace) end lnum = (lnum + line_count) % line_count end - opts.lnum = lnum - local line_diagnostics = M.get(bufnr, opts) - if line_diagnostics and not vim.tbl_isempty(line_diagnostics) then + 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 @@ -430,15 +444,15 @@ local function next_diagnostic(position, search_forward, bufnr, opts, namespace) 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, sort_diagnostics) + table.sort(line_diagnostics[lnum], sort_diagnostics) if i == 0 then - for _, v in pairs(line_diagnostics) do + for _, v in pairs(line_diagnostics[lnum]) do if is_next(v) then return v end end else - return line_diagnostics[1] + return line_diagnostics[lnum][1] end end end @@ -448,7 +462,7 @@ end local function diagnostic_move_pos(opts, pos) opts = opts or {} - local enable_popup = vim.F.if_nil(opts.enable_popup, true) + local float = vim.F.if_nil(opts.float, true) local win_id = opts.win_id or vim.api.nvim_get_current_win() if not pos then @@ -456,17 +470,22 @@ local function diagnostic_move_pos(opts, pos) return end + -- Save position in the window's jumplist + vim.api.nvim_win_call(win_id, function() vim.cmd("normal! m'") 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. + if float then + local float_opts = type(float) == "table" and float or {} vim.schedule(function() - M.show_position_diagnostics(opts.popup_opts, vim.api.nvim_win_get_buf(win_id)) + M.open_float( + vim.api.nvim_win_get_buf(win_id), + vim.tbl_extend("keep", float_opts, {scope="cursor"}) + ) end) end end - -- }}} -- Public API {{{ @@ -474,10 +493,27 @@ end --- Configure diagnostic options globally or for a specific diagnostic --- namespace. --- +--- Configuration can be specified globally, per-namespace, or ephemerally +--- (i.e. only for a single call to |vim.diagnostic.set()| or +--- |vim.diagnostic.show()|). Ephemeral configuration has highest priority, +--- followed by namespace configuration, and finally global configuration. +--- +--- For example, if a user enables virtual text globally with +--- <pre> +--- vim.diagnostic.config({virtual_text = true}) +--- </pre> +--- +--- and a diagnostic producer sets diagnostics with +--- <pre> +--- vim.diagnostic.set(ns, 0, diagnostics, {virtual_text = false}) +--- </pre> +--- +--- then virtual text will not be enabled for those diagnostics. +--- ---@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. +--- - `table`: Enable this feature with overrides. Use an empty table to use default values. --- - `function`: Function with signature (namespace, bufnr) that returns any of the above. --- ---@param opts table Configuration table with the following keys: @@ -487,9 +523,32 @@ end --- - 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| +--- * priority: (number, default 10) Base priority to use for signs. When +--- {severity_sort} is used, the priority of a sign is adjusted based on +--- its severity. Otherwise, all signs use the same priority. +--- - float: Options for floating windows: +--- * 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. --- - 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 @@ -551,32 +610,36 @@ function M.set(namespace, bufnr, diagnostics, opts) } 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 - }) + clear_diagnostic_cache(namespace, bufnr) + else + 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) end - set_diagnostic_cache(namespace, diagnostics, bufnr) - 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 diagnostic namespaces. +--- +---@return table A list of active diagnostic namespaces |vim.diagnostic|. +function M.get_namespaces() + return vim.deepcopy(all_namespaces) +end + --- Get current diagnostics. --- ---@param bufnr number|nil Buffer number to get diagnostics from. Use 0 for @@ -708,10 +771,9 @@ end --- |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()| +--- - float: (boolean or table, default true) If "true", call |vim.diagnostic.open_float()| +--- after moving. If a table, pass the table as the {opts} parameter to +--- |vim.diagnostic.open_float()|. --- - win_id: (number, default 0) Window ID function M.goto_next(opts) return diagnostic_move_pos( @@ -740,16 +802,35 @@ function M._set_signs(namespace, bufnr, diagnostics, opts) } bufnr = get_bufnr(bufnr) - opts = get_resolved_options({ signs = opts }, namespace, bufnr).signs + opts = get_resolved_options({ signs = opts }, namespace, bufnr) - if opts and opts.severity then - diagnostics = filter_by_severity(opts.severity, diagnostics) + 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, @@ -757,7 +838,7 @@ function M._set_signs(namespace, bufnr, diagnostics, opts) sign_highlight_map[diagnostic.severity], bufnr, { - priority = opts and opts.priority, + priority = get_priority(diagnostic.severity), lnum = diagnostic.lnum + 1 } ) @@ -814,6 +895,8 @@ end ---@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 { @@ -826,12 +909,20 @@ function M._set_virtual_text(namespace, bufnr, diagnostics, opts) 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) + 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, { @@ -844,13 +935,11 @@ end --- Get virtual text chunks to display using |nvim_buf_set_extmark()|. --- ----@param line_diags table The diagnostics associated with the line. ----@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. ----@return array of ({text}, {hl_group}) tuples. This can be passed directly to ---- the {virt_text} option of |nvim_buf_set_extmark()|. -function M.get_virt_text_chunks(line_diags, opts) +--- 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 @@ -983,6 +1072,8 @@ function M.show(namespace, bufnr, diagnostics, opts) end end + clamp_line_numbers(bufnr, diagnostics) + if opts.underline then M._set_underline(namespace, bufnr, diagnostics, opts.underline) end @@ -998,61 +1089,136 @@ function M.show(namespace, bufnr, diagnostics, opts) save_extmarks(namespace, bufnr) end ---- Open a floating window with the diagnostics at the given position. +--- Show diagnostics in a floating window. --- +---@param bufnr number|nil Buffer number. Defaults to the current buffer. ---@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 ----@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) +--- - scope: (string, default "buffer") Show diagnostics from the whole buffer ("buffer"), +--- the current cursor line ("line"), or the current cursor position ("cursor"). +--- - pos: (number or table) If {scope} is "line" or "cursor", use this position rather +--- than the cursor position. If a number, interpreted as a line number; +--- otherwise, a (row, col) tuple. +--- - severity_sort: (default false) Sort diagnostics by severity. Overrides the setting +--- from |vim.diagnostic.config()|. +--- - severity: See |diagnostic-severity|. Overrides the setting from +--- |vim.diagnostic.config()|. +--- - show_header: (boolean, default true) Show "Diagnostics:" header. Overrides the +--- setting from |vim.diagnostic.config()|. +--- - source: (string) Include the diagnostic source in the message. One of "always" or +--- "if_many". Overrides the setting from |vim.diagnostic.config()|. +--- - 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. +--- Overrides the setting from |vim.diagnostic.config()|. +---@return tuple ({float_bufnr}, {win_id}) +function M.open_float(bufnr, opts) vim.validate { - opts = { opts, 't', true }, bufnr = { bufnr, 'n', true }, - position = { position, 't', true }, + opts = { opts, '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 position_diagnostics = vim.tbl_filter(match_position_predicate, M.get(bufnr, opts)) - table.sort(position_diagnostics, function(a, b) return a.severity < b.severity end) - return show_diagnostics(opts, position_diagnostics) -end + local scope = opts.scope or "buffer" + local lnum, col + if scope == "line" or scope == "cursor" then + if not opts.pos then + local pos = vim.api.nvim_win_get_cursor(0) + lnum = pos[1] - 1 + col = pos[2] + elseif type(opts.pos) == "number" then + lnum = opts.pos + elseif type(opts.pos) == "table" then + lnum, col = unpack(opts.pos) + else + error("Invalid value for option 'pos'") + end + elseif scope ~= "buffer" then + error("Invalid value for option 'scope'") + 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 }, - } + local diagnostics = M.get(bufnr, opts) + clamp_line_numbers(bufnr, diagnostics) + + if scope == "line" then + diagnostics = vim.tbl_filter(function(d) + return d.lnum == lnum + end, diagnostics) + elseif scope == "cursor" then + -- LSP servers can send diagnostics with `end_col` past the length of the line + local line_length = #vim.api.nvim_buf_get_lines(bufnr, lnum, lnum + 1, true)[1] + diagnostics = vim.tbl_filter(function(d) + return d.lnum == lnum + and math.min(d.col, line_length - 1) <= col + and (d.end_col >= col or d.end_lnum > lnum) + end, diagnostics) + end - opts = opts or {} - opts.focus_id = "line_diagnostics" - opts.lnum = lnum or (vim.api.nvim_win_get_cursor(0)[1] - 1) - bufnr = get_bufnr(bufnr) - local line_diagnostics = M.get(bufnr, opts) - return show_diagnostics(opts, line_diagnostics) + if vim.tbl_isempty(diagnostics) then + return + end + + local severity_sort = vim.F.if_nil(opts.severity_sort, global_diagnostic_options.severity_sort) + if severity_sort then + if type(severity_sort) == "table" and 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 + + do + -- Resolve options with user settings from vim.diagnostic.config + -- Unlike the other decoration functions (e.g. set_virtual_text, set_signs, etc.) `open_float` + -- does not have a dedicated table for configuration options; instead, the options are mixed in + -- with its `opts` table which also includes "keyword" parameters. So we create a dedicated + -- options table that inherits missing keys from the global configuration before resolving. + local t = global_diagnostic_options.float + local float_opts = vim.tbl_extend("keep", opts, type(t) == "table" and t or {}) + opts = get_resolved_options({ float = float_opts }, nil, bufnr).float + 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] + local message_lines = vim.split(diagnostic.message, '\n') + 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 + + -- Used by open_floating_preview to allow the float to be focused + if not opts.focus_id then + opts.focus_id = scope + end + local float_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(float_bufnr, -1, hiname, i-1, prefixlen, -1) + end + + return float_bufnr, winnr end --- Remove all diagnostics from the given namespace. @@ -1151,6 +1317,7 @@ end --- <pre> --- WARNING filename:27:3: Variable 'foo' does not exist --- </pre> +--- --- This can be parsed into a diagnostic |diagnostic-structure| --- with: --- <pre> diff --git a/runtime/lua/vim/lsp.lua b/runtime/lua/vim/lsp.lua index 90c5872f11..9a008ac965 100644 --- a/runtime/lua/vim/lsp.lua +++ b/runtime/lua/vim/lsp.lua @@ -41,6 +41,7 @@ lsp._request_name_to_capability = { ['textDocument/documentSymbol'] = 'document_symbol'; ['textDocument/prepareCallHierarchy'] = 'call_hierarchy'; ['textDocument/rename'] = 'rename'; + ['textDocument/prepareRename'] = 'rename'; ['textDocument/codeAction'] = 'code_action'; ['textDocument/codeLens'] = 'code_lens'; ['codeLens/resolve'] = 'code_lens_resolve'; @@ -85,7 +86,7 @@ end 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) + return msg end ---@private @@ -121,24 +122,34 @@ local active_clients = {} local all_buffer_active_clients = {} local uninitialized_clients = {} --- Tracks all buffers attached to a client. -local all_client_active_buffers = {} - ---@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 ---each client attached to that buffer. -local function for_each_buffer_client(bufnr, fn) +---@param restrict_client_ids table list of client ids on which to restrict function application. +local function for_each_buffer_client(bufnr, fn, restrict_client_ids) validate { fn = { fn, 'f' }; + restrict_client_ids = { restrict_client_ids, 't' , true}; } bufnr = resolve_bufnr(bufnr) local client_ids = all_buffer_active_clients[bufnr] if not client_ids or tbl_isempty(client_ids) then return end + + if restrict_client_ids and #restrict_client_ids > 0 then + local filtered_client_ids = {} + for client_id in pairs(client_ids) do + if vim.tbl_contains(restrict_client_ids, client_id) then + filtered_client_ids[client_id] = true + end + end + client_ids = filtered_client_ids + end + for client_id in pairs(client_ids) do local client = active_clients[client_id] if client then @@ -728,7 +739,6 @@ function lsp.start_client(config) lsp.diagnostic.reset(client_id, all_buffer_active_clients) changetracking.reset(client_id) - all_client_active_buffers[client_id] = nil for _, client_ids in pairs(all_buffer_active_clients) do client_ids[client_id] = nil end @@ -757,6 +767,7 @@ function lsp.start_client(config) rpc = rpc; offset_encoding = offset_encoding; config = config; + attached_buffers = {}; handlers = handlers; -- for $/progress report @@ -830,7 +841,7 @@ function lsp.start_client(config) 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 @@ -896,7 +907,7 @@ 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, result, {method=method, client_id=client_id, bufnr=bufnr}) + handler(err, result, {method=method, client_id=client_id, bufnr=bufnr, params=params}) end) end @@ -975,7 +986,6 @@ function lsp.start_client(config) lsp.diagnostic.reset(client_id, all_buffer_active_clients) changetracking.reset(client_id) - all_client_active_buffers[client_id] = nil for _, client_ids in pairs(all_buffer_active_clients) do client_ids[client_id] = nil end @@ -1018,6 +1028,7 @@ function lsp.start_client(config) -- TODO(ashkan) handle errors. pcall(config.on_attach, client, bufnr) end + client.attached_buffers[bufnr] = true end initialize() @@ -1128,12 +1139,6 @@ function lsp.buf_attach_client(bufnr, client_id) }) end - if not all_client_active_buffers[client_id] then - all_client_active_buffers[client_id] = {} - end - - table.insert(all_client_active_buffers[client_id], bufnr) - if buffer_client_ids[client_id] then return end -- This is our first time attaching this client to this buffer. buffer_client_ids[client_id] = true @@ -1158,7 +1163,7 @@ 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 number client id --- ---@returns |vim.lsp.client| object, or nil function lsp.get_client_by_id(client_id) @@ -1167,15 +1172,11 @@ end --- Returns list of buffers attached to client_id. -- ----@param client_id client id +---@param client_id number 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 - return active_client_buffers - else - return {} - end + local client = lsp.get_client_by_id(client_id) + return client and vim.tbl_keys(client.attached_buffers) or {} end --- Stops a client(s). @@ -1254,33 +1255,33 @@ function lsp.buf_request(bufnr, method, params, handler) method = { method, 's' }; handler = { handler, 'f', true }; } - local client_request_ids = {} + local supported_clients = {} local method_supported = false - for_each_buffer_client(bufnr, function(client, client_id, resolved_bufnr) + for_each_buffer_client(bufnr, function(client, client_id) if client.supports_method(method) then method_supported = true - local request_success, request_id = client.request(method, params, handler, resolved_bufnr) - - -- This could only fail if the client shut down in the time since we looked - -- it up and we did the request, which should be rare. - if request_success then - client_request_ids[client_id] = request_id - end + table.insert(supported_clients, client_id) end end) - -- if has client but no clients support the given method, call the callback with the proper - -- error message. + -- if has client but no clients support the given method, notify the user if not tbl_isempty(all_buffer_active_clients[resolve_bufnr(bufnr)] or {}) and not method_supported then - local unsupported_err = lsp._unsupported_method(method) - handler = handler or lsp.handlers[method] - if handler then - handler(unsupported_err, nil, {method=method, bufnr=bufnr}) - end + vim.notify(lsp._unsupported_method(method), vim.log.levels.ERROR) + vim.api.nvim_command("redraw") return end + local client_request_ids = {} + for_each_buffer_client(bufnr, function(client, client_id, resolved_bufnr) + local request_success, request_id = client.request(method, params, handler, resolved_bufnr) + -- This could only fail if the client shut down in the time since we looked + -- it up and we did the request, which should be rare. + if request_success then + client_request_ids[client_id] = request_id + end + end, supported_clients) + local function _cancel_all_requests() for client_id, request_id in pairs(client_request_ids) do local client = active_clients[client_id] @@ -1308,12 +1309,13 @@ function lsp.buf_request_all(bufnr, method, params, callback) local request_results = {} local result_count = 0 local expected_result_count = 0 - local cancel, client_request_ids - local set_expected_result_count = once(function() - for _ in pairs(client_request_ids) do - expected_result_count = expected_result_count + 1 - end + local set_expected_result_count = once(function () + for_each_buffer_client(bufnr, function(client) + if client.supports_method(method) then + expected_result_count = expected_result_count + 1 + end + end) end) local function _sync_handler(err, result, ctx) @@ -1326,7 +1328,7 @@ function lsp.buf_request_all(bufnr, method, params, callback) end end - client_request_ids, cancel = lsp.buf_request(bufnr, method, params, _sync_handler) + local _, cancel = lsp.buf_request(bufnr, method, params, _sync_handler) return cancel end @@ -1383,6 +1385,29 @@ function lsp.buf_notify(bufnr, method, params) return resp end + +---@private +local function adjust_start_col(lnum, line, items, encoding) + local min_start_char = nil + for _, item in pairs(items) do + if item.textEdit and item.textEdit.range.start.line == lnum - 1 then + if min_start_char and min_start_char ~= item.textEdit.range.start.character then + return nil + end + min_start_char = item.textEdit.range.start.character + end + end + if min_start_char then + if encoding == 'utf-8' then + return min_start_char + else + return vim.str_byteindex(line, min_start_char, encoding == 'utf-16') + end + else + return nil + end +end + --- Implements 'omnifunc' compatible LSP completion. --- ---@see |complete-functions| @@ -1392,7 +1417,7 @@ end ---@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) @@ -1418,17 +1443,37 @@ function lsp.omnifunc(findstart, base) -- Get the start position of the current keyword local textMatch = vim.fn.match(line_to_cursor, '\\k*$') - local prefix = line_to_cursor:sub(textMatch+1) 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, ctx) if err or not result or vim.fn.mode() ~= "i" then return end + + -- Completion response items may be relative to a position different than `textMatch`. + -- Concrete example, with sumneko/lua-language-server: + -- + -- require('plenary.asy| + -- ▲ ▲ ▲ + -- │ │ └── cursor_pos: 20 + -- │ └────── textMatch: 17 + -- └────────────── textEdit.range.start.character: 9 + -- .newText = 'plenary.async' + -- ^^^ + -- prefix (We'd remove everything not starting with `asy`, + -- so we'd eliminate the `plenary.async` result + -- + -- `adjust_start_col` is used to prefer the language server boundary. + -- + local client = lsp.get_client_by_id(ctx.client_id) + local encoding = client and client.offset_encoding or 'utf-16' + local candidates = util.extract_completion_items(result) + local startbyte = adjust_start_col(pos[1], line, candidates, encoding) or textMatch + local prefix = line:sub(startbyte + 1, pos[2]) local matches = util.text_document_completion_list_to_complete_items(result, prefix) -- TODO(ashkan): is this the best way to do this? vim.list_extend(items, matches) - vim.fn.complete(textMatch+1, items) + vim.fn.complete(startbyte + 1, items) end) -- Return -2 to signal that we should continue completion so that we can @@ -1534,5 +1579,34 @@ function lsp._with_extend(name, options, user_config) return resulting_config end + +--- 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/buf.lua b/runtime/lua/vim/lsp/buf.lua index 8bfcd90f12..245f29943e 100644 --- a/runtime/lua/vim/lsp/buf.lua +++ b/runtime/lua/vim/lsp/buf.lua @@ -289,7 +289,6 @@ function M.references(context) params.context = context or { includeDeclaration = true; } - params[vim.type_idx] = vim.types.dictionary request('textDocument/references', params) end @@ -321,13 +320,21 @@ end ---@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 @@ -443,6 +450,93 @@ function M.clear_references() util.buf_clear_references() end + +---@private +-- +--- 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 @@ -450,22 +544,28 @@ 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) - local actions = {} - for _, r in pairs(results) do - vim.list_extend(actions, r.result or {}) - end - vim.lsp.handlers[method](nil, actions, {bufnr=bufnr, method=method}) + on_code_action_results(results, { bufnr = bufnr, method = method, params = params }) end) end ---- Selects a code action from the input list that is available at the current +--- Selects a code action available at the current --- cursor position. --- ----@param context: (table, optional) Valid `CodeActionContext` object +---@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 code_action_request(params) @@ -473,14 +573,25 @@ end --- Performs |vim.lsp.buf.code_action()| for a given range. --- ----@param context: (table, optional) Valid `CodeActionContext` object +--- +---@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. ---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 code_action_request(params) diff --git a/runtime/lua/vim/lsp/codelens.lua b/runtime/lua/vim/lsp/codelens.lua index 9cedb2f1db..63fcbe430b 100644 --- a/runtime/lua/vim/lsp/codelens.lua +++ b/runtime/lua/vim/lsp/codelens.lua @@ -31,10 +31,24 @@ 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 @@ -77,16 +91,16 @@ function M.run() local option = options[1] execute_lens(option.lens, bufnr, option.client) else - local options_strings = {"Code lenses:"} - for i, option in ipairs(options) do - table.insert(options_strings, string.format('%d. %s', i, option.lens.command.title)) - end - local choice = vim.fn.inputlist(options_strings) - if choice < 1 or choice > #options then - return - end - local option = options[choice] - execute_lens(option.lens, bufnr, option.client) + vim.ui.select(options, { + prompt = 'Code lenses:', + format_item = function(option) + return option.lens.command.title + end, + }, function(option) + if option then + execute_lens(option.lens, bufnr, option.client) + end + end) end end @@ -124,7 +138,8 @@ function M.display(lenses, bufnr, client_id) end end if #chunks > 0 then - api.nvim_buf_set_extmark(bufnr, ns, i, 0, { virt_text = chunks }) + api.nvim_buf_set_extmark(bufnr, ns, i, 0, { virt_text = chunks, + hl_mode="combine" }) end end end @@ -185,7 +200,8 @@ local function resolve_lenses(lenses, bufnr, client_id, callback) ns, lens.range.start.line, 0, - { virt_text = {{ lens.command.title, 'LspCodeLens' }} } + { virt_text = {{ lens.command.title, 'LspCodeLens' }}, + hl_mode="combine" } ) end countdown() diff --git a/runtime/lua/vim/lsp/diagnostic.lua b/runtime/lua/vim/lsp/diagnostic.lua index 148836a93a..bea0e44aca 100644 --- a/runtime/lua/vim/lsp/diagnostic.lua +++ b/runtime/lua/vim/lsp/diagnostic.lua @@ -102,7 +102,17 @@ local function diagnostic_lsp_to_vim(diagnostics, bufnr, client_id) 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 + 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 @@ -110,7 +120,7 @@ end ---@private local function diagnostic_vim_to_lsp(diagnostics) return vim.tbl_map(function(diagnostic) - return { + return vim.tbl_extend("error", { range = { start = { line = diagnostic.lnum, @@ -123,7 +133,8 @@ local function diagnostic_vim_to_lsp(diagnostics) }, 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 @@ -200,9 +211,14 @@ function M.on_publish_diagnostics(_, result, ctx, config) end end end + + -- Persist configuration to ensure buffer reloads use the same + -- configuration. To make lsp.with configuration work (See :help + -- lsp-handler-configuration) + vim.diagnostic.config(config, namespace) end - vim.diagnostic.set(namespace, bufnr, diagnostic_lsp_to_vim(diagnostics, bufnr, client_id), config) + vim.diagnostic.set(namespace, bufnr, diagnostic_lsp_to_vim(diagnostics, bufnr, client_id)) -- Keep old autocmd for back compat. This should eventually be removed. vim.api.nvim_command("doautocmd <nomodeline> User LspDiagnosticsChanged") @@ -518,7 +534,7 @@ 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) + return vim.diagnostic._get_virt_text_chunks(diagnostic_lsp_to_vim(line_diags, bufnr), opts) end --- Open a floating window with the diagnostics from {position} @@ -535,14 +551,15 @@ end ---@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 + opts = opts or {} + opts.scope = "cursor" + opts.pos = position + 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 - return vim.diagnostic.show_position_diagnostics(opts, buf_nr, position) + return vim.diagnostic.open_float(buf_nr, opts) end --- Open a floating window with the diagnostics from {line_nr} @@ -557,11 +574,13 @@ end ---@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) + opts = opts or {} + opts.scope = "line" + opts.pos = line_nr if client_id then - opts = opts or {} opts.namespace = M.get_namespace(client_id) end - return vim.diagnostic.show_line_diagnostics(opts, buf_nr, line_nr) + return vim.diagnostic.open_float(buf_nr, opts) end --- Redraw diagnostics for the given buffer and client diff --git a/runtime/lua/vim/lsp/handlers.lua b/runtime/lua/vim/lsp/handlers.lua index 918666ab27..eff27807be 100644 --- a/runtime/lua/vim/lsp/handlers.lua +++ b/runtime/lua/vim/lsp/handlers.lua @@ -3,7 +3,6 @@ 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 = {} @@ -109,40 +108,6 @@ M['client/registerCapability'] = function(_, _, ctx) return vim.NIL end ---see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_codeAction -M['textDocument/codeAction'] = function(_, result) - if result == nil or vim.tbl_isempty(result) then - print("No code actions available") - return - end - - local option_strings = {"Code actions:"} - for i, action in ipairs(result) 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 > #result then - return - end - local action_chosen = result[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) if not workspace_edit then return end diff --git a/runtime/lua/vim/lsp/health.lua b/runtime/lua/vim/lsp/health.lua index 855679a2df..ed3eea59df 100644 --- a/runtime/lua/vim/lsp/health.lua +++ b/runtime/lua/vim/lsp/health.lua @@ -1,7 +1,7 @@ local M = {} --- Performs a healthcheck for LSP -function M.check_health() +function M.check() local report_info = vim.fn['health#report_info'] local report_warn = vim.fn['health#report_warn'] diff --git a/runtime/lua/vim/lsp/protocol.lua b/runtime/lua/vim/lsp/protocol.lua index 27703b4503..b3aa8b934f 100644 --- a/runtime/lua/vim/lsp/protocol.lua +++ b/runtime/lua/vim/lsp/protocol.lua @@ -645,6 +645,10 @@ function protocol.make_client_capabilities() end)(); }; }; + dataSupport = true; + resolveSupport = { + properties = { 'edit', } + }; }; completion = { dynamicRegistration = false; diff --git a/runtime/lua/vim/lsp/rpc.lua b/runtime/lua/vim/lsp/rpc.lua index 7f31bbdf75..d9a684a738 100644 --- a/runtime/lua/vim/lsp/rpc.lua +++ b/runtime/lua/vim/lsp/rpc.lua @@ -4,34 +4,6 @@ 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 --- Checks whether a given path exists and is a directory. ---@param filename (string) path to check @@ -41,36 +13,6 @@ local function is_dir(filename) return stat and stat.type == 'directory' or false end -local NIL = vim.NIL - ----@private -local recursive_convert_NIL -recursive_convert_NIL = function(v, tbl_processed) - if v == NIL then - return nil - elseif not tbl_processed[v] and type(v) == 'table' then - tbl_processed[v] = true - local inside_list = vim.tbl_islist(v) - return vim.tbl_map(function(x) - if not inside_list or (inside_list and type(x) == "table") then - return recursive_convert_NIL(x, tbl_processed) - else - return x - end - end, v) - end - - return v -end - ----@private ---- Returns its argument, but converts `vim.NIL` to Lua `nil`. ----@param v (any) Argument ----@returns (any) -local function convert_NIL(v) - return recursive_convert_NIL(v, {}) -end - ---@private --- Merges current process env with the given env and returns the result as --- a list of "k=v" strings. @@ -389,16 +331,13 @@ local function start(cmd, cmd_args, dispatchers, extra_spawn_params) --- 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()| + ---@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) 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 @@ -488,16 +427,15 @@ local function start(cmd, cmd_args, dispatchers, extra_spawn_params) ---@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, { luanil = { object = true } }) + if not ok then + on_error(client_errors.INVALID_SERVER_JSON, decoded) return end local _ = log.debug() and log.debug("rpc.receive", decoded) if type(decoded.method) == 'string' and decoded.id then - -- Server Request - decoded.params = convert_NIL(decoded.params) + local err -- Schedule here so that the users functions don't trigger an error and -- we can still use the result. schedule(function() @@ -524,22 +462,16 @@ local function start(cmd, cmd_args, dispatchers, extra_spawn_params) end) -- This works because we are expecting vim.NIL here elseif decoded.id and (decoded.result ~= vim.NIL or decoded.error ~= vim.NIL) then - -- Server Result - decoded.error = convert_NIL(decoded.error) - decoded.result = convert_NIL(decoded.result) -- We sent a number, so we expect a number. local result_id = tonumber(decoded.id) - -- Do not surface RequestCancelled or ContentModified to users, it is RPC-internal. + -- Do not surface RequestCancelled to users, it is RPC-internal. if decoded.error then local mute_error = false if decoded.error.code == protocol.ErrorCodes.RequestCancelled then local _ = log.debug() and log.debug("Received cancellation ack", decoded) mute_error = true - elseif decoded.error.code == protocol.ErrorCodes.ContentModified then - local _ = log.debug() and log.debug("Received content modified ack", decoded) - mute_error = true end if mute_error then @@ -574,7 +506,6 @@ local function start(cmd, cmd_args, dispatchers, extra_spawn_params) end elseif type(decoded.method) == 'string' then -- Notification - decoded.params = convert_NIL(decoded.params) try_call(client_errors.NOTIFICATION_HANDLER_ERROR, dispatchers.notification, decoded.method, decoded.params) else @@ -582,8 +513,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 e95f170427..952926b67e 100644 --- a/runtime/lua/vim/lsp/util.lua +++ b/runtime/lua/vim/lsp/util.lua @@ -193,6 +193,7 @@ function M.get_progress_messages() title = ctx.title or "empty title", message = ctx.message, percentage = ctx.percentage, + done = ctx.done, progress = true, } table.insert(new_messages, new_report) @@ -334,10 +335,12 @@ function M.apply_text_edits(text_edits, bufnr) end 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 '')) - }) + local is_valid_cursor = true + is_valid_cursor = is_valid_cursor and cursor.row < vim.api.nvim_buf_line_count(bufnr) + is_valid_cursor = is_valid_cursor and cursor.col <= #(vim.api.nvim_buf_get_lines(bufnr, cursor.row, cursor.row + 1, false)[1] or '') + if is_valid_cursor then + vim.api.nvim_win_set_cursor(0, { cursor.row + 1, cursor.col }) + end end -- Remove final line if needed @@ -951,6 +954,11 @@ end ---@param width (number) window width (in character cells) ---@param height (number) window height (in character cells) ---@param opts (table, optional) +--- - offset_x (number) offset to add to `col` +--- - offset_y (number) offset to add to `row` +--- - border (string or table) override `border` +--- - focusable (string or table) override `focusable` +--- - zindex (string or table) override `zindex`, defaults to 50 ---@returns (table) Options function M.make_floating_popup_options(width, height, opts) validate { @@ -975,7 +983,7 @@ function M.make_floating_popup_options(width, height, opts) else anchor = anchor..'S' height = math.min(lines_above, height) - row = -get_border_size(opts).height + row = 0 end if vim.fn.wincol() + width + (opts.offset_x or 0) <= api.nvim_get_option('columns') then @@ -1124,8 +1132,6 @@ end --- - wrap_at character to wrap at for computing height --- - max_width maximal width of floating window --- - max_height maximal height of floating window ---- - pad_left number of columns to pad contents at left ---- - pad_right number of columns to pad contents at right --- - pad_top number of lines to pad contents at top --- - pad_bottom number of lines to pad contents at bottom --- - separator insert separator after code block @@ -1376,8 +1382,6 @@ end --- - wrap_at character to wrap at for computing height when wrap is enabled --- - max_width maximal width of floating window --- - max_height maximal height of floating window ---- - pad_left number of columns to pad contents at left ---- - pad_right number of columns to pad contents at right --- - pad_top number of lines to pad contents at top --- - pad_bottom number of lines to pad contents at bottom --- - focus_id if a popup with this id is opened, then focus it diff --git a/runtime/lua/vim/shared.lua b/runtime/lua/vim/shared.lua index 18c1e21049..b57b7ad4ad 100644 --- a/runtime/lua/vim/shared.lua +++ b/runtime/lua/vim/shared.lua @@ -98,17 +98,53 @@ end --- <pre> --- split(":aa::b:", ":") --> {'','aa','','b',''} --- split("axaby", "ab?") --> {'','x','y'} ---- split(x*yz*o, "*", true) --> {'x','yz','o'} +--- split("x*yz*o", "*", {plain=true}) --> {'x','yz','o'} +--- split("|x|y|z|", "|", {trimempty=true}) --> {'x', 'y', 'z'} --- </pre> --- +--- ---@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) +---@param kwargs Keyword arguments: +--- - plain: (boolean) If `true` use `sep` literally (passed to string.find) +--- - trimempty: (boolean) If `true` remove empty items from the front +--- and back of the list ---@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 +function vim.split(s, sep, kwargs) + local plain + local trimempty = false + if type(kwargs) == 'boolean' then + -- Support old signature for backward compatibility + plain = kwargs + else + vim.validate { kwargs = {kwargs, 't', true} } + kwargs = kwargs or {} + plain = kwargs.plain + trimempty = kwargs.trimempty + end + + local t = {} + local skip = trimempty + for c in vim.gsplit(s, sep, plain) do + if c ~= "" then + skip = false + end + + if not skip then + table.insert(t, c) + end + end + + if trimempty then + for i = #t, 1, -1 do + if t[i] ~= "" then + break + end + table.remove(t, i) + end + end + return t end diff --git a/runtime/lua/vim/treesitter/health.lua b/runtime/lua/vim/treesitter/health.lua index e031ba1bd6..53ccc6e88d 100644 --- a/runtime/lua/vim/treesitter/health.lua +++ b/runtime/lua/vim/treesitter/health.lua @@ -9,7 +9,7 @@ function M.list_parsers() end --- Performs a healthcheck for treesitter integration -function M.check_health() +function M.check() local report_info = vim.fn['health#report_info'] local report_ok = vim.fn['health#report_ok'] local report_error = vim.fn['health#report_error'] 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 a3e79a0f2b..5d8d4fa169 100644 --- a/runtime/lua/vim/uri.lua +++ b/runtime/lua/vim/uri.lua @@ -75,13 +75,22 @@ local function uri_from_fname(path) end local URI_SCHEME_PATTERN = '^([a-zA-Z]+[a-zA-Z0-9+-.]*):.*' +local WINDOWS_URI_SCHEME_PATTERN = '^([a-zA-Z]+[a-zA-Z0-9+-.]*):[a-zA-Z]:.*' --- Get a URI from a bufnr ---@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) + local volume_path = fname:match("^([a-zA-Z]:).*") + local is_windows = volume_path ~= nil + local scheme + if is_windows then + fname = fname:gsub("\\", "/") + scheme = fname:match(WINDOWS_URI_SCHEME_PATTERN) + else + scheme = fname:match(URI_SCHEME_PATTERN) + end if scheme then return fname else |