diff options
Diffstat (limited to 'runtime/lua/vim')
| -rw-r--r-- | runtime/lua/vim/diagnostic.lua | 717 | ||||
| -rw-r--r-- | runtime/lua/vim/lsp.lua | 185 | ||||
| -rw-r--r-- | runtime/lua/vim/lsp/buf.lua | 42 | ||||
| -rw-r--r-- | runtime/lua/vim/lsp/codelens.lua | 6 | ||||
| -rw-r--r-- | runtime/lua/vim/lsp/diagnostic.lua | 24 | ||||
| -rw-r--r-- | runtime/lua/vim/lsp/handlers.lua | 5 | ||||
| -rw-r--r-- | runtime/lua/vim/lsp/rpc.lua | 29 | ||||
| -rw-r--r-- | runtime/lua/vim/lsp/sync.lua | 381 | ||||
| -rw-r--r-- | runtime/lua/vim/lsp/util.lua | 247 | ||||
| -rw-r--r-- | runtime/lua/vim/shared.lua | 2 | ||||
| -rw-r--r-- | runtime/lua/vim/ui.lua | 38 | 
11 files changed, 1081 insertions, 595 deletions
diff --git a/runtime/lua/vim/diagnostic.lua b/runtime/lua/vim/diagnostic.lua index 326932d982..b30a678eeb 100644 --- a/runtime/lua/vim/diagnostic.lua +++ b/runtime/lua/vim/diagnostic.lua @@ -1,3 +1,5 @@ +local if_nil = vim.F.if_nil +  local M = {}  M.severity = { @@ -19,10 +21,21 @@ local global_diagnostic_options = {    signs = true,    underline = true,    virtual_text = true, +  float = true,    update_in_insert = false,    severity_sort = false,  } +M.handlers = setmetatable({}, { +  __newindex = function(t, name, handler) +    vim.validate { handler = {handler, "t" } } +    rawset(t, name, handler) +    if not global_diagnostic_options[name] then +      global_diagnostic_options[name] = true +    end +  end, +}) +  -- Local functions {{{  ---@private @@ -54,7 +67,7 @@ end  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'" } } +  end, "'always' or 'if_many'" } }    if source == "if_many" then      local sources = {} @@ -96,31 +109,9 @@ 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 - -    assert(name, "namespace does not exist or is anonymous") - -    all_namespaces[ns] = { -      name = name, -      sign_group = string.format("vim.diagnostic.%s", name), -      opts = {} -    } -  end -  return all_namespaces[ns] -end - ----@private  local function enabled_value(option, namespace) -  local ns = get_namespace(namespace) -  if type(ns.opts[option]) == "table" then +  local ns = namespace and M.get_namespace(namespace) or {} +  if ns.opts and type(ns.opts[option]) == "table" then      return ns.opts[option]    end @@ -153,8 +144,9 @@ 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 M.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(k, resolved[k], namespace, bufnr) @@ -323,7 +315,7 @@ end  ---@private  local function save_extmarks(namespace, bufnr) -  bufnr = bufnr == 0 and vim.api.nvim_get_current_buf() or bufnr +  bufnr = get_bufnr(bufnr)    if not diagnostic_attached_buffers[bufnr] then      vim.api.nvim_buf_attach(bufnr, false, {        on_lines = function(_, _, _, _, _, last) @@ -341,7 +333,7 @@ local registered_autocmds = {}  ---@private  local function make_augroup_key(namespace, bufnr) -  local ns = get_namespace(namespace) +  local ns = M.get_namespace(namespace)    return string.format("DiagnosticInsertLeave:%s:%s", bufnr, ns.name)  end @@ -354,19 +346,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 @@ -376,77 +364,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 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 - -  -- Use global setting for severity_sort since 'show_diagnostics' is namespace -  -- independent -  local 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 - -  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) @@ -469,6 +394,7 @@ 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 @@ -526,7 +452,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 @@ -539,10 +465,13 @@ local function diagnostic_move_pos(opts, pos)    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 @@ -561,12 +490,12 @@ end  ---  --- For example, if a user enables virtual text globally with  --- <pre> ----   vim.diagnostic.config({virt_text = true}) +---   vim.diagnostic.config({virtual_text = true})  --- </pre>  ---  --- and a diagnostic producer sets diagnostics with  --- <pre> ----   vim.diagnostic.set(ns, 0, diagnostics, {virt_text = false}) +---   vim.diagnostic.set(ns, 0, diagnostics, {virtual_text = false})  --- </pre>  ---  --- then virtual text will not be enabled for those diagnostics. @@ -603,6 +532,19 @@ end  ---                * 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. +---                  * prefix: (function or string) Prefix each diagnostic in the floating window. If +---                            a function, it must have the signature (diagnostic, i, total) -> string, +---                            where {i} is the index of the diagnostic being evaluated and {total} is +---                            the total number of diagnostics displayed in the window. The returned +---                            string is prepended to each diagnostic in the window. Otherwise, +---                            if {prefix} is a string, it is prepended to each 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 @@ -620,7 +562,7 @@ function M.config(opts, namespace)    local t    if namespace then -    local ns = get_namespace(namespace) +    local ns = M.get_namespace(namespace)      t = ns.opts    else      t = global_diagnostic_options @@ -672,7 +614,7 @@ function M.set(namespace, bufnr, diagnostics, opts)        -- Clean up our data when the buffer unloads.        vim.api.nvim_buf_attach(bufnr, false, {          on_detach = function(_, b) -          clear_diagnostic_cache(b, namespace) +          clear_diagnostic_cache(namespace, b)            diagnostic_cleanup[b][namespace] = nil          end        }) @@ -687,6 +629,32 @@ function M.set(namespace, bufnr, diagnostics, opts)    vim.api.nvim_command("doautocmd <nomodeline> User DiagnosticsChanged")  end +--- Get namespace metadata. +--- +---@param ns number Diagnostic namespace +---@return table Namespace metadata +function M.get_namespace(namespace) +  vim.validate { namespace = { namespace, 'n' } } +  if not all_namespaces[namespace] then +    local name +    for k, v in pairs(vim.api.nvim_get_namespaces()) do +      if namespace == v then +        name = k +        break +      end +    end + +    assert(name, "namespace does not exist or is anonymous") + +    all_namespaces[namespace] = { +      name = name, +      opts = {}, +      user_data = {}, +    } +  end +  return all_namespaces[namespace] +end +  --- Get current diagnostic namespaces.  ---  ---@return table A list of active diagnostic namespaces |vim.diagnostic|. @@ -825,10 +793,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( @@ -837,156 +804,171 @@ function M.goto_next(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}, -  } +M.handlers.signs = { +  show = function(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) +    bufnr = get_bufnr(bufnr) -  if opts.signs and opts.signs.severity then -    diagnostics = filter_by_severity(opts.signs.severity, diagnostics) -  end - -  local ns = get_namespace(namespace) +    if opts.signs and opts.signs.severity then +      diagnostics = filter_by_severity(opts.signs.severity, diagnostics) +    end -  define_default_signs() +    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) +    -- 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(severity) -        return priority + (vim.diagnostic.severity.HINT - severity) +      get_priority = function() +        return priority        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}, -  } +    local ns = M.get_namespace(namespace) +    if not ns.user_data.sign_group then +      ns.user_data.sign_group = string.format("vim.diagnostic.%s", ns.name) +    end -  bufnr = get_bufnr(bufnr) -  opts = get_resolved_options({ underline = opts }, namespace, bufnr).underline +    local sign_group = ns.user_data.sign_group +    for _, diagnostic in ipairs(diagnostics) do +      vim.fn.sign_place( +        0, +        sign_group, +        sign_highlight_map[diagnostic.severity], +        bufnr, +        { +          priority = get_priority(diagnostic.severity), +          lnum = diagnostic.lnum + 1 +        } +      ) +    end +  end, +  hide = function(namespace, bufnr) +    local ns = M.get_namespace(namespace) +    if ns.user_data.sign_group then +      vim.fn.sign_unplace(ns.user_data.sign_group, {buffer=bufnr}) +    end +  end, +} -  if opts and opts.severity then -    diagnostics = filter_by_severity(opts.severity, diagnostics) -  end +M.handlers.underline = { +  show = function(namespace, bufnr, diagnostics, opts) +    vim.validate { +      namespace = {namespace, 'n'}, +      bufnr = {bufnr, 'n'}, +      diagnostics = {diagnostics, 't'}, +      opts = {opts, 't', true}, +    } -  for _, diagnostic in ipairs(diagnostics) do -    local higroup = underline_highlight_map[diagnostic.severity] +    bufnr = get_bufnr(bufnr) -    if higroup == nil then -      -- Default to error if we don't have a highlight associated -      higroup = underline_highlight_map.Error +    if opts.underline and opts.underline.severity then +      diagnostics = filter_by_severity(opts.underline.severity, diagnostics)      end -    vim.highlight.range( -      bufnr, -      namespace, -      higroup, -      { diagnostic.lnum, diagnostic.col }, -      { diagnostic.end_lnum, diagnostic.end_col } -    ) -  end -end +    local ns = M.get_namespace(namespace) +    if not ns.user_data.underline_ns then +      ns.user_data.underline_ns = vim.api.nvim_create_namespace("") +    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}, -  } +    local underline_ns = ns.user_data.underline_ns +    for _, diagnostic in ipairs(diagnostics) do +      local higroup = underline_highlight_map[diagnostic.severity] -  bufnr = get_bufnr(bufnr) -  opts = get_resolved_options({ virtual_text = opts }, namespace, bufnr).virtual_text +      if higroup == nil then +        -- Default to error if we don't have a highlight associated +        higroup = underline_highlight_map.Error +      end -  if opts and opts.format then -    diagnostics = reformat_diagnostics(opts.format, diagnostics) +      vim.highlight.range( +        bufnr, +        underline_ns, +        higroup, +        { diagnostic.lnum, diagnostic.col }, +        { diagnostic.end_lnum, diagnostic.end_col } +      ) +    end +    save_extmarks(underline_ns, bufnr) +  end, +  hide = function(namespace, bufnr) +    local ns = M.get_namespace(namespace) +    if ns.user_data.underline_ns then +      diagnostic_cache_extmarks[bufnr][ns.user_data.underline_ns] = {} +      vim.api.nvim_buf_clear_namespace(bufnr, ns.user_data.underline_ns, 0, -1) +    end    end +} -  if opts and opts.source then -    diagnostics = prefix_source(opts.source, diagnostics) -  end +M.handlers.virtual_text = { +  show = function(namespace, bufnr, diagnostics, opts) +    vim.validate { +      namespace = {namespace, 'n'}, +      bufnr = {bufnr, 'n'}, +      diagnostics = {diagnostics, 't'}, +      opts = {opts, 't', true}, +    } -  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) +    bufnr = get_bufnr(bufnr) + +    local severity +    if opts.virtual_text then +      if opts.virtual_text.format then +        diagnostics = reformat_diagnostics(opts.virtual_text.format, diagnostics) +      end +      if opts.virtual_text.source then +        diagnostics = prefix_source(opts.virtual_text.source, diagnostics) +      end +      if opts.virtual_text.severity then +        severity = opts.virtual_text.severity +      end      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, -      }) +    local ns = M.get_namespace(namespace) +    if not ns.user_data.virt_text_ns then +      ns.user_data.virt_text_ns = vim.api.nvim_create_namespace("")      end -  end -end + +    local virt_text_ns = ns.user_data.virt_text_ns +    local buffer_line_diagnostics = diagnostic_lines(diagnostics) +    for line, line_diagnostics in pairs(buffer_line_diagnostics) do +      if severity then +        line_diagnostics = filter_by_severity(severity, line_diagnostics) +      end +      local virt_texts = M._get_virt_text_chunks(line_diagnostics, opts.virtual_text) + +      if virt_texts then +        vim.api.nvim_buf_set_extmark(bufnr, virt_text_ns, line, 0, { +          hl_mode = "combine", +          virt_text = virt_texts, +        }) +      end +    end +    save_extmarks(virt_text_ns, bufnr) +  end, +  hide = function(namespace, bufnr) +    local ns = M.get_namespace(namespace) +    if ns.user_data.virt_text_ns then +      diagnostic_cache_extmarks[bufnr][ns.user_data.virt_text_ns] = {} +      vim.api.nvim_buf_clear_namespace(bufnr, ns.user_data.virt_text_ns, 0, -1) +    end +  end, +}  --- Get virtual text chunks to display using |nvim_buf_set_extmark()|.  --- @@ -1055,46 +1037,54 @@ end  --- To hide diagnostics and prevent them from re-displaying, use  --- |vim.diagnostic.disable()|.  --- ----@param namespace number The diagnostic namespace +---@param namespace number|nil Diagnostic namespace. When omitted, hide +---                            diagnostics from all namespaces.  ---@param bufnr number|nil Buffer number. Defaults to the current buffer.  function M.hide(namespace, bufnr)    vim.validate { -    namespace = { namespace, 'n' }, +    namespace = { namespace, 'n', true },      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) +  local namespaces = namespace and {namespace} or vim.tbl_keys(diagnostic_cache[bufnr]) +  for _, iter_namespace in ipairs(namespaces) do +    for _, handler in pairs(M.handlers) do +      if handler.hide then +        handler.hide(iter_namespace, bufnr) +      end +    end +  end  end -  --- Display diagnostics for the given namespace and buffer.  --- ----@param namespace number Diagnostic namespace +---@param namespace number|nil Diagnostic namespace. When omitted, show +---                            diagnostics from all namespaces.  ---@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. +---                             diagnostics. May not be used when {namespace} is nil.  ---@param opts table|nil Display options. See |vim.diagnostic.config()|.  function M.show(namespace, bufnr, diagnostics, opts)    vim.validate { -    namespace = { namespace, 'n' }, +    namespace = { namespace, 'n', true },      bufnr = { bufnr, 'n', true },      diagnostics = { diagnostics, 't', true },      opts = { opts, 't', true },    }    bufnr = get_bufnr(bufnr) +  if not namespace then +    assert(not diagnostics, "Cannot show diagnostics without a namespace") +    for iter_namespace in pairs(diagnostic_cache[bufnr]) do +      M.show(iter_namespace, bufnr, nil, opts) +    end +    return +  end +    if is_disabled(namespace, bufnr) then      return    end @@ -1129,83 +1119,154 @@ function M.show(namespace, bufnr, diagnostics, opts)    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) +  for handler_name, handler in pairs(M.handlers) do +    if handler.show and opts[handler_name] then +      handler.show(namespace, bufnr, diagnostics, opts) +    end    end - -  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 ----            - source: (string) Include the diagnostic source in ----                      the message. One of "always" or "if_many". +---            - 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. ----@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) +---                      Overrides the setting from |vim.diagnostic.config()|. +---            - prefix: (function or string) Prefix each diagnostic in the floating window. +---                      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 +  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 -  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) + +  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 diagnostics = M.get(bufnr, opts)    clamp_line_numbers(bufnr, diagnostics) -  local position_diagnostics = vim.tbl_filter(match_position_predicate, diagnostics) -  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 }, -  } +  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" -  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) +  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 + +  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 + +  local prefix_opt = if_nil(opts.prefix, (scope == "cursor" and #diagnostics <= 1) and "" or function(_, i) +      return string.format("%d. ", i) +  end) +  if prefix_opt then +    vim.validate { prefix = { prefix_opt, function(v) +      return type(v) == "string" or type(v) == "function" +    end, "'string' or 'function'" } } +  end + +  for i, diagnostic in ipairs(diagnostics) do +    local prefix = type(prefix_opt) == "string" and prefix_opt or prefix_opt(diagnostic, i, #diagnostics) +    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. @@ -1215,19 +1276,23 @@ end  --- simply remove diagnostic decorations in a way that they can be  --- re-displayed, use |vim.diagnostic.hide()|.  --- ----@param namespace number +---@param namespace number|nil Diagnostic namespace. When omitted, remove +---                            diagnostics from all namespaces.  ---@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 +  vim.validate { +    namespace = {namespace, 'n', true}, +    bufnr = {bufnr, 'n', true}, +  } + +  local buffers = bufnr and {bufnr} or vim.tbl_keys(diagnostic_cache) +  for _, iter_bufnr in ipairs(buffers) do +    local namespaces = namespace and {namespace} or vim.tbl_keys(diagnostic_cache[iter_bufnr]) +    for _, iter_namespace in ipairs(namespaces) do +      clear_diagnostic_cache(iter_namespace, iter_bufnr) +      M.hide(iter_namespace, iter_bufnr)      end -  else -    clear_diagnostic_cache(namespace, bufnr) -    M.hide(namespace, bufnr)    end    vim.api.nvim_command("doautocmd <nomodeline> User DiagnosticsChanged") diff --git a/runtime/lua/vim/lsp.lua b/runtime/lua/vim/lsp.lua index 9c35351608..0fc0a7a7aa 100644 --- a/runtime/lua/vim/lsp.lua +++ b/runtime/lua/vim/lsp.lua @@ -5,6 +5,7 @@ local log = require 'vim.lsp.log'  local lsp_rpc = require 'vim.lsp.rpc'  local protocol = require 'vim.lsp.protocol'  local util = require 'vim.lsp.util' +local sync = require 'vim.lsp.sync'  local vim = vim  local nvim_err_writeln, nvim_buf_get_lines, nvim_command, nvim_buf_get_option @@ -108,6 +109,12 @@ local valid_encodings = {    UTF8      = 'utf-8'; UTF16      = 'utf-16'; UTF32      = 'utf-32';  } +local format_line_ending = { +  ["unix"] = '\n', +  ["dos"] = '\r\n', +  ["mac"] = '\r', +} +  local client_index = 0  ---@private  --- Returns a new, unused client id. @@ -122,9 +129,6 @@ 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}.  --- @@ -242,6 +246,7 @@ local function validate_client_config(config)      on_exit         = { config.on_exit, "f", true };      on_init         = { config.on_init, "f", true };      settings        = { config.settings, "t", true }; +    commands        = { config.commands, 't', true };      before_init     = { config.before_init, "f", true };      offset_encoding = { config.offset_encoding, "s", true };      flags           = { config.flags, "t", true }; @@ -353,15 +358,14 @@ do      end    end -  function changetracking.prepare(bufnr, firstline, new_lastline, changedtick) +  function changetracking.prepare(bufnr, firstline, lastline, new_lastline, changedtick)      local incremental_changes = function(client)        local cached_buffers = state_by_client[client.id].buffers -      local lines = nvim_buf_get_lines(bufnr, 0, -1, true) -      local startline =  math.min(firstline + 1, math.min(#cached_buffers[bufnr], #lines)) -      local endline =  math.min(-(#lines - new_lastline), -1) -      local incremental_change = vim.lsp.util.compute_diff( -        cached_buffers[bufnr], lines, startline, endline, client.offset_encoding or 'utf-16') -      cached_buffers[bufnr] = lines +      local curr_lines = nvim_buf_get_lines(bufnr, 0, -1, true) +      local line_ending = format_line_ending[vim.api.nvim_buf_get_option(0, 'fileformat')] +      local incremental_change = sync.compute_diff( +        cached_buffers[bufnr], curr_lines, firstline, lastline, new_lastline, client.offset_encoding or 'utf-16', line_ending or '\n') +      cached_buffers[bufnr] = curr_lines        return incremental_change      end      local full_changes = once(function() @@ -468,7 +472,11 @@ local function text_document_did_open_handler(bufnr, client)    -- Next chance we get, we should re-do the diagnostics    vim.schedule(function() -    vim.lsp.diagnostic.redraw(bufnr, client.id) +    -- Protect against a race where the buffer disappears +    -- between `did_open_handler` and the scheduled function firing. +    if vim.api.nvim_buf_is_valid(bufnr) then +      vim.lsp.diagnostic.redraw(bufnr, client.id) +    end    end)  end @@ -593,6 +601,11 @@ end  --- returned to the language server if requested via `workspace/configuration`.  --- Keys are case-sensitive.  --- +---@param commands table Table that maps string of clientside commands to user-defined functions. +--- Commands passed to start_client take precedence over the global command registry. Each key +--- must be a unique comand name, and the value is a function which is called if any LSP action +--- (code action, code lenses, ...) triggers the command. +---  ---@param init_options Values to pass in the initialization request  --- as `initializationOptions`. See `initialize` in the LSP spec.  --- @@ -647,7 +660,9 @@ end  --- - debounce_text_changes (number, default nil): Debounce didChange  ---       notifications to the server by the given number in milliseconds. No debounce  ---       occurs if nil ---- +--- - exit_timeout (number, default 500): Milliseconds to wait for server to +--        exit cleanly after sending the 'shutdown' request before sending kill -15. +--        If set to false, nvim exits immediately after sending the 'shutdown' request to the server.  ---@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. @@ -742,7 +757,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 @@ -771,10 +785,14 @@ function lsp.start_client(config)      rpc = rpc;      offset_encoding = offset_encoding;      config = config; +    attached_buffers = {};      handlers = handlers; +    commands = config.commands or {}; + +    requests = {};      -- for $/progress report -    messages = { name = name, messages = {}, progress = {}, status = {} } +    messages = { name = name, messages = {}, progress = {}, status = {} };    }    -- Store the uninitialized_clients for cleanup in case we exit before initialize finishes. @@ -907,11 +925,21 @@ function lsp.start_client(config)      end      -- Ensure pending didChange notifications are sent so that the server doesn't operate on a stale state      changetracking.flush(client) - +    bufnr = resolve_bufnr(bufnr)      local _ = log.debug() and log.debug(log_prefix, "client.request", client_id, method, params, handler, bufnr) -    return rpc.request(method, params, function(err, result) +    local success, request_id = rpc.request(method, params, function(err, result)        handler(err, result, {method=method, client_id=client_id, bufnr=bufnr, params=params}) +    end, function(request_id) +      client.requests[request_id] = nil +      nvim_command("doautocmd <nomodeline> User LspRequest")      end) + +    if success then +      client.requests[request_id] = { type='pending', bufnr=bufnr, method=method } +      nvim_command("doautocmd <nomodeline> User LspRequest") +    end + +    return success, request_id    end    ---@private @@ -971,6 +999,11 @@ function lsp.start_client(config)    ---@see |vim.lsp.client.notify()|    function client.cancel_request(id)      validate{id = {id, 'n'}} +    local request = client.requests[id] +    if request and request.type == 'pending' then +      request.type = 'cancel' +      nvim_command("doautocmd <nomodeline> User LspRequest") +    end      return rpc.notify("$/cancelRequest", { id = id })    end @@ -989,7 +1022,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 @@ -1032,6 +1064,7 @@ function lsp.start_client(config)        -- TODO(ashkan) handle errors.        pcall(config.on_attach, client, bufnr)      end +    client.attached_buffers[bufnr] = true    end    initialize() @@ -1044,22 +1077,14 @@ end  --- Notify all attached clients that a buffer has changed.  local text_document_did_change_handler  do -  text_document_did_change_handler = function(_, bufnr, changedtick, -      firstline, lastline, new_lastline, old_byte_size, old_utf32_size, -      old_utf16_size) - -    local _ = log.debug() and log.debug( -      string.format("on_lines bufnr: %s, changedtick: %s, firstline: %s, lastline: %s, new_lastline: %s, old_byte_size: %s, old_utf32_size: %s, old_utf16_size: %s", -      bufnr, changedtick, firstline, lastline, new_lastline, old_byte_size, old_utf32_size, old_utf16_size), -      nvim_buf_get_lines(bufnr, firstline, new_lastline, true) -    ) +  text_document_did_change_handler = function(_, bufnr, changedtick, firstline, lastline, new_lastline)      -- Don't do anything if there are no clients attached.      if tbl_isempty(all_buffer_active_clients[bufnr] or {}) then        return      end      util.buf_versions[bufnr] = changedtick -    local compute_change_and_notify = changetracking.prepare(bufnr, firstline, new_lastline, changedtick) +    local compute_change_and_notify = changetracking.prepare(bufnr, firstline, lastline, new_lastline, changedtick)      for_each_buffer_client(bufnr, compute_change_and_notify)    end  end @@ -1142,12 +1167,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 @@ -1172,7 +1191,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) @@ -1181,15 +1200,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). @@ -1239,9 +1254,41 @@ function lsp._vim_exit_handler()      client.stop()    end -  if not vim.wait(500, function() return tbl_isempty(active_clients) end, 50) then -    for _, client in pairs(active_clients) do -      client.stop(true) +  local timeouts = {} +  local max_timeout = 0 +  local send_kill = false + +  for client_id, client in pairs(active_clients) do +    local timeout = if_nil(client.config.flags.exit_timeout, 500) +    if timeout then +      send_kill = true +      timeouts[client_id] = timeout +      max_timeout = math.max(timeout, max_timeout) +    end +  end + +  local poll_time = 50 + +  local function check_clients_closed() +    for client_id, timeout in pairs(timeouts) do +      timeouts[client_id] = timeout - poll_time +    end + +    for client_id, _ in pairs(active_clients) do +      if timeouts[client_id] ~= nil and timeouts[client_id] > 0 then +        return false +      end +    end +    return true +  end + +  if send_kill then +    if not vim.wait(max_timeout, check_clients_closed, poll_time) then +      for client_id, client in pairs(active_clients) do +        if timeouts[client_id] ~= nil then +          client.stop(true) +        end +      end      end    end  end @@ -1282,7 +1329,7 @@ function lsp.buf_request(bufnr, method, params, handler)    if not tbl_isempty(all_buffer_active_clients[resolve_bufnr(bufnr)] or {}) and not method_supported then      vim.notify(lsp._unsupported_method(method), vim.log.levels.ERROR)      vim.api.nvim_command("redraw") -    return +    return {}, function() end    end    local client_request_ids = {} @@ -1430,7 +1477,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) @@ -1494,6 +1541,52 @@ function lsp.omnifunc(findstart, base)    return -2  end +--- Provides an interface between the built-in client and a `formatexpr` function. +--- +--- Currently only supports a single client. This can be set via +--- `setlocal formatexpr=v:lua.vim.lsp.formatexpr()` but will typically or in `on_attach` +--- via `vim.api.nvim_buf_set_option(bufnr, 'formatexpr', 'v:lua.vim.lsp.formatexpr(#{timeout_ms:250})')`. +--- +---@param opts table options for customizing the formatting expression which takes the +---                   following optional keys: +---                   * timeout_ms (default 500ms). The timeout period for the formatting request. +function lsp.formatexpr(opts) +  opts = opts or {} +  local timeout_ms = opts.timeout_ms or 500 + +  if vim.tbl_contains({'i', 'R', 'ic', 'ix'}, vim.fn.mode()) then +    -- `formatexpr` is also called when exceeding `textwidth` in insert mode +    -- fall back to internal formatting +    return 1 +  end + +  local start_line = vim.v.lnum +  local end_line = start_line + vim.v.count - 1 + +  if start_line > 0 and end_line > 0 then +    local params = { +      textDocument = util.make_text_document_params(); +      range = { +        start = { line = start_line - 1; character = 0; }; +        ["end"] = { line = end_line - 1; character = 0; }; +      }; +    }; +    params.options = util.make_formatting_params().options +    local client_results = vim.lsp.buf_request_sync(0, "textDocument/rangeFormatting", params, timeout_ms) + +    -- Apply the text edits from one and only one of the clients. +    for _, response in pairs(client_results) do +      if response.result then +        vim.lsp.util.apply_text_edits(response.result, 0) +        return 0 +      end +    end +  end + +  -- do not run builtin formatter. +  return 0 +end +  ---Checks whether a client is stopped.  ---  ---@param client_id (Number) diff --git a/runtime/lua/vim/lsp/buf.lua b/runtime/lua/vim/lsp/buf.lua index 245f29943e..747d761730 100644 --- a/runtime/lua/vim/lsp/buf.lua +++ b/runtime/lua/vim/lsp/buf.lua @@ -247,22 +247,35 @@ 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 ----name using |input()|. +---name using |vim.ui.input()|.  function M.rename(new_name) -  local params = util.make_position_params() +  local opts = { +    prompt = "New Name: " +  } + +  ---@private +  local function on_confirm(input) +    if not (input and #input > 0) then return end +    local params = util.make_position_params() +    params.newName = input +    request('textDocument/rename', params) +  end +    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) +      opts.default = result.placeholder +      if not new_name then npcall(vim.ui.input, opts, on_confirm) end      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)) +      opts.default = string.sub(line, start_char, end_char) +      if not new_name then npcall(vim.ui.input, opts, on_confirm) end      else        -- fallback to guessing symbol using <cword>        -- @@ -270,13 +283,12 @@ function M.rename(new_name)        -- 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>')) +      opts.default = vfn.expand('<cword>') +      if not new_name then npcall(vim.ui.input, opts, on_confirm) end      end -    if not (new_name and #new_name > 0) then return end -    params.newName = new_name -    request('textDocument/rename', params) +    if new_name then on_confirm(new_name) end    end -  request('textDocument/prepareRename', params, prepare_rename) +  request('textDocument/prepareRename', util.make_position_params(), prepare_rename)  end  --- Lists all the references to the symbol under the cursor in the quickfix window. @@ -357,7 +369,7 @@ end  function M.list_workspace_folders()    local workspace_folders = {}    for _, client in pairs(vim.lsp.buf_get_clients()) do -    for _, folder in pairs(client.workspaceFolders) do +    for _, folder in pairs(client.workspaceFolders or {}) do        table.insert(workspace_folders, folder.name)      end    end @@ -377,7 +389,7 @@ function M.add_workspace_folder(workspace_folder)    local params = util.make_workspace_params({{uri = vim.uri_from_fname(workspace_folder); name = workspace_folder}}, {{}})    for _, client in pairs(vim.lsp.buf_get_clients()) do      local found = false -    for _, folder in pairs(client.workspaceFolders) do +    for _, folder in pairs(client.workspaceFolders or {}) do        if folder.name == workspace_folder then          found = true          print(workspace_folder, "is already part of this workspace") @@ -386,6 +398,9 @@ function M.add_workspace_folder(workspace_folder)      end      if not found then        vim.lsp.buf_notify(0, 'workspace/didChangeWorkspaceFolders', params) +      if not client.workspaceFolders then +        client.workspaceFolders = {} +      end        table.insert(client.workspaceFolders, params.event.added[1])      end    end @@ -480,11 +495,11 @@ local function on_code_action_results(results, ctx)      end      if action.command then        local command = type(action.command) == 'table' and action.command or action -      local fn = vim.lsp.commands[command.command] +      local fn = client.commands[command.command] or vim.lsp.commands[command.command]        if fn then          local enriched_ctx = vim.deepcopy(ctx)          enriched_ctx.client_id = client.id -        fn(command, ctx) +        fn(command, enriched_ctx)        else          M.execute_command(command)        end @@ -529,6 +544,7 @@ local function on_code_action_results(results, ctx)    vim.ui.select(action_tuples, {      prompt = 'Code actions:', +    kind = 'codeaction',      format_item = function(action_tuple)        local title = action_tuple[2].title:gsub('\r\n', '\\r\\n')        return title:gsub('\n', '\\n') diff --git a/runtime/lua/vim/lsp/codelens.lua b/runtime/lua/vim/lsp/codelens.lua index 63fcbe430b..9eb64c9a2e 100644 --- a/runtime/lua/vim/lsp/codelens.lua +++ b/runtime/lua/vim/lsp/codelens.lua @@ -31,15 +31,15 @@ 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 client = vim.lsp.get_client_by_id(client_id) +  assert(client, 'Client is required to execute lens, client_id=' .. client_id)    local command = lens.command -  local fn = vim.lsp.commands[command.command] +  local fn = client.commands[command.command] or 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)    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 diff --git a/runtime/lua/vim/lsp/diagnostic.lua b/runtime/lua/vim/lsp/diagnostic.lua index 0e63c0dd29..1e6f83c1ba 100644 --- a/runtime/lua/vim/lsp/diagnostic.lua +++ b/runtime/lua/vim/lsp/diagnostic.lua @@ -146,7 +146,8 @@ local _client_namespaces = {}  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) +    local client = vim.lsp.get_client_by_id(client_id) +    local name = string.format("vim.lsp.%s.%d", client and client.name or "unknown", client_id)      _client_namespaces[client_id] = vim.api.nvim_create_namespace(name)    end    return _client_namespaces[client_id] @@ -551,14 +552,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} @@ -573,11 +575,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 eff27807be..a561630c2b 100644 --- a/runtime/lua/vim/lsp/handlers.lua +++ b/runtime/lua/vim/lsp/handlers.lua @@ -28,6 +28,7 @@ local function progress_handler(_, result, ctx, _)    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") +    return    end    local val = result.value    -- unspecified yet    local token = result.token  -- string or number @@ -185,7 +186,7 @@ local function response_to_list(map_result, entity)            title = 'Language Server';            items = map_result(result, ctx.bufnr);          }) -        api.nvim_command("copen") +        api.nvim_command("botright copen")        end      end    end @@ -349,7 +350,7 @@ M['textDocument/signatureHelp'] = M.signature_help  --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(ctx.bufnr, result) +  util.buf_highlight_references(ctx.bufnr, result, ctx.client_id)  end  ---@private diff --git a/runtime/lua/vim/lsp/rpc.lua b/runtime/lua/vim/lsp/rpc.lua index d9a684a738..bce1e9f35d 100644 --- a/runtime/lua/vim/lsp/rpc.lua +++ b/runtime/lua/vim/lsp/rpc.lua @@ -297,6 +297,7 @@ local function start(cmd, cmd_args, dispatchers, extra_spawn_params)    local message_index = 0    local message_callbacks = {} +  local notify_reply_callbacks = {}    local handle, pid    do @@ -309,8 +310,9 @@ local function start(cmd, cmd_args, dispatchers, extra_spawn_params)        stdout:close()        stderr:close()        handle:close() -      -- Make sure that message_callbacks can be gc'd. +      -- Make sure that message_callbacks/notify_reply_callbacks can be gc'd.        message_callbacks = nil +      notify_reply_callbacks = nil        dispatchers.on_exit(code, signal)      end      local spawn_params = { @@ -375,10 +377,12 @@ local function start(cmd, cmd_args, dispatchers, extra_spawn_params)    ---@param method (string) The invoked LSP method    ---@param params (table) Parameters for the invoked LSP method    ---@param callback (function) Callback to invoke +  ---@param notify_reply_callback (function) Callback to invoke as soon as a request is no longer pending    ---@returns (bool, number) `(true, message_id)` if request could be sent, `false` if not -  local function request(method, params, callback) +  local function request(method, params, callback, notify_reply_callback)      validate {        callback = { callback, 'f' }; +      notify_reply_callback = { notify_reply_callback, 'f', true };      }      message_index = message_index + 1      local message_id = message_index @@ -388,8 +392,15 @@ local function start(cmd, cmd_args, dispatchers, extra_spawn_params)        method = method;        params = params;      } -    if result and message_callbacks then -      message_callbacks[message_id] = schedule_wrap(callback) +    if result then +      if message_callbacks then +        message_callbacks[message_id] = schedule_wrap(callback) +      else +        return false +      end +      if notify_reply_callback and notify_reply_callbacks then +        notify_reply_callbacks[message_id] = schedule_wrap(notify_reply_callback) +      end        return result, message_id      else        return false @@ -466,6 +477,16 @@ local function start(cmd, cmd_args, dispatchers, extra_spawn_params)        -- We sent a number, so we expect a number.        local result_id = tonumber(decoded.id) +      -- Notify the user that a response was received for the request +      local notify_reply_callback = notify_reply_callbacks and notify_reply_callbacks[result_id] +      if notify_reply_callback then +        validate { +          notify_reply_callback = { notify_reply_callback, 'f' }; +        } +        notify_reply_callback(result_id) +        notify_reply_callbacks[result_id] = nil +      end +        -- Do not surface RequestCancelled to users, it is RPC-internal.        if decoded.error then          local mute_error = false diff --git a/runtime/lua/vim/lsp/sync.lua b/runtime/lua/vim/lsp/sync.lua new file mode 100644 index 0000000000..37247c61b9 --- /dev/null +++ b/runtime/lua/vim/lsp/sync.lua @@ -0,0 +1,381 @@ +-- Notes on incremental sync: +--  Per the protocol, the text range should be: +-- +--  A position inside a document (see Position definition below) is expressed as +--  a zero-based line and character offset. The offsets are based on a UTF-16 +--  string representation. So a string of the form a𐐀b the character offset +--  of the character a is 0, the character offset of 𐐀 is 1 and the character +--  offset of b is 3 since 𐐀 is represented using two code units in UTF-16. +-- +--  To ensure that both client and server split the string into the same line +--  representation the protocol specifies the following end-of-line sequences: ‘\n’, ‘\r\n’ and ‘\r’. +-- +--  Positions are line end character agnostic. So you can not specify a position that +--  denotes \r|\n or \n| where | represents the character offset. This means *no* defining +--  a range than ends on the same line after a terminating character +-- +-- Generic warnings about byte level changes in neovim. Many apparently "single" +-- operations in on_lines callbacks are actually multiple operations. +-- +--  Join operation (2 operations): +--  * extends line 1 with the contents of line 2 +--  * deletes line 2 +-- +--  test 1    test 1 test 2    test 1 test 2 +--  test 2 -> test 2        -> test 3 +--  test 3    test 3 +-- +--  Deleting (and undoing) two middle lines (1 operation): +-- +--  test 1    test 1 +--  test 2 -> test 4 +--  test 3 +--  test 4 +-- +--  Deleting partial lines (5 operations) deleting between asterisks below: +-- +--  test *1   test *    test *     test *    test *4    test *4* +--  test 2 -> test 2 -> test *4 -> *4     -> *4      -> +--  test 3    test 3 +--  test *4   test 4 + +local M = {} + +-- local string.byte, unclear if this is necessary for JIT compilation +local str_byte = string.byte +local min = math.min +local str_utfindex = vim.str_utfindex +local str_utf_start = vim.str_utf_start +local str_utf_end = vim.str_utf_end + +---@private +-- Given a line, byte idx, and offset_encoding convert to the +-- utf-8, utf-16, or utf-32 index. +---@param line string the line to index into +---@param byte integer the byte idx +---@param offset_encoding string utf-8|utf-16|utf-32|nil (default: utf-8) +--@returns integer the utf idx for the given encoding +local function byte_to_utf(line, byte, offset_encoding) +  -- convert to 0 based indexing for str_utfindex +  byte = byte - 1 + +  local utf_idx +  local _ +  -- Convert the byte range to utf-{8,16,32} and convert 1-based (lua) indexing to 0-based +  if offset_encoding == 'utf-16' then +    _, utf_idx = str_utfindex(line, byte) +  elseif offset_encoding == 'utf-32' then +    utf_idx, _ = str_utfindex(line, byte) +  else +    utf_idx = byte +  end + +  -- convert to 1 based indexing +  return utf_idx + 1 +end + +---@private +-- Given a line, byte idx, alignment, and offset_encoding convert to the aligned +-- utf-8 index and either the utf-16, or utf-32 index. +---@param line string the line to index into +---@param byte integer the byte idx +---@param align string when dealing with multibyte characters, +--        to choose the start of the current character or the beginning of the next. +--        Used for incremental sync for start/end range respectively +---@param offset_encoding string utf-8|utf-16|utf-32|nil (default: utf-8) +---@returns table<string, int> byte_idx and char_idx of first change position +local function align_position(line, byte, align, offset_encoding) +  local char +  -- If on the first byte, or an empty string: the trivial case +  if byte == 1 or #line == 0 then +    char = byte +  -- Called in the case of extending an empty line "" -> "a" +  elseif byte == #line + 1 then +    byte = byte + str_utf_end(line, #line) +    char = byte_to_utf(line, byte, offset_encoding) +  else +    -- Modifying line, find the nearest utf codepoint +    if align == 'start' then +      byte = byte + str_utf_start(line, byte) +      char = byte_to_utf(line, byte, offset_encoding) +    elseif align == 'end' then +      local offset = str_utf_end(line, byte) +      -- If the byte does not fall on the start of the character, then +      -- align to the start of the next character. +      if offset > 0 then +        char = byte_to_utf(line, byte, offset_encoding) + 1 +        byte = byte + offset +      else +        char = byte_to_utf(line, byte, offset_encoding) +        byte = byte + offset +      end +    else +      error('`align` must be start or end.') +    end +    -- Extending line, find the nearest utf codepoint for the last valid character +  end +  return byte, char +end + +---@private +--- Finds the first line, byte, and char index of the difference between the previous and current lines buffer normalized to the previous codepoint. +---@param prev_lines table list of lines from previous buffer +---@param curr_lines table list of lines from current buffer +---@param firstline integer firstline from on_lines, adjusted to 1-index +---@param lastline integer lastline from on_lines, adjusted to 1-index +---@param new_lastline integer new_lastline from on_lines, adjusted to 1-index +---@param offset_encoding string utf-8|utf-16|utf-32|nil (fallback to utf-8) +---@returns table<int, int> line_idx, byte_idx, and char_idx of first change position +local function compute_start_range(prev_lines, curr_lines, firstline, lastline, new_lastline, offset_encoding) +  -- If firstline == lastline, no existing text is changed. All edit operations +  -- occur on a new line pointed to by lastline. This occurs during insertion of +  -- new lines(O), the new newline is inserted at the line indicated by +  -- new_lastline. +  -- If firstline == new_lastline, the first change occured on a line that was deleted. +  -- In this case, the first byte change is also at the first byte of firstline +  if firstline == new_lastline or firstline == lastline then +    return { line_idx = firstline, byte_idx = 1, char_idx = 1 } +  end + +  local prev_line = prev_lines[firstline] +  local curr_line = curr_lines[firstline] + +  -- Iterate across previous and current line containing first change +  -- to find the first different byte. +  -- Note: *about -> a*about will register the second a as the first +  -- difference, regardless of edit since we do not receive the first +  -- column of the edit from on_lines. +  local start_byte_idx = 1 +  for idx = 1, #prev_line + 1 do +    start_byte_idx = idx +    if str_byte(prev_line, idx) ~= str_byte(curr_line, idx) then +      break +    end +  end + +  -- Convert byte to codepoint if applicable +  local byte_idx, char_idx = align_position(prev_line, start_byte_idx, 'start', offset_encoding) + +  -- Return the start difference (shared for new and prev lines) +  return { line_idx = firstline, byte_idx = byte_idx, char_idx = char_idx } +end + +---@private +--- Finds the last line and byte index of the differences between prev and current buffer. +--- Normalized to the next codepoint. +--- prev_end_range is the text range sent to the server representing the changed region. +--- curr_end_range is the text that should be collected and sent to the server. +-- +---@param prev_lines table list of lines +---@param curr_lines table list of lines +---@param start_range table +---@param lastline integer +---@param new_lastline integer +---@param offset_encoding string +---@returns (int, int) end_line_idx and end_col_idx of range +local function compute_end_range(prev_lines, curr_lines, start_range, firstline, lastline, new_lastline, offset_encoding) +  -- If firstline == new_lastline, the first change occured on a line that was deleted. +  -- In this case, the last_byte... +  if firstline == new_lastline then +      return { line_idx = (lastline - new_lastline + firstline), byte_idx = 1, char_idx = 1 }, { line_idx = firstline, byte_idx = 1, char_idx = 1 } +  end +  if firstline == lastline then +      return { line_idx = firstline, byte_idx = 1, char_idx = 1 }, { line_idx = new_lastline - lastline + firstline, byte_idx = 1, char_idx = 1 } +  end +  -- Compare on last line, at minimum will be the start range +  local start_line_idx = start_range.line_idx + +  -- lastline and new_lastline were last lines that were *not* replaced, compare previous lines +  local prev_line_idx = lastline - 1 +  local curr_line_idx = new_lastline - 1 + +  local prev_line = prev_lines[lastline - 1] +  local curr_line = curr_lines[new_lastline - 1] + +  local prev_line_length = #prev_line +  local curr_line_length = #curr_line + +  local byte_offset = 0 + +  -- Editing the same line +  -- If the byte offset is zero, that means there is a difference on the last byte (not newline) +  if prev_line_idx == curr_line_idx then +    local max_length +    if start_line_idx == prev_line_idx then +      -- Search until beginning of difference +      max_length = min(prev_line_length - start_range.byte_idx, curr_line_length - start_range.byte_idx) + 1 +    else +      max_length = min(prev_line_length, curr_line_length) + 1 +    end +    for idx = 0, max_length do +      byte_offset = idx +      if +        str_byte(prev_line, prev_line_length - byte_offset) ~= str_byte(curr_line, curr_line_length - byte_offset) +      then +        break +      end +    end +  end + +  -- Iterate from end to beginning of shortest line +  local prev_end_byte_idx = prev_line_length - byte_offset + 1 +  local prev_byte_idx, prev_char_idx = align_position(prev_line, prev_end_byte_idx, 'start', offset_encoding) +  local prev_end_range = { line_idx = prev_line_idx, byte_idx = prev_byte_idx, char_idx = prev_char_idx } + +  local curr_end_range +  -- Deletion event, new_range cannot be before start +  if curr_line_idx < start_line_idx then +    curr_end_range = { line_idx = start_line_idx, byte_idx = 1, char_idx = 1 } +  else +    local curr_end_byte_idx = curr_line_length - byte_offset + 1 +    local curr_byte_idx, curr_char_idx = align_position(curr_line, curr_end_byte_idx, 'start', offset_encoding) +    curr_end_range = { line_idx = curr_line_idx, byte_idx = curr_byte_idx, char_idx = curr_char_idx } +  end + +  return prev_end_range, curr_end_range +end + +---@private +--- Get the text of the range defined by start and end line/column +---@param lines table list of lines +---@param start_range table table returned by first_difference +---@param end_range table new_end_range returned by last_difference +---@returns string text extracted from defined region +local function extract_text(lines, start_range, end_range, line_ending) +    if not lines[start_range.line_idx] then +      return "" +    end +  -- Trivial case: start and end range are the same line, directly grab changed text +  if start_range.line_idx == end_range.line_idx then +    -- string.sub is inclusive, end_range is not +    return string.sub(lines[start_range.line_idx], start_range.byte_idx, end_range.byte_idx - 1) + +  else +    -- Handle deletion case +    -- Collect the changed portion of the first changed line +    local result = { string.sub(lines[start_range.line_idx], start_range.byte_idx) } + +    -- Collect the full line for intermediate lines +    for idx = start_range.line_idx + 1, end_range.line_idx - 1 do +      table.insert(result, lines[idx]) +    end + +    if lines[end_range.line_idx] then +      -- Collect the changed portion of the last changed line. +      table.insert(result, string.sub(lines[end_range.line_idx], 1, end_range.byte_idx - 1)) +    else +      table.insert(result, "") +    end + +    -- Add line ending between all lines +    return table.concat(result, line_ending) +  end +end + +local function compute_line_length(line, offset_encoding) +  local length +  local _ +  if offset_encoding == 'utf-16' then +     _, length = str_utfindex(line) +  elseif offset_encoding == 'utf-32' then +    length, _ = str_utfindex(line) +  else +    length = #line +  end +  return length +end +---@private +-- rangelength depends on the offset encoding +-- bytes for utf-8 (clangd with extenion) +-- codepoints for utf-16 +-- codeunits for utf-32 +-- Line endings count here as 2 chars for \r\n (dos), 1 char for \n (unix), and 1 char for \r (mac) +-- These correspond to Windows, Linux/macOS (OSX and newer), and macOS (version 9 and prior) +local function compute_range_length(lines, start_range, end_range, offset_encoding, line_ending) +  local line_ending_length = #line_ending +  -- Single line case +  if start_range.line_idx == end_range.line_idx then +    return end_range.char_idx - start_range.char_idx +  end + +  local start_line = lines[start_range.line_idx] +  local range_length +  if start_line and #start_line > 0 then +    range_length = compute_line_length(start_line, offset_encoding) - start_range.char_idx + 1 + line_ending_length +  else +    -- Length of newline character +    range_length = line_ending_length +  end + +  -- The first and last range of the line idx may be partial lines +  for idx = start_range.line_idx + 1, end_range.line_idx - 1 do +    -- Length full line plus newline character +    if #lines[idx] > 0 then +      range_length = range_length + compute_line_length(lines[idx], offset_encoding) + #line_ending +    else +      range_length = range_length + line_ending_length +    end +  end + +  local end_line = lines[end_range.line_idx] +  if end_line and #end_line > 0 then +    range_length = range_length + end_range.char_idx - 1 +  end + +  return range_length +end + +--- Returns the range table for the difference between prev and curr lines +---@param prev_lines table list of lines +---@param curr_lines table list of lines +---@param firstline number line to begin search for first difference +---@param lastline number line to begin search in old_lines for last difference +---@param new_lastline number line to begin search in new_lines for last difference +---@param offset_encoding string encoding requested by language server +---@returns table TextDocumentContentChangeEvent see https://microsoft.github.io/language-server-protocol/specifications/specification-3-17/#textDocumentContentChangeEvent +function M.compute_diff(prev_lines, curr_lines, firstline, lastline, new_lastline, offset_encoding, line_ending) +  -- Find the start of changes between the previous and current buffer. Common between both. +  -- Sent to the server as the start of the changed range. +  -- Used to grab the changed text from the latest buffer. +  local start_range = compute_start_range( +    prev_lines, +    curr_lines, +    firstline + 1, +    lastline + 1, +    new_lastline + 1, +    offset_encoding +  ) +  -- Find the last position changed in the previous and current buffer. +  -- prev_end_range is sent to the server as as the end of the changed range. +  -- curr_end_range is used to grab the changed text from the latest buffer. +  local prev_end_range, curr_end_range = compute_end_range( +    prev_lines, +    curr_lines, +    start_range, +    firstline + 1, +    lastline + 1, +    new_lastline + 1, +    offset_encoding +  ) + +  -- Grab the changed text of from start_range to curr_end_range in the current buffer. +  -- The text range is "" if entire range is deleted. +  local text = extract_text(curr_lines, start_range, curr_end_range, line_ending) + +  -- Compute the range of the replaced text. Deprecated but still required for certain language servers +  local range_length = compute_range_length(prev_lines, start_range, prev_end_range, offset_encoding, line_ending) + +  -- convert to 0 based indexing +  local result = { +    range = { +      ['start'] = { line = start_range.line_idx - 1, character = start_range.char_idx - 1 }, +      ['end'] = { line = prev_end_range.line_idx - 1, character = prev_end_range.char_idx - 1 }, +    }, +    text = text, +    rangeLength = range_length, +  } + +  return result +end + +return M diff --git a/runtime/lua/vim/lsp/util.lua b/runtime/lua/vim/lsp/util.lua index 952926b67e..a4b7b9922b 100644 --- a/runtime/lua/vim/lsp/util.lua +++ b/runtime/lua/vim/lsp/util.lua @@ -151,7 +151,7 @@ end  --- Position is a https://microsoft.github.io/language-server-protocol/specifications/specification-current/#position  --- Returns a zero-indexed column, since set_lines() does the conversion to  --- 1-indexed -local function get_line_byte_from_position(bufnr, position) +local function get_line_byte_from_position(bufnr, position, offset_encoding)    -- LSP's line and characters are 0-indexed    -- Vim's line and columns are 1-indexed    local col = position.character @@ -165,7 +165,13 @@ local function get_line_byte_from_position(bufnr, position)      local line = position.line      local lines = api.nvim_buf_get_lines(bufnr, line, line + 1, false)      if #lines > 0 then -      local ok, result = pcall(vim.str_byteindex, lines[1], col) +      local ok, result + +      if offset_encoding == "utf-16" or not offset_encoding then +        ok, result = pcall(vim.str_byteindex, lines[1], col, true) +      elseif offset_encoding == "utf-32" then +        ok, result = pcall(vim.str_byteindex, lines[1], col, false) +      end        if ok then          return result @@ -226,9 +232,10 @@ function M.get_progress_messages()        table.remove(client.messages, item.idx)      end -    for _, item in ipairs(progress_remove) do -      client.messages.progress[item.token] = nil -    end +  end + +  for _, item in ipairs(progress_remove) do +    item.client.messages.progress[item.token] = nil    end    return new_messages @@ -275,7 +282,8 @@ function M.apply_text_edits(text_edits, bufnr)    -- 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 '') +  -- TODO handle offset_encoding +  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 @@ -359,177 +367,6 @@ end  -- function M.glob_to_regex(glob)  -- end ----@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 -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 -  if not start_line_idx then -    for i = 1, line_count do -      start_line_idx = i -      if old_lines[start_line_idx] ~= new_lines[start_line_idx] then -        break -      end -    end -  end -  local old_line = old_lines[start_line_idx] -  local new_line = new_lines[start_line_idx] -  local length = math.min(#old_line, #new_line) -  local start_col_idx = 1 -  while start_col_idx <= length do -    if string.sub(old_line, start_col_idx, start_col_idx) ~= string.sub(new_line, start_col_idx, start_col_idx) then -      break -    end -    start_col_idx  = start_col_idx  + 1 -  end -  return start_line_idx, start_col_idx -end - - ----@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 -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 -  if not end_line_idx then -    end_line_idx = -1 -  end -  for i = end_line_idx, -line_count, -1  do -    if old_lines[#old_lines + i + 1] ~= new_lines[#new_lines + i + 1] then -      end_line_idx = i -      break -    end -  end -  local old_line -  local new_line -  if end_line_idx <= -line_count then -    end_line_idx = -line_count -    old_line  = string.sub(old_lines[#old_lines + end_line_idx + 1], start_char) -    new_line  = string.sub(new_lines[#new_lines + end_line_idx + 1], start_char) -  else -    old_line  = old_lines[#old_lines + end_line_idx + 1] -    new_line  = new_lines[#new_lines + end_line_idx + 1] -  end -  local old_line_length = #old_line -  local new_line_length = #new_line -  local length = math.min(old_line_length, new_line_length) -  local end_col_idx = -1 -  while end_col_idx >= -length do -    local old_char =  string.sub(old_line, old_line_length + end_col_idx + 1, old_line_length + end_col_idx + 1) -    local new_char =  string.sub(new_line, new_line_length + end_col_idx + 1, new_line_length + end_col_idx + 1) -    if old_char ~= new_char then -      break -    end -    end_col_idx = end_col_idx - 1 -  end -  return end_line_idx, end_col_idx - -end - ----@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 -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 -    local line = lines[start_line] -    local length = #line + end_char - start_char -    return string.sub(line, start_char, start_char + length + 1) -  end -  local result = string.sub(lines[start_line], start_char) .. '\n' -  for line_idx = start_line + 1, #lines + end_line do -    result = result .. lines[line_idx] .. '\n' -  end -  if end_line ~= 0 then -    local line = lines[#lines + end_line + 1] -    local length = #line + end_char + 1 -    result = result .. string.sub(line, 1, length) -  end -  return result -end - ----@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 -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 -  if adj_end_line > #lines then -    adj_end_char =  end_char - 1 -  else -    adj_end_char = #lines[adj_end_line] + end_char -  end -  if start_line == adj_end_line then -    return adj_end_char - start_char + 1 -  end -  local result = #lines[start_line] - start_char + 1 -  for line = start_line + 1, adj_end_line -1 do -    result = result + #lines[line] + 1 -  end -  result = result + adj_end_char + 1 -  return result -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 -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), -      vim.list_slice(new_lines, start_line, #new_lines), start_char, end_line_idx) -  local text = extract_text(new_lines, start_line, start_char, end_line, end_char) -  local length = compute_length(old_lines, start_line, start_char, end_line, end_char) - -  local adj_end_line = #old_lines + end_line -  local adj_end_char -  if end_line == 0 then -    adj_end_char = 0 -  else -    adj_end_char = #old_lines[#old_lines + end_line + 1] + end_char + 1 -  end - -  local _ -  if offset_encoding == "utf-16" then -    _, start_char = vim.str_utfindex(old_lines[start_line], start_char - 1) -    _, end_char = vim.str_utfindex(old_lines[#old_lines + end_line + 1], adj_end_char) -  else -    start_char = start_char - 1 -    end_char = adj_end_char -  end - -  local result = { -    range = { -      start = { line = start_line - 1, character = start_char}, -      ["end"] = { line = adj_end_line, character = end_char} -    }, -    text = text, -    rangeLength = length + 1, -  } - -  return result -end -  --- Can be used to extract the completion items from a  --- `textDocument/completion` request, which may return one of  --- `CompletionItem[]`, `CompletionList` or null. @@ -712,18 +549,29 @@ end  --         ignoreIfExists? bool  function M.rename(old_fname, new_fname, opts)    opts = opts or {} -  local bufnr = vim.fn.bufadd(old_fname) -  vim.fn.bufload(bufnr)    local target_exists = vim.loop.fs_stat(new_fname) ~= nil    if target_exists and not opts.overwrite or opts.ignoreIfExists then      vim.notify('Rename target already exists. Skipping rename.')      return    end +  local oldbuf = vim.fn.bufadd(old_fname) +  vim.fn.bufload(oldbuf) + +  -- The there may be pending changes in the buffer +  api.nvim_buf_call(oldbuf, function() +    vim.cmd('w!') +  end) +    local ok, err = os.rename(old_fname, new_fname)    assert(ok, err) -  api.nvim_buf_call(bufnr, function() -    vim.cmd('saveas! ' .. vim.fn.fnameescape(new_fname)) -  end) + +  local newbuf = vim.fn.bufadd(new_fname) +  for _, win in pairs(api.nvim_list_wins()) do +    if api.nvim_win_get_buf(win) == oldbuf then +      api.nvim_win_set_buf(win, newbuf) +    end +  end +  api.nvim_buf_delete(oldbuf, { force = true })  end @@ -1494,18 +1342,30 @@ do --[[ References ]]    ---@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) +  function M.buf_highlight_references(bufnr, references, client_id)      validate { bufnr = {bufnr, 'n', true} } +    local client = vim.lsp.get_client_by_id(client_id) +    if not client then +      return +    end      for _, reference in ipairs(references) do -      local start_pos = {reference["range"]["start"]["line"], reference["range"]["start"]["character"]} -      local end_pos = {reference["range"]["end"]["line"], reference["range"]["end"]["character"]} +      local start_line, start_char = reference["range"]["start"]["line"], reference["range"]["start"]["character"] +      local end_line, end_char = reference["range"]["end"]["line"], reference["range"]["end"]["character"] + +      local start_idx = get_line_byte_from_position(bufnr, { line = start_line, character = start_char }, client.offset_encoding) +      local end_idx = get_line_byte_from_position(bufnr, { line = start_line, character = end_char }, client.offset_encoding) +        local document_highlight_kind = {          [protocol.DocumentHighlightKind.Text] = "LspReferenceText";          [protocol.DocumentHighlightKind.Read] = "LspReferenceRead";          [protocol.DocumentHighlightKind.Write] = "LspReferenceWrite";        }        local kind = reference["kind"] or protocol.DocumentHighlightKind.Text -      highlight.range(bufnr, reference_ns, document_highlight_kind[kind], start_pos, end_pos) +      highlight.range(bufnr, +                      reference_ns, +                      document_highlight_kind[kind], +                      { start_line, start_idx }, +                      { end_line, end_idx })      end    end  end @@ -1719,7 +1579,9 @@ function M.symbols_to_items(symbols, bufnr)          })          if symbol.children then            for _, v in ipairs(_symbols_to_items(symbol.children, _items, _bufnr)) do -            vim.list_extend(_items, v) +            for _, s in ipairs(v) do +              table.insert(_items, s) +            end            end          end        end @@ -1787,7 +1649,9 @@ local function make_position_param()    if not line then      return { line = 0; character = 0; }    end -  col = str_utfindex(line, col) +  -- TODO handle offset_encoding +  local _ +  _, col = str_utfindex(line, col)    return { line = row; character = col; }  end @@ -1837,11 +1701,14 @@ function M.make_given_range_params(start_pos, end_pos)    A[1] = A[1] - 1    B[1] = B[1] - 1    -- account for encoding. +  -- TODO handle offset_encoding    if A[2] > 0 then -    A = {A[1], M.character_offset(0, A[1], A[2])} +    local _, char = M.character_offset(0, A[1], A[2]) +    A = {A[1], char}    end    if B[2] > 0 then -    B = {B[1], M.character_offset(0, B[1], B[2])} +    local _, char = M.character_offset(0, B[1], B[2]) +    B = {B[1], char}    end    -- we need to offset the end character position otherwise we loose the last    -- character of the selection, as LSP end position is exclusive diff --git a/runtime/lua/vim/shared.lua b/runtime/lua/vim/shared.lua index b57b7ad4ad..6e40b6ca52 100644 --- a/runtime/lua/vim/shared.lua +++ b/runtime/lua/vim/shared.lua @@ -605,7 +605,7 @@ do    function vim.validate(opt)      local ok, err_msg = is_valid(opt)      if not ok then -      error(debug.traceback(err_msg, 2), 2) +      error(err_msg, 2)      end    end  end diff --git a/runtime/lua/vim/ui.lua b/runtime/lua/vim/ui.lua index 5eab20fc54..9568b60fd0 100644 --- a/runtime/lua/vim/ui.lua +++ b/runtime/lua/vim/ui.lua @@ -9,6 +9,11 @@ local M = {}  ---     - format_item (function item -> text)  ---               Function to format an  ---               individual item from `items`. Defaults to `tostring`. +---     - kind (string|nil) +---               Arbitrary hint string indicating the item shape. +---               Plugins reimplementing `vim.ui.select` may wish to +---               use this to infer the structure or semantics of +---               `items`, or the context in which select() was called.  ---@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`. @@ -32,5 +37,38 @@ function M.select(items, opts, on_choice)    end  end +--- Prompts the user for input +--- +---@param opts table Additional options. See |input()| +---     - prompt (string|nil) +---               Text of the prompt. Defaults to `Input: `. +---     - default (string|nil) +---               Default reply to the input +---     - completion (string|nil) +---               Specifies type of completion supported +---               for input. Supported types are the same +---               that can be supplied to a user-defined +---               command using the "-complete=" argument. +---               See |:command-completion| +---     - highlight (function) +---               Function that will be used for highlighting +---               user inputs. +---@param on_confirm function ((input|nil) -> ()) +---               Called once the user confirms or abort the input. +---               `input` is what the user typed. +---               `nil` if the user aborted the dialog. +function M.input(opts, on_confirm) +  vim.validate { +    on_confirm = { on_confirm, 'function', false }, +  } + +  opts = opts or {} +  local input = vim.fn.input(opts) +  if #input > 0 then +    on_confirm(input) +  else +    on_confirm(nil) +  end +end  return M  | 
