diff options
Diffstat (limited to 'runtime/lua/vim/lsp')
-rw-r--r-- | runtime/lua/vim/lsp/buf.lua | 316 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/codelens.lua | 27 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/diagnostic.lua | 2 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/handlers.lua | 145 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/health.lua | 16 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/log.lua | 17 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/protocol.lua | 97 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/rpc.lua | 741 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/semantic_tokens.lua | 702 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/sync.lua | 2 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/util.lua | 207 |
11 files changed, 1602 insertions, 670 deletions
diff --git a/runtime/lua/vim/lsp/buf.lua b/runtime/lua/vim/lsp/buf.lua index 6a070928d9..6ac885c78f 100644 --- a/runtime/lua/vim/lsp/buf.lua +++ b/runtime/lua/vim/lsp/buf.lua @@ -1,4 +1,3 @@ -local vim = vim local api = vim.api local validate = vim.validate local util = require('vim.lsp.util') @@ -111,7 +110,7 @@ end --- about the context in which a completion was triggered (how it was triggered, --- and by which trigger character, if applicable) --- ----@see |vim.lsp.protocol.constants.CompletionTriggerKind| +---@see vim.lsp.protocol.constants.CompletionTriggerKind function M.completion(context) local params = util.make_position_params() params.context = context @@ -119,35 +118,30 @@ function M.completion(context) end ---@private ---- If there is more than one client that supports the given method, ---- asks the user to select one. --- ----@returns The client that the user selected or nil -local function select_client(method, on_choice) - validate({ - on_choice = { on_choice, 'function', false }, - }) - local clients = vim.tbl_values(vim.lsp.buf_get_clients()) - clients = vim.tbl_filter(function(client) - return client.supports_method(method) - end, clients) - -- better UX when choices are always in the same order (between restarts) - table.sort(clients, function(a, b) - return a.name < b.name - end) - - if #clients > 1 then - vim.ui.select(clients, { - prompt = 'Select a language server:', - format_item = function(client) - return client.name - end, - }, on_choice) - elseif #clients < 1 then - on_choice(nil) - else - on_choice(clients[1]) - end +---@return table {start={row, col}, end={row, col}} using (1, 0) indexing +local function range_from_selection() + -- TODO: Use `vim.region()` instead https://github.com/neovim/neovim/pull/13896 + + -- [bufnum, lnum, col, off]; both row and column 1-indexed + local start = vim.fn.getpos('v') + local end_ = vim.fn.getpos('.') + local start_row = start[2] + local start_col = start[3] + local end_row = end_[2] + local end_col = end_[3] + + -- A user can start visual selection at the end and move backwards + -- Normalize the range to start < end + if start_row == end_row and end_col < start_col then + end_col, start_col = start_col, end_col + elseif end_row < start_row then + start_row, end_row = end_row, start_row + start_col, end_col = end_col, start_col + end + return { + ['start'] = { start_row, start_col - 1 }, + ['end'] = { end_row, end_col - 1 }, + } end --- Formats a buffer using the attached (and optionally filtered) language @@ -168,11 +162,11 @@ end --- Predicate used to filter clients. Receives a client as argument and must return a --- boolean. Clients matching the predicate are included. Example: --- ---- <pre> ---- -- Never request typescript-language-server for formatting ---- vim.lsp.buf.format { ---- filter = function(client) return client.name ~= "tsserver" end ---- } +--- <pre>lua +--- -- Never request typescript-language-server for formatting +--- vim.lsp.buf.format { +--- filter = function(client) return client.name ~= "tsserver" end +--- } --- </pre> --- --- - async boolean|nil @@ -184,7 +178,12 @@ end --- Restrict formatting to the client with ID (client.id) matching this field. --- - name (string|nil): --- Restrict formatting to the client with name (client.name) matching this field. - +--- +--- - range (table|nil) Range to format. +--- Table must contain `start` and `end` keys with {row, col} tuples using +--- (1,0) indexing. +--- Defaults to current selection in visual mode +--- Defaults to `nil` in other modes, formatting the full buffer function M.format(options) options = options or {} local bufnr = options.bufnr or api.nvim_get_current_buf() @@ -198,24 +197,40 @@ function M.format(options) clients = vim.tbl_filter(options.filter, clients) end + local mode = api.nvim_get_mode().mode + local range = options.range + if not range and mode == 'v' or mode == 'V' then + range = range_from_selection() + end + local method = range and 'textDocument/rangeFormatting' or 'textDocument/formatting' + clients = vim.tbl_filter(function(client) - return client.supports_method('textDocument/formatting') + return client.supports_method(method) end, clients) if #clients == 0 then vim.notify('[LSP] Format request failed, no matching language servers.') end + ---@private + local function set_range(client, params) + if range then + local range_params = + util.make_given_range_params(range.start, range['end'], bufnr, client.offset_encoding) + params.range = range_params.range + end + return params + end + if options.async then local do_format do_format = function(idx, client) if not client then return end - local params = util.make_formatting_params(options.formatting_options) - client.request('textDocument/formatting', params, function(...) - local handler = client.handlers['textDocument/formatting'] - or vim.lsp.handlers['textDocument/formatting'] + local params = set_range(client, util.make_formatting_params(options.formatting_options)) + client.request(method, params, function(...) + local handler = client.handlers[method] or vim.lsp.handlers[method] handler(...) do_format(next(clients, idx)) end, bufnr) @@ -224,8 +239,8 @@ function M.format(options) else local timeout_ms = options.timeout_ms or 1000 for _, client in pairs(clients) do - local params = util.make_formatting_params(options.formatting_options) - local result, err = client.request_sync('textDocument/formatting', params, timeout_ms, bufnr) + local params = set_range(client, util.make_formatting_params(options.formatting_options)) + local result, err = client.request_sync(method, params, timeout_ms, bufnr) if result and result.result then util.apply_text_edits(result.result, bufnr, client.offset_encoding) elseif err then @@ -235,138 +250,6 @@ function M.format(options) end end ---- Formats the current buffer. ---- ----@param options (table|nil) Can be used to specify FormattingOptions. ---- Some unspecified options will be automatically derived from the current ---- Neovim options. --- ----@see https://microsoft.github.io/language-server-protocol/specification#textDocument_formatting -function M.formatting(options) - vim.notify_once( - 'vim.lsp.buf.formatting is deprecated. Use vim.lsp.buf.format { async = true } instead', - vim.log.levels.WARN - ) - local params = util.make_formatting_params(options) - local bufnr = api.nvim_get_current_buf() - select_client('textDocument/formatting', function(client) - if client == nil then - return - end - - return client.request('textDocument/formatting', params, nil, bufnr) - end) -end - ---- Performs |vim.lsp.buf.formatting()| synchronously. ---- ---- Useful for running on save, to make sure buffer is formatted prior to being ---- saved. {timeout_ms} is passed on to |vim.lsp.buf_request_sync()|. Example: ---- ---- <pre> ---- autocmd BufWritePre <buffer> lua vim.lsp.buf.formatting_sync() ---- </pre> ---- ----@param options table|nil with valid `FormattingOptions` entries ----@param timeout_ms (number) Request timeout ----@see |vim.lsp.buf.formatting_seq_sync| -function M.formatting_sync(options, timeout_ms) - vim.notify_once( - 'vim.lsp.buf.formatting_sync is deprecated. Use vim.lsp.buf.format instead', - vim.log.levels.WARN - ) - local params = util.make_formatting_params(options) - local bufnr = api.nvim_get_current_buf() - select_client('textDocument/formatting', function(client) - if client == nil then - return - end - - local result, err = client.request_sync('textDocument/formatting', params, timeout_ms, bufnr) - if result and result.result then - util.apply_text_edits(result.result, bufnr, client.offset_encoding) - elseif err then - vim.notify('vim.lsp.buf.formatting_sync: ' .. err, vim.log.levels.WARN) - end - end) -end - ---- Formats the current buffer by sequentially requesting formatting from attached clients. ---- ---- Useful when multiple clients with formatting capability are attached. ---- ---- Since it's synchronous, can be used for running on save, to make sure buffer is formatted ---- prior to being saved. {timeout_ms} is passed on to the |vim.lsp.client| `request_sync` method. ---- Example: ---- <pre> ---- vim.api.nvim_command[[autocmd BufWritePre <buffer> lua vim.lsp.buf.formatting_seq_sync()]] ---- </pre> ---- ----@param options (table|nil) `FormattingOptions` entries ----@param timeout_ms (number|nil) Request timeout ----@param order (table|nil) List of client names. Formatting is requested from clients ----in the following order: first all clients that are not in the `order` list, then ----the remaining clients in the order as they occur in the `order` list. -function M.formatting_seq_sync(options, timeout_ms, order) - vim.notify_once( - 'vim.lsp.buf.formatting_seq_sync is deprecated. Use vim.lsp.buf.format instead', - vim.log.levels.WARN - ) - local clients = vim.tbl_values(vim.lsp.buf_get_clients()) - local bufnr = api.nvim_get_current_buf() - - -- sort the clients according to `order` - for _, client_name in pairs(order or {}) do - -- if the client exists, move to the end of the list - for i, client in pairs(clients) do - if client.name == client_name then - table.insert(clients, table.remove(clients, i)) - break - end - end - end - - -- loop through the clients and make synchronous formatting requests - for _, client in pairs(clients) do - if vim.tbl_get(client.server_capabilities, 'documentFormattingProvider') then - local params = util.make_formatting_params(options) - local result, err = client.request_sync( - 'textDocument/formatting', - params, - timeout_ms, - api.nvim_get_current_buf() - ) - if result and result.result then - util.apply_text_edits(result.result, bufnr, client.offset_encoding) - elseif err then - vim.notify( - string.format('vim.lsp.buf.formatting_seq_sync: (%s) %s', client.name, err), - vim.log.levels.WARN - ) - end - end - end -end - ---- Formats a given range. ---- ----@param options Table with valid `FormattingOptions` entries. ----@param start_pos ({number, number}, optional) mark-indexed position. ----Defaults to the start of the last visual selection. ----@param end_pos ({number, number}, optional) mark-indexed position. ----Defaults to the end of the last visual selection. -function M.range_formatting(options, start_pos, end_pos) - local params = util.make_given_range_params(start_pos, end_pos) - params.options = util.make_formatting_params(options).options - select_client('textDocument/rangeFormatting', function(client) - if client == nil then - return - end - - return client.request('textDocument/rangeFormatting', params) - end) -end - --- Renames all references to the symbol under the cursor. --- ---@param new_name string|nil If not provided, the user will be prompted for a new @@ -500,7 +383,7 @@ end --- Lists all the references to the symbol under the cursor in the quickfix window. --- ----@param context (table) Context for the request +---@param context (table|nil) Context for the request ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_references ---@param options table|nil additional options --- - on_list: (function) handler for list results. See |lsp-on-list-handler| @@ -565,14 +448,14 @@ end --- Lists all the call sites of the symbol under the cursor in the --- |quickfix| window. If the symbol can resolve to multiple ---- items, the user can pick one in the |inputlist|. +--- items, the user can pick one in the |inputlist()|. function M.incoming_calls() call_hierarchy('callHierarchy/incomingCalls') end --- Lists all the items that are called by the symbol under the --- cursor in the |quickfix| window. If the symbol can resolve to ---- multiple items, the user can pick one in the |inputlist|. +--- multiple items, the user can pick one in the |inputlist()|. function M.outgoing_calls() call_hierarchy('callHierarchy/outgoingCalls') end @@ -581,7 +464,7 @@ end --- function M.list_workspace_folders() local workspace_folders = {} - for _, client in pairs(vim.lsp.buf_get_clients()) do + for _, client in pairs(vim.lsp.get_active_clients({ bufnr = 0 })) do for _, folder in pairs(client.workspace_folders or {}) do table.insert(workspace_folders, folder.name) end @@ -604,9 +487,9 @@ function M.add_workspace_folder(workspace_folder) end 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 + for _, client in pairs(vim.lsp.get_active_clients({ bufnr = 0 })) do local found = false for _, folder in pairs(client.workspace_folders or {}) do if folder.name == workspace_folder then @@ -639,7 +522,7 @@ function M.remove_workspace_folder(workspace_folder) { {} }, { { uri = vim.uri_from_fname(workspace_folder), name = workspace_folder } } ) - for _, client in pairs(vim.lsp.buf_get_clients()) do + for _, client in pairs(vim.lsp.get_active_clients({ bufnr = 0 })) do for idx, folder in pairs(client.workspace_folders) do if folder.name == workspace_folder then vim.lsp.buf_notify(0, 'workspace/didChangeWorkspaceFolders', params) @@ -672,18 +555,17 @@ end --- Send request to the server to resolve document highlights for the current --- text document position. This request can be triggered by a key mapping or --- by events such as `CursorHold`, e.g.: ---- ---- <pre> ---- autocmd CursorHold <buffer> lua vim.lsp.buf.document_highlight() ---- autocmd CursorHoldI <buffer> lua vim.lsp.buf.document_highlight() ---- autocmd CursorMoved <buffer> lua vim.lsp.buf.clear_references() +--- <pre>vim +--- autocmd CursorHold <buffer> lua vim.lsp.buf.document_highlight() +--- autocmd CursorHoldI <buffer> lua vim.lsp.buf.document_highlight() +--- autocmd CursorMoved <buffer> lua vim.lsp.buf.clear_references() --- </pre> --- --- Note: Usage of |vim.lsp.buf.document_highlight()| requires the following highlight groups --- to be defined or you won't be able to see the actual highlights. ---- |LspReferenceText| ---- |LspReferenceRead| ---- |LspReferenceWrite| +--- |hl-LspReferenceText| +--- |hl-LspReferenceRead| +--- |hl-LspReferenceWrite| function M.document_highlight() local params = util.make_position_params() request('textDocument/documentHighlight', params) @@ -851,6 +733,7 @@ end --- List of LSP `CodeActionKind`s used to filter the code actions. --- Most language servers support values like `refactor` --- or `quickfix`. +--- - triggerKind (number|nil): The reason why code actions were requested. --- - filter: (function|nil) --- Predicate taking an `CodeAction` and returning a boolean. --- - apply: (boolean|nil) @@ -864,6 +747,7 @@ end --- using mark-like indexing. See |api-indexing| --- ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_codeAction +---@see vim.lsp.protocol.constants.CodeActionTriggerKind function M.code_action(options) validate({ options = { options, 't', true } }) options = options or {} @@ -873,6 +757,9 @@ function M.code_action(options) options = { options = options } end local context = options.context or {} + if not context.triggerKind then + context.triggerKind = vim.lsp.protocol.CodeActionTriggerKind.Invoked + end if not context.diagnostics then local bufnr = api.nvim_get_current_buf() context.diagnostics = vim.lsp.diagnostic.get_line_diagnostics(bufnr) @@ -885,23 +772,8 @@ function M.code_action(options) local end_ = assert(options.range['end'], 'range must have a `end` property') params = util.make_given_range_params(start, end_) elseif mode == 'v' or mode == 'V' then - -- [bufnum, lnum, col, off]; both row and column 1-indexed - local start = vim.fn.getpos('v') - local end_ = vim.fn.getpos('.') - local start_row = start[2] - local start_col = start[3] - local end_row = end_[2] - local end_col = end_[3] - - -- A user can start visual selection at the end and move backwards - -- Normalize the range to start < end - if start_row == end_row and end_col < start_col then - end_col, start_col = start_col, end_col - elseif end_row < start_row then - start_row, end_row = end_row, start_row - start_col, end_col = end_col, start_col - end - params = util.make_given_range_params({ start_row, start_col - 1 }, { end_row, end_col - 1 }) + local range = range_from_selection() + params = util.make_given_range_params(range.start, range['end']) else params = util.make_range_params() end @@ -909,34 +781,6 @@ function M.code_action(options) code_action_request(params, options) end ---- Performs |vim.lsp.buf.code_action()| for a given range. ---- ---- ----@param context table|nil `CodeActionContext` of the LSP specification: ---- - diagnostics: (table|nil) ---- LSP `Diagnostic[]`. Inferred from the current ---- position if not provided. ---- - only: (table|nil) ---- List of LSP `CodeActionKind`s used to filter the code actions. ---- Most language servers support values like `refactor` ---- or `quickfix`. ----@param start_pos ({number, number}, optional) mark-indexed position. ----Defaults to the start of the last visual selection. ----@param end_pos ({number, number}, optional) mark-indexed position. ----Defaults to the end of the last visual selection. -function M.range_code_action(context, start_pos, end_pos) - vim.deprecate('vim.lsp.buf.range_code_action', 'vim.lsp.buf.code_action', '0.9.0') - validate({ context = { context, 't', true } }) - context = context or {} - if not context.diagnostics then - local bufnr = api.nvim_get_current_buf() - context.diagnostics = vim.lsp.diagnostic.get_line_diagnostics(bufnr) - end - local params = util.make_given_range_params(start_pos, end_pos) - params.context = context - code_action_request(params) -end - --- Executes an LSP server command. --- ---@param command_params table A valid `ExecuteCommandParams` object diff --git a/runtime/lua/vim/lsp/codelens.lua b/runtime/lua/vim/lsp/codelens.lua index 4fa02c8db2..17489ed84d 100644 --- a/runtime/lua/vim/lsp/codelens.lua +++ b/runtime/lua/vim/lsp/codelens.lua @@ -108,13 +108,36 @@ function M.run() end end +---@private +local function resolve_bufnr(bufnr) + return bufnr == 0 and api.nvim_get_current_buf() or bufnr +end + +--- Clear the lenses +--- +---@param client_id number|nil filter by client_id. All clients if nil +---@param bufnr number|nil filter by buffer. All buffers if nil +function M.clear(client_id, bufnr) + local buffers = bufnr and { resolve_bufnr(bufnr) } or vim.tbl_keys(lens_cache_by_buf) + for _, iter_bufnr in pairs(buffers) do + local client_ids = client_id and { client_id } or vim.tbl_keys(namespaces) + for _, iter_client_id in pairs(client_ids) do + local ns = namespaces[iter_client_id] + lens_cache_by_buf[iter_bufnr][iter_client_id] = {} + api.nvim_buf_clear_namespace(iter_bufnr, ns, 0, -1) + end + end +end + --- Display the lenses using virtual text --- ---@param lenses table of lenses to display (`CodeLens[] | null`) ---@param bufnr number ---@param client_id number function M.display(lenses, bufnr, client_id) + local ns = namespaces[client_id] if not lenses or not next(lenses) then + api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) return end local lenses_by_lnum = {} @@ -126,7 +149,6 @@ function M.display(lenses, bufnr, client_id) end table.insert(line_lenses, lens) end - local ns = namespaces[client_id] local num_lines = api.nvim_buf_line_count(bufnr) for i = 0, num_lines do local line_lenses = lenses_by_lnum[i] or {} @@ -241,7 +263,8 @@ end --- --- It is recommended to trigger this using an autocmd or via keymap. --- ---- <pre> +--- Example: +--- <pre>vim --- autocmd BufEnter,CursorHold,InsertLeave <buffer> lua vim.lsp.codelens.refresh() --- </pre> --- diff --git a/runtime/lua/vim/lsp/diagnostic.lua b/runtime/lua/vim/lsp/diagnostic.lua index 1f9d084e2b..5e2bf75f1b 100644 --- a/runtime/lua/vim/lsp/diagnostic.lua +++ b/runtime/lua/vim/lsp/diagnostic.lua @@ -150,7 +150,7 @@ end --- --- See |vim.diagnostic.config()| for configuration options. Handler-specific --- configuration can be set using |vim.lsp.with()|: ---- <pre> +--- <pre>lua --- vim.lsp.handlers["textDocument/publishDiagnostics"] = vim.lsp.with( --- vim.lsp.diagnostic.on_publish_diagnostics, { --- -- Enable underline, use default values diff --git a/runtime/lua/vim/lsp/handlers.lua b/runtime/lua/vim/lsp/handlers.lua index 624436bc9b..5096100a60 100644 --- a/runtime/lua/vim/lsp/handlers.lua +++ b/runtime/lua/vim/lsp/handlers.lua @@ -1,7 +1,6 @@ local log = require('vim.lsp.log') local protocol = require('vim.lsp.protocol') local util = require('vim.lsp.util') -local vim = vim local api = vim.api local M = {} @@ -82,22 +81,38 @@ M['window/workDoneProgress/create'] = function(_, result, ctx) end --see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#window_showMessageRequest +---@param result lsp.ShowMessageRequestParams M['window/showMessageRequest'] = function(_, result) - local actions = result.actions - print(result.message) - local option_strings = { result.message, '\nRequest Actions:' } - for i, action in ipairs(actions) do - local title = action.title:gsub('\r\n', '\\r\\n') - title = title:gsub('\n', '\\n') - table.insert(option_strings, string.format('%d. %s', i, title)) - end - - -- window/showMessageRequest can return either MessageActionItem[] or null. - local choice = vim.fn.inputlist(option_strings) - if choice < 1 or choice > #actions then - return vim.NIL + local actions = result.actions or {} + local co, is_main = coroutine.running() + if co and not is_main then + local opts = { + prompt = result.message .. ': ', + format_item = function(action) + return (action.title:gsub('\r\n', '\\r\\n')):gsub('\n', '\\n') + end, + } + vim.ui.select(actions, opts, function(choice) + -- schedule to ensure resume doesn't happen _before_ yield with + -- default synchronous vim.ui.select + vim.schedule(function() + coroutine.resume(co, choice or vim.NIL) + end) + end) + return coroutine.yield() else - return actions[choice] + local option_strings = { result.message, '\nRequest Actions:' } + for i, action in ipairs(actions) do + local title = action.title:gsub('\r\n', '\\r\\n') + title = title:gsub('\n', '\\n') + table.insert(option_strings, string.format('%d. %s', i, title)) + end + local choice = vim.fn.inputlist(option_strings) + if choice < 1 or choice > #actions then + return vim.NIL + else + return actions[choice] + end end end @@ -116,9 +131,10 @@ end --see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#workspace_applyEdit M['workspace/applyEdit'] = function(_, workspace_edit, ctx) - if not workspace_edit then - return - end + assert( + workspace_edit, + 'workspace/applyEdit must be called with `ApplyWorkspaceEditParams`. Server is violating the specification' + ) -- TODO(ashkan) Do something more with label? local client_id = ctx.client_id local client = vim.lsp.get_client_by_id(client_id) @@ -298,13 +314,15 @@ M['textDocument/completion'] = function(_, result, _, _) end --- |lsp-handler| for the method "textDocument/hover" ---- <pre> ---- vim.lsp.handlers["textDocument/hover"] = vim.lsp.with( ---- vim.lsp.handlers.hover, { ---- -- Use a sharp border with `FloatBorder` highlights ---- border = "single" ---- } ---- ) +--- <pre>lua +--- vim.lsp.handlers["textDocument/hover"] = vim.lsp.with( +--- vim.lsp.handlers.hover, { +--- -- Use a sharp border with `FloatBorder` highlights +--- border = "single", +--- -- add the title in hover float window +--- title = "hover" +--- } +--- ) --- </pre> ---@param config table Configuration table. --- - border: (default=nil) @@ -313,8 +331,14 @@ end function M.hover(_, result, ctx, config) config = config or {} config.focus_id = ctx.method + if api.nvim_get_current_buf() ~= ctx.bufnr then + -- Ignore result since buffer changed. This happens for slow language servers. + return + end if not (result and result.contents) then - vim.notify('No information available') + if config.silent ~= true then + vim.notify('No information available') + end return end local markdown_lines = util.convert_input_to_markdown_lines(result.contents) @@ -378,21 +402,25 @@ M['textDocument/implementation'] = location_handler --- |lsp-handler| for the method "textDocument/signatureHelp". --- The active parameter is highlighted with |hl-LspSignatureActiveParameter|. ---- <pre> ---- vim.lsp.handlers["textDocument/signatureHelp"] = vim.lsp.with( ---- vim.lsp.handlers.signature_help, { ---- -- Use a sharp border with `FloatBorder` highlights ---- border = "single" ---- } ---- ) +--- <pre>lua +--- vim.lsp.handlers["textDocument/signatureHelp"] = vim.lsp.with( +--- vim.lsp.handlers.signature_help, { +--- -- Use a sharp border with `FloatBorder` highlights +--- border = "single" +--- } +--- ) --- </pre> ---@param config table Configuration table. --- - border: (default=nil) --- - Add borders to the floating window ---- - See |vim.api.nvim_open_win()| +--- - See |nvim_open_win()| function M.signature_help(_, result, ctx, config) config = config or {} config.focus_id = ctx.method + if api.nvim_get_current_buf() ~= ctx.bufnr then + -- Ignore result since buffer changed. This happens for slow language servers. + return + end -- When use `autocmd CompleteDone <silent><buffer> lua vim.lsp.buf.signature_help()` to call signatureHelp handler -- If the completion item doesn't have signatures It will make noise. Change to use `print` that can use `<silent>` to ignore if not (result and result.signatures and result.signatures[1]) then @@ -512,6 +540,55 @@ M['window/showMessage'] = function(_, result, ctx, _) return result end +--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#window_showDocument +M['window/showDocument'] = function(_, result, ctx, _) + local uri = result.uri + + if result.external then + -- TODO(lvimuser): ask the user for confirmation + local cmd + if vim.fn.has('win32') == 1 then + cmd = { 'cmd.exe', '/c', 'start', '""', uri } + elseif vim.fn.has('macunix') == 1 then + cmd = { 'open', uri } + else + cmd = { 'xdg-open', uri } + end + + local ret = vim.fn.system(cmd) + if vim.v.shell_error ~= 0 then + return { + success = false, + error = { + code = protocol.ErrorCodes.UnknownErrorCode, + message = ret, + }, + } + end + + return { success = true } + end + + local client_id = ctx.client_id + local client = vim.lsp.get_client_by_id(client_id) + local client_name = client and client.name or string.format('id=%d', client_id) + if not client then + err_message({ 'LSP[', client_name, '] client has shut down after sending ', ctx.method }) + return vim.NIL + end + + local location = { + uri = uri, + range = result.selection, + } + + local success = util.show_document(location, client.offset_encoding, { + reuse_win = true, + focus = result.takeFocus, + }) + return { success = success or false } +end + -- Add boilerplate error validation and logging for all of these. for k, fn in pairs(M) do M[k] = function(err, result, ctx, config) diff --git a/runtime/lua/vim/lsp/health.lua b/runtime/lua/vim/lsp/health.lua index ba730e3d6d..987707e661 100644 --- a/runtime/lua/vim/lsp/health.lua +++ b/runtime/lua/vim/lsp/health.lua @@ -2,8 +2,8 @@ local M = {} --- Performs a healthcheck for LSP function M.check() - local report_info = vim.fn['health#report_info'] - local report_warn = vim.fn['health#report_warn'] + local report_info = vim.health.report_info + local report_warn = vim.health.report_warn local log = require('vim.lsp.log') local current_log_level = log.get_level() @@ -27,6 +27,18 @@ function M.check() local report_fn = (log_size / 1000000 > 100 and report_warn or report_info) report_fn(string.format('Log size: %d KB', log_size / 1000)) + + local clients = vim.lsp.get_active_clients() + vim.health.report_start('vim.lsp: Active Clients') + if next(clients) then + for _, client in pairs(clients) do + report_info( + string.format('%s (id=%s, root_dir=%s)', client.name, client.id, client.config.root_dir) + ) + end + else + report_info('No active clients') + end end return M diff --git a/runtime/lua/vim/lsp/log.lua b/runtime/lua/vim/lsp/log.lua index 6c6ba0f206..d1a78572aa 100644 --- a/runtime/lua/vim/lsp/log.lua +++ b/runtime/lua/vim/lsp/log.lua @@ -20,6 +20,17 @@ local format_func = function(arg) end do + ---@private + local function notify(msg, level) + if vim.in_fast_event() then + vim.schedule(function() + vim.notify(msg, level) + end) + else + vim.notify(msg, level) + end + end + local path_sep = vim.loop.os_uname().version:match('Windows') and '\\' or '/' ---@private local function path_join(...) @@ -53,7 +64,7 @@ do logfile, openerr = io.open(logfilename, 'a+') if not logfile then local err_msg = string.format('Failed to open LSP client log file: %s', openerr) - vim.notify(err_msg, vim.log.levels.ERROR) + notify(err_msg, vim.log.levels.ERROR) return false end @@ -64,7 +75,7 @@ do log_info.size / (1000 * 1000), logfilename ) - vim.notify(warn_msg) + notify(warn_msg) end -- Start message for logging @@ -130,7 +141,7 @@ end vim.tbl_add_reverse_lookup(log.levels) --- Sets the current log level. ----@param level (string or number) One of `vim.lsp.log.levels` +---@param level (string|number) One of `vim.lsp.log.levels` function log.set_level(level) if type(level) == 'string' then current_log_level = diff --git a/runtime/lua/vim/lsp/protocol.lua b/runtime/lua/vim/lsp/protocol.lua index 27da60b4ae..12345b6c8c 100644 --- a/runtime/lua/vim/lsp/protocol.lua +++ b/runtime/lua/vim/lsp/protocol.lua @@ -20,6 +20,14 @@ function transform_schema_to_table() end --]=] +---@class lsp.ShowMessageRequestParams +---@field type lsp.MessageType +---@field message string +---@field actions nil|lsp.MessageActionItem[] + +---@class lsp.MessageActionItem +---@field title string + local constants = { DiagnosticSeverity = { -- Reports an error. @@ -39,6 +47,7 @@ local constants = { Deprecated = 2, }, + ---@enum lsp.MessageType MessageType = { -- An error message. Error = 1, @@ -142,6 +151,7 @@ local constants = { }, -- Represents reasons why a text document is saved. + ---@enum lsp.TextDocumentSaveReason TextDocumentSaveReason = { -- Manually triggered, e.g. by the user pressing save, by starting debugging, -- or by an API call. @@ -294,6 +304,17 @@ local constants = { -- Base kind for an organize imports source action SourceOrganizeImports = 'source.organizeImports', }, + -- The reason why code actions were requested. + ---@enum lsp.CodeActionTriggerKind + CodeActionTriggerKind = { + -- Code actions were explicitly requested by the user or by an extension. + Invoked = 1, + -- Code actions were requested automatically. + -- + -- This typically happens when current selection in a file changes, but can + -- also be triggered when file content changes. + Automatic = 2, + }, } for k, v in pairs(constants) do @@ -619,14 +640,63 @@ export interface WorkspaceClientCapabilities { function protocol.make_client_capabilities() return { textDocument = { - synchronization = { + semanticTokens = { dynamicRegistration = false, + tokenTypes = { + 'namespace', + 'type', + 'class', + 'enum', + 'interface', + 'struct', + 'typeParameter', + 'parameter', + 'variable', + 'property', + 'enumMember', + 'event', + 'function', + 'method', + 'macro', + 'keyword', + 'modifier', + 'comment', + 'string', + 'number', + 'regexp', + 'operator', + 'decorator', + }, + tokenModifiers = { + 'declaration', + 'definition', + 'readonly', + 'static', + 'deprecated', + 'abstract', + 'async', + 'modification', + 'documentation', + 'defaultLibrary', + }, + formats = { 'relative' }, + requests = { + -- TODO(jdrouhard): Add support for this + range = false, + full = { delta = true }, + }, - -- TODO(ashkan) Send textDocument/willSave before saving (BufWritePre) - willSave = false, + overlappingTokenSupport = true, + -- TODO(jdrouhard): Add support for this + multilineTokenSupport = false, + serverCancelSupport = false, + augmentsSyntaxTokens = true, + }, + synchronization = { + dynamicRegistration = false, - -- TODO(ashkan) Implement textDocument/willSaveWaitUntil - willSaveWaitUntil = false, + willSave = true, + willSaveWaitUntil = true, -- Send textDocument/didSave after saving (BufWritePost) didSave = true, @@ -637,7 +707,7 @@ function protocol.make_client_capabilities() codeActionLiteralSupport = { codeActionKind = { valueSet = (function() - local res = vim.tbl_values(protocol.CodeActionKind) + local res = vim.tbl_values(constants.CodeActionKind) table.sort(res) return res end)(), @@ -742,6 +812,9 @@ function protocol.make_client_capabilities() end)(), }, }, + callHierarchy = { + dynamicRegistration = false, + }, }, workspace = { symbol = { @@ -765,9 +838,9 @@ function protocol.make_client_capabilities() workspaceEdit = { resourceOperations = { 'rename', 'create', 'delete' }, }, - }, - callHierarchy = { - dynamicRegistration = false, + semanticTokens = { + refreshSupport = true, + }, }, experimental = nil, window = { @@ -778,7 +851,7 @@ function protocol.make_client_capabilities() }, }, showDocument = { - support = false, + support = true, }, }, } @@ -861,8 +934,8 @@ function protocol._resolve_capabilities_compat(server_capabilities) text_document_sync_properties = { text_document_open_close = if_nil(textDocumentSync.openClose, false), text_document_did_change = if_nil(textDocumentSync.change, TextDocumentSyncKind.None), - text_document_will_save = if_nil(textDocumentSync.willSave, false), - text_document_will_save_wait_until = if_nil(textDocumentSync.willSaveWaitUntil, false), + text_document_will_save = if_nil(textDocumentSync.willSave, true), + text_document_will_save_wait_until = if_nil(textDocumentSync.willSaveWaitUntil, true), text_document_save = if_nil(textDocumentSync.save, false), text_document_save_include_text = if_nil( type(textDocumentSync.save) == 'table' and textDocumentSync.save.includeText, diff --git a/runtime/lua/vim/lsp/rpc.lua b/runtime/lua/vim/lsp/rpc.lua index 0926912066..f1492601ff 100644 --- a/runtime/lua/vim/lsp/rpc.lua +++ b/runtime/lua/vim/lsp/rpc.lua @@ -1,4 +1,3 @@ -local vim = vim local uv = vim.loop local log = require('vim.lsp.log') local protocol = require('vim.lsp.protocol') @@ -241,247 +240,162 @@ function default_dispatchers.on_error(code, err) local _ = log.error() and log.error('client_error:', client_errors[code], err) end ---- Starts an LSP server process and create an LSP RPC client object to ---- interact with it. Communication with the server is currently limited to stdio. ---- ----@param cmd (string) Command to start the LSP server. ----@param cmd_args (table) List of additional string arguments to pass to {cmd}. ----@param dispatchers table|nil Dispatchers for LSP message types. Valid ----dispatcher names are: ---- - `"notification"` ---- - `"server_request"` ---- - `"on_error"` ---- - `"on_exit"` ----@param extra_spawn_params table|nil Additional context for the LSP ---- server process. May contain: ---- - {cwd} (string) Working directory for the LSP server process ---- - {env} (table) Additional environment variables for LSP server process ----@returns Client RPC object. ---- ----@returns Methods: ---- - `notify()` |vim.lsp.rpc.notify()| ---- - `request()` |vim.lsp.rpc.request()| ---- ----@returns Members: ---- - {pid} (number) The LSP server's PID. ---- - {handle} A handle for low-level interaction with the LSP server process ---- |vim.loop|. -local function start(cmd, cmd_args, dispatchers, extra_spawn_params) - local _ = log.info() - and log.info('Starting RPC client', { cmd = cmd, args = cmd_args, extra = extra_spawn_params }) - validate({ - cmd = { cmd, 's' }, - cmd_args = { cmd_args, 't' }, - dispatchers = { dispatchers, 't', true }, - }) - - if extra_spawn_params and extra_spawn_params.cwd then - assert(is_dir(extra_spawn_params.cwd), 'cwd must be a directory') - end - if dispatchers then - local user_dispatchers = dispatchers - dispatchers = {} - for dispatch_name, default_dispatch in pairs(default_dispatchers) do - local user_dispatcher = user_dispatchers[dispatch_name] - if user_dispatcher then - if type(user_dispatcher) ~= 'function' then - error(string.format('dispatcher.%s must be a function', dispatch_name)) - end - -- server_request is wrapped elsewhere. - if - not (dispatch_name == 'server_request' or dispatch_name == 'on_exit') -- TODO this blocks the loop exiting for some reason. - then - user_dispatcher = schedule_wrap(user_dispatcher) - end - dispatchers[dispatch_name] = user_dispatcher - else - dispatchers[dispatch_name] = default_dispatch - end +---@private +local function create_read_loop(handle_body, on_no_chunk, on_error) + local parse_chunk = coroutine.wrap(request_parser_loop) + parse_chunk() + return function(err, chunk) + if err then + on_error(err) + return end - else - dispatchers = default_dispatchers - end - - local stdin = uv.new_pipe(false) - local stdout = uv.new_pipe(false) - local stderr = uv.new_pipe(false) - - local message_index = 0 - local message_callbacks = {} - local notify_reply_callbacks = {} - local handle, pid - do - ---@private - --- Callback for |vim.loop.spawn()| Closes all streams and runs the `on_exit` dispatcher. - ---@param code (number) Exit code - ---@param signal (number) Signal that was used to terminate (if any) - local function onexit(code, signal) - stdin:close() - stdout:close() - stderr:close() - handle:close() - -- 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 = { - args = cmd_args, - stdio = { stdin, stdout, stderr }, - detached = not is_win, - } - if extra_spawn_params then - spawn_params.cwd = extra_spawn_params.cwd - spawn_params.env = env_merge(extra_spawn_params.env) - if extra_spawn_params.detached ~= nil then - spawn_params.detached = extra_spawn_params.detached + if not chunk then + if on_no_chunk then + on_no_chunk() end + return end - handle, pid = uv.spawn(cmd, spawn_params, onexit) - if handle == nil then - stdin:close() - stdout:close() - stderr:close() - local msg = string.format('Spawning language server with cmd: `%s` failed', cmd) - if string.match(pid, 'ENOENT') then - msg = msg - .. '. The language server is either not installed, missing from PATH, or not executable.' + + while true do + local headers, body = parse_chunk(chunk) + if headers then + handle_body(body) + chunk = '' else - msg = msg .. string.format(' with error message: %s', pid) + break end - vim.notify(msg, vim.log.levels.WARN) - return end end +end - ---@private - --- Encodes {payload} into a JSON-RPC message and sends it to the remote - --- process. - --- - ---@param payload table - ---@returns true if the payload could be scheduled, false if the main event-loop is in the process of closing. - local function encode_and_send(payload) - local _ = log.debug() and log.debug('rpc.send', payload) - if handle == nil or handle:is_closing() then - return false - end - local encoded = vim.json.encode(payload) - stdin:write(format_message_with_content_length(encoded)) - return true - end +---@class RpcClient +---@field message_index number +---@field message_callbacks table +---@field notify_reply_callbacks table +---@field transport table +---@field dispatchers table - -- FIXME: DOC: Should be placed on the RPC client object returned by - -- `start()` - -- - --- Sends a notification to the LSP server. - ---@param method (string) The invoked LSP method - ---@param params (table): Parameters for the invoked LSP method - ---@returns (bool) `true` if notification could be sent, `false` if not - local function notify(method, params) - return encode_and_send({ - jsonrpc = '2.0', - method = method, - params = params, - }) - end +---@class RpcClient +local Client = {} - ---@private - --- sends an error object to the remote LSP process. - local function send_response(request_id, err, result) - return encode_and_send({ - id = request_id, - jsonrpc = '2.0', - error = err, - result = result, - }) +---@private +function Client:encode_and_send(payload) + local _ = log.debug() and log.debug('rpc.send', payload) + if self.transport.is_closing() then + return false end + local encoded = vim.json.encode(payload) + self.transport.write(format_message_with_content_length(encoded)) + return true +end - -- FIXME: DOC: Should be placed on the RPC client object returned by - -- `start()` - -- - --- Sends a request to the LSP server and runs {callback} upon response. - --- - ---@param method (string) The invoked LSP method - ---@param params (table) Parameters for the invoked LSP method - ---@param callback (function) Callback to invoke - ---@param notify_reply_callback (function|nil) 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, 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 - local result = encode_and_send({ - id = message_id, - jsonrpc = '2.0', - method = method, - params = params, - }) - 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 +---@private +--- Sends a notification to the LSP server. +---@param method (string) The invoked LSP method +---@param params (any): Parameters for the invoked LSP method +---@returns (bool) `true` if notification could be sent, `false` if not +function Client:notify(method, params) + return self:encode_and_send({ + jsonrpc = '2.0', + method = method, + params = params, + }) +end + +---@private +--- sends an error object to the remote LSP process. +function Client:send_response(request_id, err, result) + return self:encode_and_send({ + id = request_id, + jsonrpc = '2.0', + error = err, + result = result, + }) +end + +---@private +--- Sends a request to the LSP server and runs {callback} upon response. +--- +---@param method (string) The invoked LSP method +---@param params (table|nil) Parameters for the invoked LSP method +---@param callback (function) Callback to invoke +---@param notify_reply_callback (function|nil) 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 +function Client:request(method, params, callback, notify_reply_callback) + validate({ + callback = { callback, 'f' }, + notify_reply_callback = { notify_reply_callback, 'f', true }, + }) + self.message_index = self.message_index + 1 + local message_id = self.message_index + local result = self:encode_and_send({ + id = message_id, + jsonrpc = '2.0', + method = method, + params = params, + }) + local message_callbacks = self.message_callbacks + local notify_reply_callbacks = self.notify_reply_callbacks + 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 end +end - stderr:read_start(function(_, chunk) - if chunk then - local _ = log.error() and log.error('rpc', cmd, 'stderr', chunk) - end - end) +---@private +function Client:on_error(errkind, ...) + assert(client_errors[errkind]) + -- TODO what to do if this fails? + pcall(self.dispatchers.on_error, errkind, ...) +end - ---@private - local function on_error(errkind, ...) - assert(client_errors[errkind]) - -- TODO what to do if this fails? - pcall(dispatchers.on_error, errkind, ...) - end - ---@private - local function pcall_handler(errkind, status, head, ...) - if not status then - on_error(errkind, head, ...) - return status, head - end - return status, head, ... - end - ---@private - local function try_call(errkind, fn, ...) - return pcall_handler(errkind, pcall(fn, ...)) +---@private +function Client:pcall_handler(errkind, status, head, ...) + if not status then + self:on_error(errkind, head, ...) + return status, head end + return status, head, ... +end - -- TODO periodically check message_callbacks for old requests past a certain - -- time and log them. This would require storing the timestamp. I could call - -- them with an error then, perhaps. +---@private +function Client:try_call(errkind, fn, ...) + return self:pcall_handler(errkind, pcall(fn, ...)) +end - ---@private - local function handle_body(body) - local ok, decoded = pcall(vim.json.decode, body, { luanil = { object = true } }) - if not ok then - on_error(client_errors.INVALID_SERVER_JSON, decoded) - return - end - local _ = log.debug() and log.debug('rpc.receive', decoded) +-- TODO periodically check message_callbacks for old requests past a certain +-- time and log them. This would require storing the timestamp. I could call +-- them with an error then, perhaps. - if type(decoded.method) == 'string' and decoded.id then - local err - -- Schedule here so that the users functions don't trigger an error and - -- we can still use the result. - schedule(function() +---@private +function Client:handle_body(body) + local ok, decoded = pcall(vim.json.decode, body, { luanil = { object = true } }) + if not ok then + self:on_error(client_errors.INVALID_SERVER_JSON, decoded) + return + end + local _ = log.debug() and log.debug('rpc.receive', decoded) + + if type(decoded.method) == 'string' and decoded.id then + local err + -- Schedule here so that the users functions don't trigger an error and + -- we can still use the result. + schedule(function() + coroutine.wrap(function() local status, result - status, result, err = try_call( + status, result, err = self:try_call( client_errors.SERVER_REQUEST_HANDLER_ERROR, - dispatchers.server_request, + self.dispatchers.server_request, decoded.method, decoded.params ) @@ -491,8 +405,7 @@ local function start(cmd, cmd_args, dispatchers, extra_spawn_params) { status = status, result = result, err = err } ) if status then - if not (result or err) then - -- TODO this can be a problem if `null` is sent for result. needs vim.NIL + if result == nil and err == nil then error( string.format( 'method %q: either a result or an error must be sent to the server in response', @@ -516,120 +429,328 @@ local function start(cmd, cmd_args, dispatchers, extra_spawn_params) err = rpc_response_error(protocol.ErrorCodes.InternalError, result) result = nil end - send_response(decoded.id, err, result) - end) - -- This works because we are expecting vim.NIL here - elseif decoded.id and (decoded.result ~= vim.NIL or decoded.error ~= vim.NIL) then - -- We sent a number, so we expect a number. - local result_id = assert(tonumber(decoded.id), 'response id must be a number') - - -- 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 + self:send_response(decoded.id, err, result) + end)() + end) + -- This works because we are expecting vim.NIL here + elseif decoded.id and (decoded.result ~= vim.NIL or decoded.error ~= vim.NIL) then + -- We sent a number, so we expect a number. + local result_id = assert(tonumber(decoded.id), 'response id must be a number') + + -- Notify the user that a response was received for the request + local notify_reply_callbacks = self.notify_reply_callbacks + 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 - if decoded.error.code == protocol.ErrorCodes.RequestCancelled then - local _ = log.debug() and log.debug('Received cancellation ack', decoded) - mute_error = true - end + local message_callbacks = self.message_callbacks - if mute_error then - -- Clear any callback since this is cancelled now. - -- This is safe to do assuming that these conditions hold: - -- - The server will not send a result callback after this cancellation. - -- - If the server sent this cancellation ACK after sending the result, the user of this RPC - -- client will ignore the result themselves. - if result_id and message_callbacks then - message_callbacks[result_id] = nil - end - return + -- Do not surface RequestCancelled to users, it is RPC-internal. + if decoded.error then + local mute_error = false + if decoded.error.code == protocol.ErrorCodes.RequestCancelled then + local _ = log.debug() and log.debug('Received cancellation ack', decoded) + mute_error = true + end + + if mute_error then + -- Clear any callback since this is cancelled now. + -- This is safe to do assuming that these conditions hold: + -- - The server will not send a result callback after this cancellation. + -- - If the server sent this cancellation ACK after sending the result, the user of this RPC + -- client will ignore the result themselves. + if result_id and message_callbacks then + message_callbacks[result_id] = nil end + return end + end - local callback = message_callbacks and message_callbacks[result_id] - if callback then - message_callbacks[result_id] = nil - validate({ - callback = { callback, 'f' }, + local callback = message_callbacks and message_callbacks[result_id] + if callback then + message_callbacks[result_id] = nil + validate({ + callback = { callback, 'f' }, + }) + if decoded.error then + decoded.error = setmetatable(decoded.error, { + __tostring = format_rpc_error, }) - if decoded.error then - decoded.error = setmetatable(decoded.error, { - __tostring = format_rpc_error, - }) - end - try_call( - client_errors.SERVER_RESULT_CALLBACK_ERROR, - callback, - decoded.error, - decoded.result - ) - else - on_error(client_errors.NO_RESULT_CALLBACK_FOUND, decoded) - local _ = log.error() - and log.error('No callback found for server response id ' .. result_id) end - elseif type(decoded.method) == 'string' then - -- Notification - try_call( - client_errors.NOTIFICATION_HANDLER_ERROR, - dispatchers.notification, - decoded.method, - decoded.params + self:try_call( + client_errors.SERVER_RESULT_CALLBACK_ERROR, + callback, + decoded.error, + decoded.result ) else - -- Invalid server message - on_error(client_errors.INVALID_SERVER_MESSAGE, decoded) + self:on_error(client_errors.NO_RESULT_CALLBACK_FOUND, decoded) + local _ = log.error() and log.error('No callback found for server response id ' .. result_id) end + elseif type(decoded.method) == 'string' then + -- Notification + self:try_call( + client_errors.NOTIFICATION_HANDLER_ERROR, + self.dispatchers.notification, + decoded.method, + decoded.params + ) + else + -- Invalid server message + self:on_error(client_errors.INVALID_SERVER_MESSAGE, decoded) end +end - local request_parser = coroutine.wrap(request_parser_loop) - request_parser() - stdout:read_start(function(err, chunk) - if err then - -- TODO better handling. Can these be intermittent errors? - on_error(client_errors.READ_ERROR, err) - return - end - -- This should signal that we are done reading from the client. - if not chunk then - return - end - -- Flush anything in the parser by looping until we don't get a result - -- anymore. - while true do - local headers, body = request_parser(chunk) - -- If we successfully parsed, then handle the response. - if headers then - handle_body(body) - -- Set chunk to empty so that we can call request_parser to get - -- anything existing in the parser to flush. - chunk = '' +---@private +---@return RpcClient +local function new_client(dispatchers, transport) + local state = { + message_index = 0, + message_callbacks = {}, + notify_reply_callbacks = {}, + transport = transport, + dispatchers = dispatchers, + } + return setmetatable(state, { __index = Client }) +end + +---@private +---@param client RpcClient +local function public_client(client) + local result = {} + + ---@private + function result.is_closing() + return client.transport.is_closing() + end + + ---@private + function result.terminate() + client.transport.terminate() + end + + --- Sends a request to the LSP server and runs {callback} upon response. + --- + ---@param method (string) The invoked LSP method + ---@param params (table|nil) Parameters for the invoked LSP method + ---@param callback (function) Callback to invoke + ---@param notify_reply_callback (function|nil) 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 + function result.request(method, params, callback, notify_reply_callback) + return client:request(method, params, callback, notify_reply_callback) + end + + --- Sends a notification to the LSP server. + ---@param method (string) The invoked LSP method + ---@param params (table|nil): Parameters for the invoked LSP method + ---@returns (bool) `true` if notification could be sent, `false` if not + function result.notify(method, params) + return client:notify(method, params) + end + + return result +end + +---@private +local function merge_dispatchers(dispatchers) + if dispatchers then + local user_dispatchers = dispatchers + dispatchers = {} + for dispatch_name, default_dispatch in pairs(default_dispatchers) do + local user_dispatcher = user_dispatchers[dispatch_name] + if user_dispatcher then + if type(user_dispatcher) ~= 'function' then + error(string.format('dispatcher.%s must be a function', dispatch_name)) + end + -- server_request is wrapped elsewhere. + if + not (dispatch_name == 'server_request' or dispatch_name == 'on_exit') -- TODO this blocks the loop exiting for some reason. + then + user_dispatcher = schedule_wrap(user_dispatcher) + end + dispatchers[dispatch_name] = user_dispatcher else - break + dispatchers[dispatch_name] = default_dispatch end end - end) + else + dispatchers = default_dispatchers + end + return dispatchers +end + +--- Create a LSP RPC client factory that connects via TCP to the given host +--- and port +--- +---@param host string +---@param port number +---@return function +local function connect(host, port) + return function(dispatchers) + dispatchers = merge_dispatchers(dispatchers) + local tcp = uv.new_tcp() + local closing = false + local transport = { + write = function(msg) + tcp:write(msg) + end, + is_closing = function() + return closing + end, + terminate = function() + if not closing then + closing = true + tcp:shutdown() + tcp:close() + dispatchers.on_exit(0, 0) + end + end, + } + local client = new_client(dispatchers, transport) + tcp:connect(host, port, function(err) + if err then + vim.schedule(function() + vim.notify( + string.format('Could not connect to %s:%s, reason: %s', host, port, vim.inspect(err)), + vim.log.levels.WARN + ) + end) + return + end + local handle_body = function(body) + client:handle_body(body) + end + tcp:read_start(create_read_loop(handle_body, transport.terminate, function(read_err) + client:on_error(client_errors.READ_ERROR, read_err) + end)) + end) - return { - pid = pid, - handle = handle, - request = request, - notify = notify, + return public_client(client) + end +end + +--- Starts an LSP server process and create an LSP RPC client object to +--- interact with it. Communication with the spawned process happens via stdio. For +--- communication via TCP, spawn a process manually and use |vim.lsp.rpc.connect()| +--- +---@param cmd (string) Command to start the LSP server. +---@param cmd_args (table) List of additional string arguments to pass to {cmd}. +---@param dispatchers table|nil Dispatchers for LSP message types. Valid +---dispatcher names are: +--- - `"notification"` +--- - `"server_request"` +--- - `"on_error"` +--- - `"on_exit"` +---@param extra_spawn_params table|nil Additional context for the LSP +--- server process. May contain: +--- - {cwd} (string) Working directory for the LSP server process +--- - {env} (table) Additional environment variables for LSP server process +---@returns Client RPC object. +--- +---@returns Methods: +--- - `notify()` |vim.lsp.rpc.notify()| +--- - `request()` |vim.lsp.rpc.request()| +--- - `is_closing()` returns a boolean indicating if the RPC is closing. +--- - `terminate()` terminates the RPC client. +local function start(cmd, cmd_args, dispatchers, extra_spawn_params) + local _ = log.info() + and log.info('Starting RPC client', { cmd = cmd, args = cmd_args, extra = extra_spawn_params }) + validate({ + cmd = { cmd, 's' }, + cmd_args = { cmd_args, 't' }, + dispatchers = { dispatchers, 't', true }, + }) + + if extra_spawn_params and extra_spawn_params.cwd then + assert(is_dir(extra_spawn_params.cwd), 'cwd must be a directory') + end + + dispatchers = merge_dispatchers(dispatchers) + local stdin = uv.new_pipe(false) + local stdout = uv.new_pipe(false) + local stderr = uv.new_pipe(false) + local handle, pid + + local client = new_client(dispatchers, { + write = function(msg) + stdin:write(msg) + end, + is_closing = function() + return handle == nil or handle:is_closing() + end, + terminate = function() + if handle then + handle:kill(15) + end + end, + }) + + ---@private + --- Callback for |vim.loop.spawn()| Closes all streams and runs the `on_exit` dispatcher. + ---@param code (number) Exit code + ---@param signal (number) Signal that was used to terminate (if any) + local function onexit(code, signal) + stdin:close() + stdout:close() + stderr:close() + handle:close() + dispatchers.on_exit(code, signal) + end + local spawn_params = { + args = cmd_args, + stdio = { stdin, stdout, stderr }, + detached = not is_win, } + if extra_spawn_params then + spawn_params.cwd = extra_spawn_params.cwd + spawn_params.env = env_merge(extra_spawn_params.env) + if extra_spawn_params.detached ~= nil then + spawn_params.detached = extra_spawn_params.detached + end + end + handle, pid = uv.spawn(cmd, spawn_params, onexit) + if handle == nil then + stdin:close() + stdout:close() + stderr:close() + local msg = string.format('Spawning language server with cmd: `%s` failed', cmd) + if string.match(pid, 'ENOENT') then + msg = msg + .. '. The language server is either not installed, missing from PATH, or not executable.' + else + msg = msg .. string.format(' with error message: %s', pid) + end + vim.notify(msg, vim.log.levels.WARN) + return + end + + stderr:read_start(function(_, chunk) + if chunk then + local _ = log.error() and log.error('rpc', cmd, 'stderr', chunk) + end + end) + + local handle_body = function(body) + client:handle_body(body) + end + stdout:read_start(create_read_loop(handle_body, nil, function(err) + client:on_error(client_errors.READ_ERROR, err) + end)) + + return public_client(client) end return { start = start, + connect = connect, rpc_response_error = rpc_response_error, format_rpc_error = format_rpc_error, client_errors = client_errors, + create_read_loop = create_read_loop, } -- vim:sw=2 ts=2 et diff --git a/runtime/lua/vim/lsp/semantic_tokens.lua b/runtime/lua/vim/lsp/semantic_tokens.lua new file mode 100644 index 0000000000..b1bc48dac6 --- /dev/null +++ b/runtime/lua/vim/lsp/semantic_tokens.lua @@ -0,0 +1,702 @@ +local api = vim.api +local handlers = require('vim.lsp.handlers') +local util = require('vim.lsp.util') + +--- @class STTokenRange +--- @field line number line number 0-based +--- @field start_col number start column 0-based +--- @field end_col number end column 0-based +--- @field type string token type as string +--- @field modifiers string[] token modifiers as strings +--- @field extmark_added boolean whether this extmark has been added to the buffer yet +--- +--- @class STCurrentResult +--- @field version number document version associated with this result +--- @field result_id string resultId from the server; used with delta requests +--- @field highlights STTokenRange[] cache of highlight ranges for this document version +--- @field tokens number[] raw token array as received by the server. used for calculating delta responses +--- @field namespace_cleared boolean whether the namespace was cleared for this result yet +--- +--- @class STActiveRequest +--- @field request_id number the LSP request ID of the most recent request sent to the server +--- @field version number the document version associated with the most recent request +--- +--- @class STClientState +--- @field namespace number +--- @field active_request STActiveRequest +--- @field current_result STCurrentResult + +---@class STHighlighter +---@field active table<number, STHighlighter> +---@field bufnr number +---@field augroup number augroup for buffer events +---@field debounce number milliseconds to debounce requests for new tokens +---@field timer table uv_timer for debouncing requests for new tokens +---@field client_state table<number, STClientState> +local STHighlighter = { active = {} } + +---@private +local function binary_search(tokens, line) + local lo = 1 + local hi = #tokens + while lo < hi do + local mid = math.floor((lo + hi) / 2) + if tokens[mid].line < line then + lo = mid + 1 + else + hi = mid + end + end + return lo +end + +--- Extracts modifier strings from the encoded number in the token array +--- +---@private +---@return string[] +local function modifiers_from_number(x, modifiers_table) + local modifiers = {} + local idx = 1 + while x > 0 do + if _G.bit then + if _G.bit.band(x, 1) == 1 then + modifiers[#modifiers + 1] = modifiers_table[idx] + end + x = _G.bit.rshift(x, 1) + else + --TODO(jdrouhard): remove this branch once `bit` module is available for non-LuaJIT (#21222) + if x % 2 == 1 then + modifiers[#modifiers + 1] = modifiers_table[idx] + end + x = math.floor(x / 2) + end + idx = idx + 1 + end + + return modifiers +end + +--- Converts a raw token list to a list of highlight ranges used by the on_win callback +--- +---@private +---@return STTokenRange[] +local function tokens_to_ranges(data, bufnr, client) + local legend = client.server_capabilities.semanticTokensProvider.legend + local token_types = legend.tokenTypes + local token_modifiers = legend.tokenModifiers + local ranges = {} + + local line + local start_char = 0 + for i = 1, #data, 5 do + local delta_line = data[i] + line = line and line + delta_line or delta_line + local delta_start = data[i + 1] + start_char = delta_line == 0 and start_char + delta_start or delta_start + + -- data[i+3] +1 because Lua tables are 1-indexed + local token_type = token_types[data[i + 3] + 1] + local modifiers = modifiers_from_number(data[i + 4], token_modifiers) + + ---@private + local function _get_byte_pos(char_pos) + return util._get_line_byte_from_position(bufnr, { + line = line, + character = char_pos, + }, client.offset_encoding) + end + + local start_col = _get_byte_pos(start_char) + local end_col = _get_byte_pos(start_char + data[i + 2]) + + if token_type then + ranges[#ranges + 1] = { + line = line, + start_col = start_col, + end_col = end_col, + type = token_type, + modifiers = modifiers, + extmark_added = false, + } + end + end + + return ranges +end + +--- Construct a new STHighlighter for the buffer +--- +---@private +---@param bufnr number +function STHighlighter.new(bufnr) + local self = setmetatable({}, { __index = STHighlighter }) + + self.bufnr = bufnr + self.augroup = api.nvim_create_augroup('vim_lsp_semantic_tokens:' .. bufnr, { clear = true }) + self.client_state = {} + + STHighlighter.active[bufnr] = self + + api.nvim_buf_attach(bufnr, false, { + on_lines = function(_, buf) + local highlighter = STHighlighter.active[buf] + if not highlighter then + return true + end + highlighter:on_change() + end, + on_reload = function(_, buf) + local highlighter = STHighlighter.active[buf] + if highlighter then + highlighter:reset() + highlighter:send_request() + end + end, + on_detach = function(_, buf) + local highlighter = STHighlighter.active[buf] + if highlighter then + highlighter:destroy() + end + end, + }) + + api.nvim_create_autocmd({ 'BufWinEnter', 'InsertLeave' }, { + buffer = self.bufnr, + group = self.augroup, + callback = function() + self:send_request() + end, + }) + + api.nvim_create_autocmd('LspDetach', { + buffer = self.bufnr, + group = self.augroup, + callback = function(args) + self:detach(args.data.client_id) + if vim.tbl_isempty(self.client_state) then + self:destroy() + end + end, + }) + + return self +end + +---@private +function STHighlighter:destroy() + for client_id, _ in pairs(self.client_state) do + self:detach(client_id) + end + + api.nvim_del_augroup_by_id(self.augroup) + STHighlighter.active[self.bufnr] = nil +end + +---@private +function STHighlighter:attach(client_id) + local state = self.client_state[client_id] + if not state then + state = { + namespace = api.nvim_create_namespace('vim_lsp_semantic_tokens:' .. client_id), + active_request = {}, + current_result = {}, + } + self.client_state[client_id] = state + end +end + +---@private +function STHighlighter:detach(client_id) + local state = self.client_state[client_id] + if state then + --TODO: delete namespace if/when that becomes possible + api.nvim_buf_clear_namespace(self.bufnr, state.namespace, 0, -1) + self.client_state[client_id] = nil + end +end + +--- This is the entry point for getting all the tokens in a buffer. +--- +--- For the given clients (or all attached, if not provided), this sends a request +--- to ask for semantic tokens. If the server supports delta requests, that will +--- be prioritized if we have a previous requestId and token array. +--- +--- This function will skip servers where there is an already an active request in +--- flight for the same version. If there is a stale request in flight, that is +--- cancelled prior to sending a new one. +--- +--- Finally, if the request was successful, the requestId and document version +--- are saved to facilitate document synchronization in the response. +--- +---@private +function STHighlighter:send_request() + local version = util.buf_versions[self.bufnr] + + self:reset_timer() + + for client_id, state in pairs(self.client_state) do + local client = vim.lsp.get_client_by_id(client_id) + + local current_result = state.current_result + local active_request = state.active_request + + -- Only send a request for this client if the current result is out of date and + -- there isn't a current a request in flight for this version + if client and current_result.version ~= version and active_request.version ~= version then + -- cancel stale in-flight request + if active_request.request_id then + client.cancel_request(active_request.request_id) + active_request = {} + state.active_request = active_request + end + + local spec = client.server_capabilities.semanticTokensProvider.full + local hasEditProvider = type(spec) == 'table' and spec.delta + + local params = { textDocument = util.make_text_document_params(self.bufnr) } + local method = 'textDocument/semanticTokens/full' + + if hasEditProvider and current_result.result_id then + method = method .. '/delta' + params.previousResultId = current_result.result_id + end + local success, request_id = client.request(method, params, function(err, response, ctx) + -- look client up again using ctx.client_id instead of using a captured + -- client object + local c = vim.lsp.get_client_by_id(ctx.client_id) + local highlighter = STHighlighter.active[ctx.bufnr] + if not err and c and highlighter then + highlighter:process_response(response, c, version) + end + end, self.bufnr) + + if success then + active_request.request_id = request_id + active_request.version = version + end + end + end +end + +--- This function will parse the semantic token responses and set up the cache +--- (current_result). It also performs document synchronization by checking the +--- version of the document associated with the resulting request_id and only +--- performing work if the response is not out-of-date. +--- +--- Delta edits are applied if necessary, and new highlight ranges are calculated +--- and stored in the buffer state. +--- +--- Finally, a redraw command is issued to force nvim to redraw the screen to +--- pick up changed highlight tokens. +--- +---@private +function STHighlighter:process_response(response, client, version) + local state = self.client_state[client.id] + if not state then + return + end + + -- ignore stale responses + if state.active_request.version and version ~= state.active_request.version then + return + end + + -- reset active request + state.active_request = {} + + -- skip nil responses + if response == nil then + return + end + + -- if we have a response to a delta request, update the state of our tokens + -- appropriately. if it's a full response, just use that + local tokens + local token_edits = response.edits + if token_edits then + table.sort(token_edits, function(a, b) + return a.start < b.start + end) + + tokens = {} + local old_tokens = state.current_result.tokens + local idx = 1 + for _, token_edit in ipairs(token_edits) do + vim.list_extend(tokens, old_tokens, idx, token_edit.start) + if token_edit.data then + vim.list_extend(tokens, token_edit.data) + end + idx = token_edit.start + token_edit.deleteCount + 1 + end + vim.list_extend(tokens, old_tokens, idx) + else + tokens = response.data + end + + -- Update the state with the new results + local current_result = state.current_result + current_result.version = version + current_result.result_id = response.resultId + current_result.tokens = tokens + current_result.highlights = tokens_to_ranges(tokens, self.bufnr, client) + current_result.namespace_cleared = false + + api.nvim_command('redraw!') +end + +--- on_win handler for the decoration provider (see |nvim_set_decoration_provider|) +--- +--- If there is a current result for the buffer and the version matches the +--- current document version, then the tokens are valid and can be applied. As +--- the buffer is drawn, this function will add extmark highlights for every +--- token in the range of visible lines. Once a highlight has been added, it +--- sticks around until the document changes and there's a new set of matching +--- highlight tokens available. +--- +--- If this is the first time a buffer is being drawn with a new set of +--- highlights for the current document version, the namespace is cleared to +--- remove extmarks from the last version. It's done here instead of the response +--- handler to avoid the "blink" that occurs due to the timing between the +--- response handler and the actual redraw. +--- +---@private +function STHighlighter:on_win(topline, botline) + for _, state in pairs(self.client_state) do + local current_result = state.current_result + if current_result.version and current_result.version == util.buf_versions[self.bufnr] then + if not current_result.namespace_cleared then + api.nvim_buf_clear_namespace(self.bufnr, state.namespace, 0, -1) + current_result.namespace_cleared = true + end + + -- We can't use ephemeral extmarks because the buffer updates are not in + -- sync with the list of semantic tokens. There's a delay between the + -- buffer changing and when the LSP server can respond with updated + -- tokens, and we don't want to "blink" the token highlights while + -- updates are in flight, and we don't want to use stale tokens because + -- they likely won't line up right with the actual buffer. + -- + -- Instead, we have to use normal extmarks that can attach to locations + -- in the buffer and are persisted between redraws. + local highlights = current_result.highlights + local idx = binary_search(highlights, topline) + + for i = idx, #highlights do + local token = highlights[i] + + if token.line > botline then + break + end + + if not token.extmark_added then + -- `strict = false` is necessary here for the 1% of cases where the + -- current result doesn't actually match the buffer contents. Some + -- LSP servers can respond with stale tokens on requests if they are + -- still processing changes from a didChange notification. + -- + -- LSP servers that do this _should_ follow up known stale responses + -- with a refresh notification once they've finished processing the + -- didChange notification, which would re-synchronize the tokens from + -- our end. + -- + -- The server I know of that does this is clangd when the preamble of + -- a file changes and the token request is processed with a stale + -- preamble while the new one is still being built. Once the preamble + -- finishes, clangd sends a refresh request which lets the client + -- re-synchronize the tokens. + api.nvim_buf_set_extmark(self.bufnr, state.namespace, token.line, token.start_col, { + hl_group = '@' .. token.type, + end_col = token.end_col, + priority = vim.highlight.priorities.semantic_tokens, + strict = false, + }) + + -- TODO(bfredl) use single extmark when hl_group supports table + if #token.modifiers > 0 then + for _, modifier in pairs(token.modifiers) do + api.nvim_buf_set_extmark(self.bufnr, state.namespace, token.line, token.start_col, { + hl_group = '@' .. modifier, + end_col = token.end_col, + priority = vim.highlight.priorities.semantic_tokens + 1, + strict = false, + }) + end + end + + token.extmark_added = true + end + end + end + end +end + +--- Reset the buffer's highlighting state and clears the extmark highlights. +--- +---@private +function STHighlighter:reset() + for client_id, state in pairs(self.client_state) do + api.nvim_buf_clear_namespace(self.bufnr, state.namespace, 0, -1) + state.current_result = {} + if state.active_request.request_id then + local client = vim.lsp.get_client_by_id(client_id) + assert(client) + client.cancel_request(state.active_request.request_id) + state.active_request = {} + end + end +end + +--- Mark a client's results as dirty. This method will cancel any active +--- requests to the server and pause new highlights from being added +--- in the on_win callback. The rest of the current results are saved +--- in case the server supports delta requests. +--- +---@private +---@param client_id number +function STHighlighter:mark_dirty(client_id) + local state = self.client_state[client_id] + assert(state) + + -- if we clear the version from current_result, it'll cause the + -- next request to be sent and will also pause new highlights + -- from being added in on_win until a new result comes from + -- the server + if state.current_result then + state.current_result.version = nil + end + + if state.active_request.request_id then + local client = vim.lsp.get_client_by_id(client_id) + assert(client) + client.cancel_request(state.active_request.request_id) + state.active_request = {} + end +end + +---@private +function STHighlighter:on_change() + self:reset_timer() + if self.debounce > 0 then + self.timer = vim.defer_fn(function() + self:send_request() + end, self.debounce) + else + self:send_request() + end +end + +---@private +function STHighlighter:reset_timer() + local timer = self.timer + if timer then + self.timer = nil + if not timer:is_closing() then + timer:stop() + timer:close() + end + end +end + +local M = {} + +--- Start the semantic token highlighting engine for the given buffer with the +--- given client. The client must already be attached to the buffer. +--- +--- NOTE: This is currently called automatically by |vim.lsp.buf_attach_client()|. To +--- opt-out of semantic highlighting with a server that supports it, you can +--- delete the semanticTokensProvider table from the {server_capabilities} of +--- your client in your |LspAttach| callback or your configuration's +--- `on_attach` callback: +--- <pre>lua +--- client.server_capabilities.semanticTokensProvider = nil +--- </pre> +--- +---@param bufnr number +---@param client_id number +---@param opts (nil|table) Optional keyword arguments +--- - debounce (number, default: 200): Debounce token requests +--- to the server by the given number in milliseconds +function M.start(bufnr, client_id, opts) + vim.validate({ + bufnr = { bufnr, 'n', false }, + client_id = { client_id, 'n', false }, + }) + + opts = opts or {} + assert( + (not opts.debounce or type(opts.debounce) == 'number'), + 'opts.debounce must be a number with the debounce time in milliseconds' + ) + + local client = vim.lsp.get_client_by_id(client_id) + if not client then + vim.notify('[LSP] No client with id ' .. client_id, vim.log.levels.ERROR) + return + end + + if not vim.lsp.buf_is_attached(bufnr, client_id) then + vim.notify( + '[LSP] Client with id ' .. client_id .. ' not attached to buffer ' .. bufnr, + vim.log.levels.WARN + ) + return + end + + if not vim.tbl_get(client.server_capabilities, 'semanticTokensProvider', 'full') then + vim.notify('[LSP] Server does not support semantic tokens', vim.log.levels.WARN) + return + end + + local highlighter = STHighlighter.active[bufnr] + + if not highlighter then + highlighter = STHighlighter.new(bufnr) + highlighter.debounce = opts.debounce or 200 + else + highlighter.debounce = math.max(highlighter.debounce, opts.debounce or 200) + end + + highlighter:attach(client_id) + highlighter:send_request() +end + +--- Stop the semantic token highlighting engine for the given buffer with the +--- given client. +--- +--- NOTE: This is automatically called by a |LspDetach| autocmd that is set up as part +--- of `start()`, so you should only need this function to manually disengage the semantic +--- token engine without fully detaching the LSP client from the buffer. +--- +---@param bufnr number +---@param client_id number +function M.stop(bufnr, client_id) + vim.validate({ + bufnr = { bufnr, 'n', false }, + client_id = { client_id, 'n', false }, + }) + + local highlighter = STHighlighter.active[bufnr] + if not highlighter then + return + end + + highlighter:detach(client_id) + + if vim.tbl_isempty(highlighter.client_state) then + highlighter:destroy() + end +end + +--- Return the semantic token(s) at the given position. +--- If called without arguments, returns the token under the cursor. +--- +---@param bufnr number|nil Buffer number (0 for current buffer, default) +---@param row number|nil Position row (default cursor position) +---@param col number|nil Position column (default cursor position) +--- +---@return table|nil (table|nil) List of tokens at position +function M.get_at_pos(bufnr, row, col) + if bufnr == nil or bufnr == 0 then + bufnr = api.nvim_get_current_buf() + end + + local highlighter = STHighlighter.active[bufnr] + if not highlighter then + return + end + + if row == nil or col == nil then + local cursor = api.nvim_win_get_cursor(0) + row, col = cursor[1] - 1, cursor[2] + end + + local tokens = {} + for client_id, client in pairs(highlighter.client_state) do + local highlights = client.current_result.highlights + if highlights then + local idx = binary_search(highlights, row) + for i = idx, #highlights do + local token = highlights[i] + + if token.line > row then + break + end + + if token.start_col <= col and token.end_col > col then + token.client_id = client_id + tokens[#tokens + 1] = token + end + end + end + end + return tokens +end + +--- Force a refresh of all semantic tokens +--- +--- Only has an effect if the buffer is currently active for semantic token +--- highlighting (|vim.lsp.semantic_tokens.start()| has been called for it) +--- +---@param bufnr (nil|number) default: current buffer +function M.force_refresh(bufnr) + vim.validate({ + bufnr = { bufnr, 'n', true }, + }) + + if bufnr == nil or bufnr == 0 then + bufnr = api.nvim_get_current_buf() + end + + local highlighter = STHighlighter.active[bufnr] + if not highlighter then + return + end + + highlighter:reset() + highlighter:send_request() +end + +--- |lsp-handler| for the method `workspace/semanticTokens/refresh` +--- +--- Refresh requests are sent by the server to indicate a project-wide change +--- that requires all tokens to be re-requested by the client. This handler will +--- invalidate the current results of all buffers and automatically kick off a +--- new request for buffers that are displayed in a window. For those that aren't, a +--- the BufWinEnter event should take care of it next time it's displayed. +--- +---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#semanticTokens_refreshRequest +handlers['workspace/semanticTokens/refresh'] = function(err, _, ctx) + if err then + return vim.NIL + end + + for _, bufnr in ipairs(vim.lsp.get_buffers_by_client_id(ctx.client_id)) do + local highlighter = STHighlighter.active[bufnr] + if highlighter and highlighter.client_state[ctx.client_id] then + highlighter:mark_dirty(ctx.client_id) + + if not vim.tbl_isempty(vim.fn.win_findbuf(bufnr)) then + highlighter:send_request() + end + end + end + + return vim.NIL +end + +local namespace = api.nvim_create_namespace('vim_lsp_semantic_tokens') +api.nvim_set_decoration_provider(namespace, { + on_win = function(_, _, bufnr, topline, botline) + local highlighter = STHighlighter.active[bufnr] + if highlighter then + highlighter:on_win(topline, botline) + end + end, +}) + +--- for testing only! there is no guarantee of API stability with this! +--- +---@private +M.__STHighlighter = STHighlighter + +return M diff --git a/runtime/lua/vim/lsp/sync.lua b/runtime/lua/vim/lsp/sync.lua index 0d65e86b55..826352f036 100644 --- a/runtime/lua/vim/lsp/sync.lua +++ b/runtime/lua/vim/lsp/sync.lua @@ -392,7 +392,7 @@ end ---@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 +---@returns table TextDocumentContentChangeEvent see https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocumentContentChangeEvent function M.compute_diff( prev_lines, curr_lines, diff --git a/runtime/lua/vim/lsp/util.lua b/runtime/lua/vim/lsp/util.lua index 283099bbcf..38051e6410 100644 --- a/runtime/lua/vim/lsp/util.lua +++ b/runtime/lua/vim/lsp/util.lua @@ -1,6 +1,5 @@ local protocol = require('vim.lsp.protocol') local snippet = require('vim.lsp._snippet') -local vim = vim local validate = vim.validate local api = vim.api local list_extend = vim.list_extend @@ -110,6 +109,15 @@ local function split_lines(value) return split(value, '\n', true) end +---@private +local function create_window_without_focus() + local prev = vim.api.nvim_get_current_win() + vim.cmd.new() + local new = vim.api.nvim_get_current_win() + vim.api.nvim_set_current_win(prev) + return new +end + --- Convert byte index to `encoding` index. --- Convenience wrapper around vim.str_utfindex ---@param line string line to be indexed @@ -459,35 +467,52 @@ function M.apply_text_edits(text_edits, bufnr, offset_encoding) text = split(text_edit.newText, '\n', true), } - -- 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 max = api.nvim_buf_line_count(bufnr) - if max <= e.start_row or max <= e.end_row then - local len = #(get_line(bufnr, max - 1) or '') - if max <= e.start_row then - e.start_row = max - 1 - e.start_col = len - table.insert(e.text, 1, '') - end + -- If the whole edit is after the lines in the buffer we can simply add the new text to the end + -- of the buffer. + if max <= e.start_row then + api.nvim_buf_set_lines(bufnr, max, max, false, e.text) + else + local last_line_len = #(get_line(bufnr, math.min(e.end_row, max - 1)) or '') + -- Some LSP servers may return +1 range of the buffer content but nvim_buf_set_text can't + -- accept it so we should fix it here. if max <= e.end_row then e.end_row = max - 1 - e.end_col = len + e.end_col = last_line_len + has_eol_text_edit = true + else + -- If the replacement is over the end of a line (i.e. e.end_col is out of bounds and the + -- replacement text ends with a newline We can likely assume that the replacement is assumed + -- to be meant to replace the newline with another newline and we need to make sure this + -- doesn't add an extra empty line. E.g. when the last line to be replaced contains a '\r' + -- in the file some servers (clangd on windows) will include that character in the line + -- while nvim_buf_set_text doesn't count it as part of the line. + if + e.end_col > last_line_len + and #text_edit.newText > 0 + and string.sub(text_edit.newText, -1) == '\n' + then + table.remove(e.text, #e.text) + end end - has_eol_text_edit = true - end - api.nvim_buf_set_text(bufnr, e.start_row, e.start_col, e.end_row, e.end_col, e.text) - - -- Fix cursor position. - local row_count = (e.end_row - e.start_row) + 1 - if e.end_row < cursor.row then - cursor.row = cursor.row + (#e.text - row_count) - is_cursor_fixed = true - elseif e.end_row == cursor.row and e.end_col <= cursor.col then - cursor.row = cursor.row + (#e.text - row_count) - cursor.col = #e.text[#e.text] + (cursor.col - e.end_col) - if #e.text == 1 then - cursor.col = cursor.col + e.start_col + -- Make sure we don't go out of bounds for e.end_col + e.end_col = math.min(last_line_len, e.end_col) + + api.nvim_buf_set_text(bufnr, e.start_row, e.start_col, e.end_row, e.end_col, e.text) + + -- Fix cursor position. + local row_count = (e.end_row - e.start_row) + 1 + if e.end_row < cursor.row then + cursor.row = cursor.row + (#e.text - row_count) + is_cursor_fixed = true + elseif e.end_row == cursor.row and e.end_col <= cursor.col then + cursor.row = cursor.row + (#e.text - row_count) + cursor.col = #e.text[#e.text] + (cursor.col - e.end_col) + if #e.text == 1 then + cursor.col = cursor.col + e.start_col + end + is_cursor_fixed = true end - is_cursor_fixed = true end end @@ -755,8 +780,11 @@ local function create_file(change) -- from spec: Overwrite wins over `ignoreIfExists` local fname = vim.uri_to_fname(change.uri) if not opts.ignoreIfExists or opts.overwrite then + vim.fn.mkdir(vim.fs.dirname(fname), 'p') local file = io.open(fname, 'w') - file:close() + if file then + file:close() + end end vim.fn.bufadd(fname) end @@ -828,7 +856,7 @@ end --- `textDocument/signatureHelp`, and potentially others. --- ---@param input (`MarkedString` | `MarkedString[]` | `MarkupContent`) ----@param contents (table, optional, default `{}`) List of strings to extend with converted lines +---@param contents (table|nil) List of strings to extend with converted lines. Defaults to {}. ---@returns {contents}, extended with lines of converted markdown. ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_hover function M.convert_input_to_markdown_lines(input, contents) @@ -887,8 +915,8 @@ function M.convert_signature_help_to_markdown_lines(signature_help, ft, triggers return end --The active signature. If omitted or the value lies outside the range of - --`signatures` the value defaults to zero or is ignored if `signatures.length - --=== 0`. Whenever possible implementors should make an active decision about + --`signatures` the value defaults to zero or is ignored if `signatures.length == 0`. + --Whenever possible implementors should make an active decision about --the active signature and shouldn't rely on a default value. local contents = {} local active_hl @@ -987,6 +1015,7 @@ end --- - border (string or table) override `border` --- - focusable (string or table) override `focusable` --- - zindex (string or table) override `zindex`, defaults to 50 +--- - relative ("mouse"|"cursor") defaults to "cursor" ---@returns (table) Options function M.make_floating_popup_options(width, height, opts) validate({ @@ -1001,7 +1030,8 @@ function M.make_floating_popup_options(width, height, opts) local anchor = '' local row, col - local lines_above = vim.fn.winline() - 1 + local lines_above = opts.relative == 'mouse' and vim.fn.getmousepos().line - 1 + or vim.fn.winline() - 1 local lines_below = vim.fn.winheight(0) - lines_above if lines_above < lines_below then @@ -1014,7 +1044,9 @@ function M.make_floating_popup_options(width, height, opts) row = 0 end - if vim.fn.wincol() + width + (opts.offset_x or 0) <= api.nvim_get_option('columns') then + local wincol = opts.relative == 'mouse' and vim.fn.getmousepos().column or vim.fn.wincol() + + if wincol + width + (opts.offset_x or 0) <= api.nvim_get_option('columns') then anchor = anchor .. 'W' col = 0 else @@ -1022,64 +1054,103 @@ function M.make_floating_popup_options(width, height, opts) col = 1 end + local title = (opts.border and opts.title) and opts.title or nil + local title_pos + + if title then + title_pos = opts.title_pos or 'center' + end + return { anchor = anchor, col = col + (opts.offset_x or 0), height = height, focusable = opts.focusable, - relative = 'cursor', + relative = opts.relative == 'mouse' and 'mouse' or 'cursor', row = row + (opts.offset_y or 0), style = 'minimal', width = width, border = opts.border or default_border, zindex = opts.zindex or 50, + title = title, + title_pos = title_pos, } end ---- Jumps to a location. +--- Shows document and optionally jumps to the location. --- ---@param location table (`Location`|`LocationLink`) ----@param offset_encoding string utf-8|utf-16|utf-32 (required) ----@param reuse_win boolean Jump to existing window if buffer is already opened. ----@returns `true` if the jump succeeded -function M.jump_to_location(location, offset_encoding, reuse_win) +---@param offset_encoding "utf-8" | "utf-16" | "utf-32" +---@param opts table|nil options +--- - reuse_win (boolean) Jump to existing window if buffer is already open. +--- - focus (boolean) Whether to focus/jump to location if possible. Defaults to true. +---@return boolean `true` if succeeded +function M.show_document(location, offset_encoding, opts) -- location may be Location or LocationLink local uri = location.uri or location.targetUri if uri == nil then - return + return false end if offset_encoding == nil then - vim.notify_once( - 'jump_to_location must be called with valid offset encoding', - vim.log.levels.WARN - ) + vim.notify_once('show_document must be called with valid offset encoding', vim.log.levels.WARN) end local bufnr = vim.uri_to_bufnr(uri) - -- Save position in jumplist - vim.cmd("normal! m'") - -- Push a new item into tagstack - local from = { vim.fn.bufnr('%'), vim.fn.line('.'), vim.fn.col('.'), 0 } - local items = { { tagname = vim.fn.expand('<cword>'), from = from } } - vim.fn.settagstack(vim.fn.win_getid(), { items = items }, 't') + opts = opts or {} + local focus = vim.F.if_nil(opts.focus, true) + if focus then + -- Save position in jumplist + vim.cmd("normal! m'") - --- Jump to new location (adjusting for UTF-16 encoding of characters) - local win = reuse_win and bufwinid(bufnr) - if win then + -- Push a new item into tagstack + local from = { vim.fn.bufnr('%'), vim.fn.line('.'), vim.fn.col('.'), 0 } + local items = { { tagname = vim.fn.expand('<cword>'), from = from } } + vim.fn.settagstack(vim.fn.win_getid(), { items = items }, 't') + end + + local win = opts.reuse_win and bufwinid(bufnr) + or focus and api.nvim_get_current_win() + or create_window_without_focus() + + api.nvim_buf_set_option(bufnr, 'buflisted', true) + api.nvim_win_set_buf(win, bufnr) + if focus then api.nvim_set_current_win(win) - else - api.nvim_buf_set_option(bufnr, 'buflisted', true) - api.nvim_set_current_buf(bufnr) end + + -- location may be Location or LocationLink local range = location.range or location.targetSelectionRange - local row = range.start.line - local col = get_line_byte_from_position(bufnr, range.start, offset_encoding) - api.nvim_win_set_cursor(0, { row + 1, col }) - -- Open folds under the cursor - vim.cmd('normal! zv') + if range then + --- Jump to new location (adjusting for encoding of characters) + local row = range.start.line + local col = get_line_byte_from_position(bufnr, range.start, offset_encoding) + api.nvim_win_set_cursor(win, { row + 1, col }) + api.nvim_win_call(win, function() + -- Open folds under the cursor + vim.cmd('normal! zv') + end) + end + return true end +--- Jumps to a location. +--- +---@param location table (`Location`|`LocationLink`) +---@param offset_encoding "utf-8" | "utf-16" | "utf-32" +---@param reuse_win boolean|nil Jump to existing window if buffer is already open. +---@return boolean `true` if the jump succeeded +function M.jump_to_location(location, offset_encoding, reuse_win) + if offset_encoding == nil then + vim.notify_once( + 'jump_to_location must be called with valid offset encoding', + vim.log.levels.WARN + ) + end + + return M.show_document(location, offset_encoding, { reuse_win = reuse_win, focus = true }) +end + --- Previews a location in a floating window --- --- behavior depends on type of location: @@ -1194,7 +1265,7 @@ function M.stylize_markdown(bufnr, contents, opts) -- when ft is nil, we get the ft from the regex match local matchers = { block = { nil, '```+([a-zA-Z0-9_]*)', '```+' }, - pre = { '', '<pre>', '</pre>' }, + pre = { nil, '<pre>([a-z0-9]*)', '</pre>' }, code = { '', '<code>', '</code>' }, text = { 'text', '<text>', '</text>' }, } @@ -1219,8 +1290,6 @@ function M.stylize_markdown(bufnr, contents, opts) -- Clean up contents = M._trim(contents, opts) - -- Insert blank line separator after code block? - local add_sep = opts.separator == nil and true or opts.separator local stripped = {} local highlights = {} -- keep track of lnums that contain markdown @@ -1248,7 +1317,7 @@ function M.stylize_markdown(bufnr, contents, opts) finish = #stripped, }) -- add a separator, but not on the last line - if add_sep and i < #contents then + if opts.separator and i < #contents then table.insert(stripped, '---') markdown_lines[#stripped] = true end @@ -1484,7 +1553,7 @@ end --- ---@param contents table of lines to show in window ---@param syntax string of syntax to set for opened buffer ----@param opts table with optional fields (additional keys are passed on to |vim.api.nvim_open_win()|) +---@param opts table with optional fields (additional keys are passed on to |nvim_open_win()|) --- - height: (number) height of floating window --- - width: (number) width of floating window --- - wrap: (boolean, default true) wrap long lines @@ -1612,7 +1681,7 @@ do --[[ References ]] ---@param bufnr number Buffer id ---@param references table List of `DocumentHighlight` objects to highlight ---@param offset_encoding string One of "utf-8", "utf-16", "utf-32". - ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-3-17/#documentHighlight + ---@see https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocumentContentChangeEvent function M.buf_highlight_references(bufnr, references, offset_encoding) validate({ bufnr = { bufnr, 'n', true }, @@ -1799,7 +1868,7 @@ end --- CAUTION: Modifies the input in-place! --- ---@param lines (table) list of lines ----@returns (string) filetype or 'markdown' if it was unchanged. +---@returns (string) filetype or "markdown" if it was unchanged. function M.try_trim_markdown_code_blocks(lines) local language_id = lines[1]:match('^```(.*)') if language_id then @@ -1843,7 +1912,7 @@ end --- Creates a `TextDocumentPositionParams` object for the current buffer and cursor position. --- ---@param window number|nil: window handle or 0 for current, defaults to current ----@param offset_encoding string utf-8|utf-16|utf-32|nil defaults to `offset_encoding` of first client of buffer of `window` +---@param offset_encoding string|nil utf-8|utf-16|utf-32|nil defaults to `offset_encoding` of first client of buffer of `window` ---@returns `TextDocumentPositionParams` object ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocumentPositionParams function M.make_position_params(window, offset_encoding) @@ -1866,7 +1935,7 @@ function M._get_offset_encoding(bufnr) local offset_encoding - for _, client in pairs(vim.lsp.buf_get_clients(bufnr)) do + for _, client in pairs(vim.lsp.get_active_clients({ bufnr = bufnr })) do if client.offset_encoding == nil then vim.notify_once( string.format( @@ -1972,7 +2041,7 @@ function M.make_workspace_params(added, removed) end --- Returns indentation size. --- ----@see |shiftwidth| +---@see 'shiftwidth' ---@param bufnr (number|nil): Buffer handle, defaults to current ---@returns (number) indentation size function M.get_effective_tabstop(bufnr) |