From 00dc12c5d8454a2d3c6806710f63bbb446076e96 Mon Sep 17 00:00:00 2001 From: Ashkan Kiani Date: Wed, 13 Nov 2019 12:55:26 -0800 Subject: lua LSP client: initial implementation (#11336) Mainly configuration and RPC infrastructure can be considered "done". Specific requests and their callbacks will be improved later (and also served by plugins). There are also some TODO:s for the client itself, like incremental updates. Co-authored by at-tjdevries and at-h-michael, with many review/suggestion contributions. --- runtime/lua/vim/lsp/builtin_callbacks.lua | 296 ++++++++++ runtime/lua/vim/lsp/log.lua | 95 +++ runtime/lua/vim/lsp/protocol.lua | 936 ++++++++++++++++++++++++++++++ runtime/lua/vim/lsp/rpc.lua | 451 ++++++++++++++ runtime/lua/vim/lsp/util.lua | 557 ++++++++++++++++++ 5 files changed, 2335 insertions(+) create mode 100644 runtime/lua/vim/lsp/builtin_callbacks.lua create mode 100644 runtime/lua/vim/lsp/log.lua create mode 100644 runtime/lua/vim/lsp/protocol.lua create mode 100644 runtime/lua/vim/lsp/rpc.lua create mode 100644 runtime/lua/vim/lsp/util.lua (limited to 'runtime/lua/vim/lsp') diff --git a/runtime/lua/vim/lsp/builtin_callbacks.lua b/runtime/lua/vim/lsp/builtin_callbacks.lua new file mode 100644 index 0000000000..cc739ce3ad --- /dev/null +++ b/runtime/lua/vim/lsp/builtin_callbacks.lua @@ -0,0 +1,296 @@ +--- Implements the following default callbacks: +-- +-- vim.api.nvim_buf_set_lines(0, 0, 0, false, vim.tbl_keys(vim.lsp.builtin_callbacks)) +-- + +-- textDocument/completion +-- textDocument/declaration +-- textDocument/definition +-- textDocument/hover +-- textDocument/implementation +-- textDocument/publishDiagnostics +-- textDocument/rename +-- textDocument/signatureHelp +-- textDocument/typeDefinition +-- TODO codeLens/resolve +-- TODO completionItem/resolve +-- TODO documentLink/resolve +-- TODO textDocument/codeAction +-- TODO textDocument/codeLens +-- TODO textDocument/documentHighlight +-- TODO textDocument/documentLink +-- TODO textDocument/documentSymbol +-- TODO textDocument/formatting +-- TODO textDocument/onTypeFormatting +-- TODO textDocument/rangeFormatting +-- TODO textDocument/references +-- window/logMessage +-- window/showMessage + +local log = require 'vim.lsp.log' +local protocol = require 'vim.lsp.protocol' +local util = require 'vim.lsp.util' +local api = vim.api + +local function split_lines(value) + return vim.split(value, '\n', true) +end + +local builtin_callbacks = {} + +-- textDocument/completion +-- https://microsoft.github.io/language-server-protocol/specification#textDocument_completion +builtin_callbacks['textDocument/completion'] = function(_, _, result) + if not result or vim.tbl_isempty(result) then + return + end + local pos = api.nvim_win_get_cursor(0) + local row, col = pos[1], pos[2] + local line = assert(api.nvim_buf_get_lines(0, row-1, row, false)[1]) + local line_to_cursor = line:sub(col+1) + + local matches = util.text_document_completion_list_to_complete_items(result, line_to_cursor) + local match_result = vim.fn.matchstrpos(line_to_cursor, '\\k\\+$') + local match_start, match_finish = match_result[2], match_result[3] + + vim.fn.complete(col + 1 - (match_finish - match_start), matches) +end + +-- textDocument/rename +builtin_callbacks['textDocument/rename'] = function(_, _, result) + if not result then return end + util.workspace_apply_workspace_edit(result) +end + +local function uri_to_bufnr(uri) + return vim.fn.bufadd((vim.uri_to_fname(uri))) +end + +builtin_callbacks['textDocument/publishDiagnostics'] = function(_, _, result) + if not result then return end + local uri = result.uri + local bufnr = uri_to_bufnr(uri) + if not bufnr then + api.nvim_err_writeln(string.format("LSP.publishDiagnostics: Couldn't find buffer for %s", uri)) + return + end + util.buf_clear_diagnostics(bufnr) + util.buf_diagnostics_save_positions(bufnr, result.diagnostics) + util.buf_diagnostics_underline(bufnr, result.diagnostics) + util.buf_diagnostics_virtual_text(bufnr, result.diagnostics) + -- util.buf_loclist(bufnr, result.diagnostics) +end + +-- textDocument/hover +-- https://microsoft.github.io/language-server-protocol/specification#textDocument_hover +-- @params MarkedString | MarkedString[] | MarkupContent +builtin_callbacks['textDocument/hover'] = function(_, _, result) + if result == nil or vim.tbl_isempty(result) then + return + end + + if result.contents ~= nil then + local markdown_lines = util.convert_input_to_markdown_lines(result.contents) + if vim.tbl_isempty(markdown_lines) then + markdown_lines = { 'No information available' } + end + util.open_floating_preview(markdown_lines, 'markdown') + end +end + +builtin_callbacks['textDocument/peekDefinition'] = function(_, _, result) + if result == nil or vim.tbl_isempty(result) then return end + -- TODO(ashkan) what to do with multiple locations? + result = result[1] + local bufnr = uri_to_bufnr(result.uri) + assert(bufnr) + local start = result.range.start + local finish = result.range["end"] + util.open_floating_peek_preview(bufnr, start, finish, { offset_x = 1 }) + util.open_floating_preview({"*Peek:*", string.rep(" ", finish.character - start.character + 1) }, 'markdown', { offset_y = -(finish.line - start.line) }) +end + +--- Convert SignatureHelp response to preview contents. +-- https://microsoft.github.io/language-server-protocol/specifications/specification-3-14/#textDocument_signatureHelp +local function signature_help_to_preview_contents(input) + if not input.signatures then + return + end + --The active signature. If omitted or the value lies outside the range of + --`signatures` the value defaults to zero or is ignored if `signatures.length + --=== 0`. Whenever possible implementors should make an active decision about + --the active signature and shouldn't rely on a default value. + local contents = {} + local active_signature = input.activeSignature or 0 + -- If the activeSignature is not inside the valid range, then clip it. + if active_signature >= #input.signatures then + active_signature = 0 + end + local signature = input.signatures[active_signature + 1] + if not signature then + return + end + vim.list_extend(contents, split_lines(signature.label)) + if signature.documentation then + util.convert_input_to_markdown_lines(signature.documentation, contents) + end + if input.parameters then + local active_parameter = input.activeParameter or 0 + -- If the activeParameter is not inside the valid range, then clip it. + if active_parameter >= #input.parameters then + active_parameter = 0 + end + local parameter = signature.parameters and signature.parameters[active_parameter] + if parameter then + --[=[ + --Represents a parameter of a callable-signature. A parameter can + --have a label and a doc-comment. + interface ParameterInformation { + --The label of this parameter information. + -- + --Either a string or an inclusive start and exclusive end offsets within its containing + --signature label. (see SignatureInformation.label). The offsets are based on a UTF-16 + --string representation as `Position` and `Range` does. + -- + --*Note*: a label of type string should be a substring of its containing signature label. + --Its intended use case is to highlight the parameter label part in the `SignatureInformation.label`. + label: string | [number, number]; + --The human-readable doc-comment of this parameter. Will be shown + --in the UI but can be omitted. + documentation?: string | MarkupContent; + } + --]=] + -- TODO highlight parameter + if parameter.documentation then + util.convert_input_to_markdown_lines(parameter.documentation, contents) + end + end + end + return contents +end + +-- textDocument/signatureHelp +-- https://microsoft.github.io/language-server-protocol/specification#textDocument_signatureHelp +builtin_callbacks['textDocument/signatureHelp'] = function(_, _, result) + if result == nil or vim.tbl_isempty(result) then + return + end + + -- TODO show empty popup when signatures is empty? + if #result.signatures > 0 then + local markdown_lines = signature_help_to_preview_contents(result) + if vim.tbl_isempty(markdown_lines) then + markdown_lines = { 'No signature available' } + end + util.open_floating_preview(markdown_lines, 'markdown') + end +end + +local function update_tagstack() + local bufnr = api.nvim_get_current_buf() + local line = vim.fn.line('.') + local col = vim.fn.col('.') + local tagname = vim.fn.expand('') + local item = { bufnr = bufnr, from = { bufnr, line, col, 0 }, tagname = tagname } + local winid = vim.fn.win_getid() + local tagstack = vim.fn.gettagstack(winid) + + local action + + if tagstack.length == tagstack.curidx then + action = 'r' + tagstack.items[tagstack.curidx] = item + elseif tagstack.length > tagstack.curidx then + action = 'r' + if tagstack.curidx > 1 then + tagstack.items = table.insert(tagstack.items[tagstack.curidx - 1], item) + else + tagstack.items = { item } + end + else + action = 'a' + tagstack.items = { item } + end + + tagstack.curidx = tagstack.curidx + 1 + vim.fn.settagstack(winid, tagstack, action) +end + +local function handle_location(result) + -- We can sometimes get a list of locations, so set the first value as the + -- only value we want to handle + -- TODO(ashkan) was this correct^? We could use location lists. + if result[1] ~= nil then + result = result[1] + end + if result.uri == nil then + api.nvim_err_writeln('[LSP] Could not find a valid location') + return + end + local result_file = vim.uri_to_fname(result.uri) + local bufnr = vim.fn.bufadd(result_file) + update_tagstack() + api.nvim_set_current_buf(bufnr) + local start = result.range.start + api.nvim_win_set_cursor(0, {start.line + 1, start.character}) +end + +local function location_callback(_, method, result) + if result == nil or vim.tbl_isempty(result) then + local _ = log.info() and log.info(method, 'No location found') + return nil + end + handle_location(result) + return true +end + +local location_callbacks = { + -- https://microsoft.github.io/language-server-protocol/specification#textDocument_declaration + 'textDocument/declaration'; + -- https://microsoft.github.io/language-server-protocol/specification#textDocument_definition + 'textDocument/definition'; + -- https://microsoft.github.io/language-server-protocol/specification#textDocument_implementation + 'textDocument/implementation'; + -- https://microsoft.github.io/language-server-protocol/specification#textDocument_typeDefinition + 'textDocument/typeDefinition'; +} + +for _, location_method in ipairs(location_callbacks) do + builtin_callbacks[location_method] = location_callback +end + +local function log_message(_, _, result, client_id) + local message_type = result.type + local message = result.message + 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 + api.nvim_err_writeln(string.format("LSP[%s] client has shut down after sending the message", client_name)) + end + if message_type == protocol.MessageType.Error then + -- Might want to not use err_writeln, + -- but displaying a message with red highlights or something + api.nvim_err_writeln(string.format("LSP[%s] %s", client_name, message)) + else + local message_type_name = protocol.MessageType[message_type] + api.nvim_out_write(string.format("LSP[%s][%s] %s\n", client_name, message_type_name, message)) + end + return result +end + +builtin_callbacks['window/showMessage'] = log_message +builtin_callbacks['window/logMessage'] = log_message + +-- Add boilerplate error validation and logging for all of these. +for k, fn in pairs(builtin_callbacks) do + builtin_callbacks[k] = function(err, method, params, client_id) + local _ = log.debug() and log.debug('builtin_callback', method, { params = params, client_id = client_id, err = err }) + if err then + error(tostring(err)) + end + return fn(err, method, params, client_id) + end +end + +return builtin_callbacks +-- vim:sw=2 ts=2 et diff --git a/runtime/lua/vim/lsp/log.lua b/runtime/lua/vim/lsp/log.lua new file mode 100644 index 0000000000..974eaae38c --- /dev/null +++ b/runtime/lua/vim/lsp/log.lua @@ -0,0 +1,95 @@ +-- Logger for language client plugin. + +local log = {} + +-- Log level dictionary with reverse lookup as well. +-- +-- Can be used to lookup the number from the name or the name from the number. +-- Levels by name: 'trace', 'debug', 'info', 'warn', 'error' +-- Level numbers begin with 'trace' at 0 +log.levels = { + TRACE = 0; + DEBUG = 1; + INFO = 2; + WARN = 3; + ERROR = 4; + -- FATAL = 4; +} + +-- Default log level is warn. +local current_log_level = log.levels.WARN +local log_date_format = "%FT%H:%M:%SZ%z" + +do + local path_sep = vim.loop.os_uname().sysname == "Windows" and "\\" or "/" + local function path_join(...) + return table.concat(vim.tbl_flatten{...}, path_sep) + end + local logfilename = path_join(vim.fn.stdpath('data'), 'vim-lsp.log') + + --- Return the log filename. + function log.get_filename() + return logfilename + end + + vim.fn.mkdir(vim.fn.stdpath('data'), "p") + local logfile = assert(io.open(logfilename, "a+")) + for level, levelnr in pairs(log.levels) do + -- Also export the log level on the root object. + log[level] = levelnr + -- Set the lowercase name as the main use function. + -- If called without arguments, it will check whether the log level is + -- greater than or equal to this one. When called with arguments, it will + -- log at that level (if applicable, it is checked either way). + -- + -- Recommended usage: + -- ``` + -- local _ = log.warn() and log.warn("123") + -- ``` + -- + -- This way you can avoid string allocations if the log level isn't high enough. + log[level:lower()] = function(...) + local argc = select("#", ...) + if levelnr < current_log_level then return false end + if argc == 0 then return true end + local info = debug.getinfo(2, "Sl") + local fileinfo = string.format("%s:%s", info.short_src, info.currentline) + local parts = { table.concat({"[", level, "]", os.date(log_date_format), "]", fileinfo, "]"}, " ") } + for i = 1, argc do + local arg = select(i, ...) + if arg == nil then + table.insert(parts, "nil") + else + table.insert(parts, vim.inspect(arg, {newline=''})) + end + end + logfile:write(table.concat(parts, '\t'), "\n") + logfile:flush() + end + end + -- Add some space to make it easier to distinguish different neovim runs. + logfile:write("\n") +end + +-- This is put here on purpose after the loop above so that it doesn't +-- interfere with iterating the levels +vim.tbl_add_reverse_lookup(log.levels) + +function log.set_level(level) + if type(level) == 'string' then + current_log_level = assert(log.levels[level:upper()], string.format("Invalid log level: %q", level)) + else + assert(type(level) == 'number', "level must be a number or string") + assert(log.levels[level], string.format("Invalid log level: %d", level)) + current_log_level = level + end +end + +-- Return whether the level is sufficient for logging. +-- @param level number log level +function log.should_log(level) + return level >= current_log_level +end + +return log +-- vim:sw=2 ts=2 et diff --git a/runtime/lua/vim/lsp/protocol.lua b/runtime/lua/vim/lsp/protocol.lua new file mode 100644 index 0000000000..1413a88ce2 --- /dev/null +++ b/runtime/lua/vim/lsp/protocol.lua @@ -0,0 +1,936 @@ +-- Protocol for the Microsoft Language Server Protocol (mslsp) + +local protocol = {} + +local function ifnil(a, b) + if a == nil then return b end + return a +end + + +--[=[ +-- Useful for interfacing with: +-- https://github.com/microsoft/language-server-protocol/blob/gh-pages/_specifications/specification-3-14.md +-- https://github.com/microsoft/language-server-protocol/raw/gh-pages/_specifications/specification-3-14.md +function transform_schema_comments() + nvim.command [[silent! '<,'>g/\/\*\*\|\*\/\|^$/d]] + nvim.command [[silent! '<,'>s/^\(\s*\) \* \=\(.*\)/\1--\2/]] +end +function transform_schema_to_table() + transform_schema_comments() + nvim.command [[silent! '<,'>s/: \S\+//]] + nvim.command [[silent! '<,'>s/export const //]] + nvim.command [[silent! '<,'>s/export namespace \(\S*\)\s*{/protocol.\1 = {/]] + nvim.command [[silent! '<,'>s/namespace \(\S*\)\s*{/protocol.\1 = {/]] +end +--]=] + +local constants = { + DiagnosticSeverity = { + -- Reports an error. + Error = 1; + -- Reports a warning. + Warning = 2; + -- Reports an information. + Information = 3; + -- Reports a hint. + Hint = 4; + }; + + MessageType = { + -- An error message. + Error = 1; + -- A warning message. + Warning = 2; + -- An information message. + Info = 3; + -- A log message. + Log = 4; + }; + + -- The file event type. + FileChangeType = { + -- The file got created. + Created = 1; + -- The file got changed. + Changed = 2; + -- The file got deleted. + Deleted = 3; + }; + + -- The kind of a completion entry. + CompletionItemKind = { + Text = 1; + Method = 2; + Function = 3; + Constructor = 4; + Field = 5; + Variable = 6; + Class = 7; + Interface = 8; + Module = 9; + Property = 10; + Unit = 11; + Value = 12; + Enum = 13; + Keyword = 14; + Snippet = 15; + Color = 16; + File = 17; + Reference = 18; + Folder = 19; + EnumMember = 20; + Constant = 21; + Struct = 22; + Event = 23; + Operator = 24; + TypeParameter = 25; + }; + + -- How a completion was triggered + CompletionTriggerKind = { + -- Completion was triggered by typing an identifier (24x7 code + -- complete), manual invocation (e.g Ctrl+Space) or via API. + Invoked = 1; + -- Completion was triggered by a trigger character specified by + -- the `triggerCharacters` properties of the `CompletionRegistrationOptions`. + TriggerCharacter = 2; + -- Completion was re-triggered as the current completion list is incomplete. + TriggerForIncompleteCompletions = 3; + }; + + -- A document highlight kind. + DocumentHighlightKind = { + -- A textual occurrence. + Text = 1; + -- Read-access of a symbol, like reading a variable. + Read = 2; + -- Write-access of a symbol, like writing to a variable. + Write = 3; + }; + + -- A symbol kind. + SymbolKind = { + File = 1; + Module = 2; + Namespace = 3; + Package = 4; + Class = 5; + Method = 6; + Property = 7; + Field = 8; + Constructor = 9; + Enum = 10; + Interface = 11; + Function = 12; + Variable = 13; + Constant = 14; + String = 15; + Number = 16; + Boolean = 17; + Array = 18; + Object = 19; + Key = 20; + Null = 21; + EnumMember = 22; + Struct = 23; + Event = 24; + Operator = 25; + TypeParameter = 26; + }; + + -- Represents reasons why a text document is saved. + TextDocumentSaveReason = { + -- Manually triggered, e.g. by the user pressing save, by starting debugging, + -- or by an API call. + Manual = 1; + -- Automatic after a delay. + AfterDelay = 2; + -- When the editor lost focus. + FocusOut = 3; + }; + + ErrorCodes = { + -- Defined by JSON RPC + ParseError = -32700; + InvalidRequest = -32600; + MethodNotFound = -32601; + InvalidParams = -32602; + InternalError = -32603; + serverErrorStart = -32099; + serverErrorEnd = -32000; + ServerNotInitialized = -32002; + UnknownErrorCode = -32001; + -- Defined by the protocol. + RequestCancelled = -32800; + ContentModified = -32801; + }; + + -- Describes the content type that a client supports in various + -- result literals like `Hover`, `ParameterInfo` or `CompletionItem`. + -- + -- Please note that `MarkupKinds` must not start with a `$`. This kinds + -- are reserved for internal usage. + MarkupKind = { + -- Plain text is supported as a content format + PlainText = 'plaintext'; + -- Markdown is supported as a content format + Markdown = 'markdown'; + }; + + ResourceOperationKind = { + -- Supports creating new files and folders. + Create = 'create'; + -- Supports renaming existing files and folders. + Rename = 'rename'; + -- Supports deleting existing files and folders. + Delete = 'delete'; + }; + + FailureHandlingKind = { + -- Applying the workspace change is simply aborted if one of the changes provided + -- fails. All operations executed before the failing operation stay executed. + Abort = 'abort'; + -- All operations are executed transactionally. That means they either all + -- succeed or no changes at all are applied to the workspace. + Transactional = 'transactional'; + -- If the workspace edit contains only textual file changes they are executed transactionally. + -- If resource changes (create, rename or delete file) are part of the change the failure + -- handling strategy is abort. + TextOnlyTransactional = 'textOnlyTransactional'; + -- The client tries to undo the operations already executed. But there is no + -- guarantee that this succeeds. + Undo = 'undo'; + }; + + -- Known error codes for an `InitializeError`; + InitializeError = { + -- If the protocol version provided by the client can't be handled by the server. + -- @deprecated This initialize error got replaced by client capabilities. There is + -- no version handshake in version 3.0x + unknownProtocolVersion = 1; + }; + + -- Defines how the host (editor) should sync document changes to the language server. + TextDocumentSyncKind = { + -- Documents should not be synced at all. + None = 0; + -- Documents are synced by always sending the full content + -- of the document. + Full = 1; + -- Documents are synced by sending the full content on open. + -- After that only incremental updates to the document are + -- send. + Incremental = 2; + }; + + WatchKind = { + -- Interested in create events. + Create = 1; + -- Interested in change events + Change = 2; + -- Interested in delete events + Delete = 4; + }; + + -- Defines whether the insert text in a completion item should be interpreted as + -- plain text or a snippet. + InsertTextFormat = { + -- The primary text to be inserted is treated as a plain string. + PlainText = 1; + -- The primary text to be inserted is treated as a snippet. + -- + -- A snippet can define tab stops and placeholders with `$1`, `$2` + -- and `${3:foo};`. `$0` defines the final tab stop, it defaults to + -- the end of the snippet. Placeholders with equal identifiers are linked, + -- that is typing in one will update others too. + Snippet = 2; + }; + + -- A set of predefined code action kinds + CodeActionKind = { + -- Empty kind. + Empty = ''; + -- Base kind for quickfix actions + QuickFix = 'quickfix'; + -- Base kind for refactoring actions + Refactor = 'refactor'; + -- Base kind for refactoring extraction actions + -- + -- Example extract actions: + -- + -- - Extract method + -- - Extract function + -- - Extract variable + -- - Extract interface from class + -- - ... + RefactorExtract = 'refactor.extract'; + -- Base kind for refactoring inline actions + -- + -- Example inline actions: + -- + -- - Inline function + -- - Inline variable + -- - Inline constant + -- - ... + RefactorInline = 'refactor.inline'; + -- Base kind for refactoring rewrite actions + -- + -- Example rewrite actions: + -- + -- - Convert JavaScript function to class + -- - Add or remove parameter + -- - Encapsulate field + -- - Make method static + -- - Move method to base class + -- - ... + RefactorRewrite = 'refactor.rewrite'; + -- Base kind for source actions + -- + -- Source code actions apply to the entire file. + Source = 'source'; + -- Base kind for an organize imports source action + SourceOrganizeImports = 'source.organizeImports'; + }; +} + +for k, v in pairs(constants) do + vim.tbl_add_reverse_lookup(v) + protocol[k] = v +end + +--[=[ +--Text document specific client capabilities. +export interface TextDocumentClientCapabilities { + synchronization?: { + --Whether text document synchronization supports dynamic registration. + dynamicRegistration?: boolean; + --The client supports sending will save notifications. + willSave?: boolean; + --The client supports sending a will save request and + --waits for a response providing text edits which will + --be applied to the document before it is saved. + willSaveWaitUntil?: boolean; + --The client supports did save notifications. + didSave?: boolean; + } + --Capabilities specific to the `textDocument/completion` + completion?: { + --Whether completion supports dynamic registration. + dynamicRegistration?: boolean; + --The client supports the following `CompletionItem` specific + --capabilities. + completionItem?: { + --The client supports snippets as insert text. + -- + --A snippet can define tab stops and placeholders with `$1`, `$2` + --and `${3:foo}`. `$0` defines the final tab stop, it defaults to + --the end of the snippet. Placeholders with equal identifiers are linked, + --that is typing in one will update others too. + snippetSupport?: boolean; + --The client supports commit characters on a completion item. + commitCharactersSupport?: boolean + --The client supports the following content formats for the documentation + --property. The order describes the preferred format of the client. + documentationFormat?: MarkupKind[]; + --The client supports the deprecated property on a completion item. + deprecatedSupport?: boolean; + --The client supports the preselect property on a completion item. + preselectSupport?: boolean; + } + completionItemKind?: { + --The completion item kind values the client supports. When this + --property exists the client also guarantees that it will + --handle values outside its set gracefully and falls back + --to a default value when unknown. + -- + --If this property is not present the client only supports + --the completion items kinds from `Text` to `Reference` as defined in + --the initial version of the protocol. + valueSet?: CompletionItemKind[]; + }, + --The client supports to send additional context information for a + --`textDocument/completion` request. + contextSupport?: boolean; + }; + --Capabilities specific to the `textDocument/hover` + hover?: { + --Whether hover supports dynamic registration. + dynamicRegistration?: boolean; + --The client supports the follow content formats for the content + --property. The order describes the preferred format of the client. + contentFormat?: MarkupKind[]; + }; + --Capabilities specific to the `textDocument/signatureHelp` + signatureHelp?: { + --Whether signature help supports dynamic registration. + dynamicRegistration?: boolean; + --The client supports the following `SignatureInformation` + --specific properties. + signatureInformation?: { + --The client supports the follow content formats for the documentation + --property. The order describes the preferred format of the client. + documentationFormat?: MarkupKind[]; + --Client capabilities specific to parameter information. + parameterInformation?: { + --The client supports processing label offsets instead of a + --simple label string. + -- + --Since 3.14.0 + labelOffsetSupport?: boolean; + } + }; + }; + --Capabilities specific to the `textDocument/references` + references?: { + --Whether references supports dynamic registration. + dynamicRegistration?: boolean; + }; + --Capabilities specific to the `textDocument/documentHighlight` + documentHighlight?: { + --Whether document highlight supports dynamic registration. + dynamicRegistration?: boolean; + }; + --Capabilities specific to the `textDocument/documentSymbol` + documentSymbol?: { + --Whether document symbol supports dynamic registration. + dynamicRegistration?: boolean; + --Specific capabilities for the `SymbolKind`. + symbolKind?: { + --The symbol kind values the client supports. When this + --property exists the client also guarantees that it will + --handle values outside its set gracefully and falls back + --to a default value when unknown. + -- + --If this property is not present the client only supports + --the symbol kinds from `File` to `Array` as defined in + --the initial version of the protocol. + valueSet?: SymbolKind[]; + } + --The client supports hierarchical document symbols. + hierarchicalDocumentSymbolSupport?: boolean; + }; + --Capabilities specific to the `textDocument/formatting` + formatting?: { + --Whether formatting supports dynamic registration. + dynamicRegistration?: boolean; + }; + --Capabilities specific to the `textDocument/rangeFormatting` + rangeFormatting?: { + --Whether range formatting supports dynamic registration. + dynamicRegistration?: boolean; + }; + --Capabilities specific to the `textDocument/onTypeFormatting` + onTypeFormatting?: { + --Whether on type formatting supports dynamic registration. + dynamicRegistration?: boolean; + }; + --Capabilities specific to the `textDocument/declaration` + declaration?: { + --Whether declaration supports dynamic registration. If this is set to `true` + --the client supports the new `(TextDocumentRegistrationOptions & StaticRegistrationOptions)` + --return value for the corresponding server capability as well. + dynamicRegistration?: boolean; + --The client supports additional metadata in the form of declaration links. + -- + --Since 3.14.0 + linkSupport?: boolean; + }; + --Capabilities specific to the `textDocument/definition`. + -- + --Since 3.14.0 + definition?: { + --Whether definition supports dynamic registration. + dynamicRegistration?: boolean; + --The client supports additional metadata in the form of definition links. + linkSupport?: boolean; + }; + --Capabilities specific to the `textDocument/typeDefinition` + -- + --Since 3.6.0 + typeDefinition?: { + --Whether typeDefinition supports dynamic registration. If this is set to `true` + --the client supports the new `(TextDocumentRegistrationOptions & StaticRegistrationOptions)` + --return value for the corresponding server capability as well. + dynamicRegistration?: boolean; + --The client supports additional metadata in the form of definition links. + -- + --Since 3.14.0 + linkSupport?: boolean; + }; + --Capabilities specific to the `textDocument/implementation`. + -- + --Since 3.6.0 + implementation?: { + --Whether implementation supports dynamic registration. If this is set to `true` + --the client supports the new `(TextDocumentRegistrationOptions & StaticRegistrationOptions)` + --return value for the corresponding server capability as well. + dynamicRegistration?: boolean; + --The client supports additional metadata in the form of definition links. + -- + --Since 3.14.0 + linkSupport?: boolean; + }; + --Capabilities specific to the `textDocument/codeAction` + codeAction?: { + --Whether code action supports dynamic registration. + dynamicRegistration?: boolean; + --The client support code action literals as a valid + --response of the `textDocument/codeAction` request. + -- + --Since 3.8.0 + codeActionLiteralSupport?: { + --The code action kind is support with the following value + --set. + codeActionKind: { + --The code action kind values the client supports. When this + --property exists the client also guarantees that it will + --handle values outside its set gracefully and falls back + --to a default value when unknown. + valueSet: CodeActionKind[]; + }; + }; + }; + --Capabilities specific to the `textDocument/codeLens` + codeLens?: { + --Whether code lens supports dynamic registration. + dynamicRegistration?: boolean; + }; + --Capabilities specific to the `textDocument/documentLink` + documentLink?: { + --Whether document link supports dynamic registration. + dynamicRegistration?: boolean; + }; + --Capabilities specific to the `textDocument/documentColor` and the + --`textDocument/colorPresentation` request. + -- + --Since 3.6.0 + colorProvider?: { + --Whether colorProvider supports dynamic registration. If this is set to `true` + --the client supports the new `(ColorProviderOptions & TextDocumentRegistrationOptions & StaticRegistrationOptions)` + --return value for the corresponding server capability as well. + dynamicRegistration?: boolean; + } + --Capabilities specific to the `textDocument/rename` + rename?: { + --Whether rename supports dynamic registration. + dynamicRegistration?: boolean; + --The client supports testing for validity of rename operations + --before execution. + prepareSupport?: boolean; + }; + --Capabilities specific to `textDocument/publishDiagnostics`. + publishDiagnostics?: { + --Whether the clients accepts diagnostics with related information. + relatedInformation?: boolean; + }; + --Capabilities specific to `textDocument/foldingRange` requests. + -- + --Since 3.10.0 + foldingRange?: { + --Whether implementation supports dynamic registration for folding range providers. If this is set to `true` + --the client supports the new `(FoldingRangeProviderOptions & TextDocumentRegistrationOptions & StaticRegistrationOptions)` + --return value for the corresponding server capability as well. + dynamicRegistration?: boolean; + --The maximum number of folding ranges that the client prefers to receive per document. The value serves as a + --hint, servers are free to follow the limit. + rangeLimit?: number; + --If set, the client signals that it only supports folding complete lines. If set, client will + --ignore specified `startCharacter` and `endCharacter` properties in a FoldingRange. + lineFoldingOnly?: boolean; + }; +} +--]=] + +--[=[ +--Workspace specific client capabilities. +export interface WorkspaceClientCapabilities { + --The client supports applying batch edits to the workspace by supporting + --the request 'workspace/applyEdit' + applyEdit?: boolean; + --Capabilities specific to `WorkspaceEdit`s + workspaceEdit?: { + --The client supports versioned document changes in `WorkspaceEdit`s + documentChanges?: boolean; + --The resource operations the client supports. Clients should at least + --support 'create', 'rename' and 'delete' files and folders. + resourceOperations?: ResourceOperationKind[]; + --The failure handling strategy of a client if applying the workspace edit + --fails. + failureHandling?: FailureHandlingKind; + }; + --Capabilities specific to the `workspace/didChangeConfiguration` notification. + didChangeConfiguration?: { + --Did change configuration notification supports dynamic registration. + dynamicRegistration?: boolean; + }; + --Capabilities specific to the `workspace/didChangeWatchedFiles` notification. + didChangeWatchedFiles?: { + --Did change watched files notification supports dynamic registration. Please note + --that the current protocol doesn't support static configuration for file changes + --from the server side. + dynamicRegistration?: boolean; + }; + --Capabilities specific to the `workspace/symbol` request. + symbol?: { + --Symbol request supports dynamic registration. + dynamicRegistration?: boolean; + --Specific capabilities for the `SymbolKind` in the `workspace/symbol` request. + symbolKind?: { + --The symbol kind values the client supports. When this + --property exists the client also guarantees that it will + --handle values outside its set gracefully and falls back + --to a default value when unknown. + -- + --If this property is not present the client only supports + --the symbol kinds from `File` to `Array` as defined in + --the initial version of the protocol. + valueSet?: SymbolKind[]; + } + }; + --Capabilities specific to the `workspace/executeCommand` request. + executeCommand?: { + --Execute command supports dynamic registration. + dynamicRegistration?: boolean; + }; + --The client has support for workspace folders. + -- + --Since 3.6.0 + workspaceFolders?: boolean; + --The client supports `workspace/configuration` requests. + -- + --Since 3.6.0 + configuration?: boolean; +} +--]=] + +function protocol.make_client_capabilities() + return { + textDocument = { + synchronization = { + dynamicRegistration = false; + + -- TODO(ashkan) Send textDocument/willSave before saving (BufWritePre) + willSave = false; + + -- TODO(ashkan) Implement textDocument/willSaveWaitUntil + willSaveWaitUntil = false; + + -- Send textDocument/didSave after saving (BufWritePost) + didSave = true; + }; + completion = { + dynamicRegistration = false; + completionItem = { + + -- TODO(tjdevries): Is it possible to implement this in plain lua? + snippetSupport = false; + commitCharactersSupport = false; + preselectSupport = false; + deprecatedSupport = false; + documentationFormat = { protocol.MarkupKind.Markdown; protocol.MarkupKind.PlainText }; + }; + completionItemKind = { + valueSet = (function() + local res = {} + for k in pairs(protocol.CompletionItemKind) do + if type(k) == 'number' then table.insert(res, k) end + end + return res + end)(); + }; + + -- TODO(tjdevries): Implement this + contextSupport = false; + }; + hover = { + dynamicRegistration = false; + contentFormat = { protocol.MarkupKind.Markdown; protocol.MarkupKind.PlainText }; + }; + signatureHelp = { + dynamicRegistration = false; + signatureInformation = { + documentationFormat = { protocol.MarkupKind.Markdown; protocol.MarkupKind.PlainText }; + -- parameterInformation = { + -- labelOffsetSupport = false; + -- }; + }; + }; + references = { + dynamicRegistration = false; + }; + documentHighlight = { + dynamicRegistration = false + }; + -- documentSymbol = { + -- dynamicRegistration = false; + -- symbolKind = { + -- valueSet = (function() + -- local res = {} + -- for k in pairs(protocol.SymbolKind) do + -- if type(k) == 'string' then table.insert(res, k) end + -- end + -- return res + -- end)(); + -- }; + -- hierarchicalDocumentSymbolSupport = false; + -- }; + }; + workspace = nil; + experimental = nil; + } +end + +function protocol.make_text_document_position_params() + local position = vim.api.nvim_win_get_cursor(0) + return { + textDocument = { + uri = vim.uri_from_bufnr() + }; + position = { + line = position[1] - 1; + character = position[2]; + } + } +end + +--[=[ +export interface DocumentFilter { + --A language id, like `typescript`. + language?: string; + --A Uri [scheme](#Uri.scheme), like `file` or `untitled`. + scheme?: string; + --A glob pattern, like `*.{ts,js}`. + -- + --Glob patterns can have the following syntax: + --- `*` to match one or more characters in a path segment + --- `?` to match on one character in a path segment + --- `**` to match any number of path segments, including none + --- `{}` to group conditions (e.g. `**​/*.{ts,js}` matches all TypeScript and JavaScript files) + --- `[]` to declare a range of characters to match in a path segment (e.g., `example.[0-9]` to match on `example.0`, `example.1`, …) + --- `[!...]` to negate a range of characters to match in a path segment (e.g., `example.[!0-9]` to match on `example.a`, `example.b`, but not `example.0`) + pattern?: string; +} +--]=] + +--[[ +--Static registration options to be returned in the initialize request. +interface StaticRegistrationOptions { + --The id used to register the request. The id can be used to deregister + --the request again. See also Registration#id. + id?: string; +} + +export interface DocumentFilter { + --A language id, like `typescript`. + language?: string; + --A Uri [scheme](#Uri.scheme), like `file` or `untitled`. + scheme?: string; + --A glob pattern, like `*.{ts,js}`. + -- + --Glob patterns can have the following syntax: + --- `*` to match one or more characters in a path segment + --- `?` to match on one character in a path segment + --- `**` to match any number of path segments, including none + --- `{}` to group conditions (e.g. `**​/*.{ts,js}` matches all TypeScript and JavaScript files) + --- `[]` to declare a range of characters to match in a path segment (e.g., `example.[0-9]` to match on `example.0`, `example.1`, …) + --- `[!...]` to negate a range of characters to match in a path segment (e.g., `example.[!0-9]` to match on `example.a`, `example.b`, but not `example.0`) + pattern?: string; +} +export type DocumentSelector = DocumentFilter[]; +export interface TextDocumentRegistrationOptions { + --A document selector to identify the scope of the registration. If set to null + --the document selector provided on the client side will be used. + documentSelector: DocumentSelector | null; +} + +--Code Action options. +export interface CodeActionOptions { + --CodeActionKinds that this server may return. + -- + --The list of kinds may be generic, such as `CodeActionKind.Refactor`, or the server + --may list out every specific kind they provide. + codeActionKinds?: CodeActionKind[]; +} + +interface ServerCapabilities { + --Defines how text documents are synced. Is either a detailed structure defining each notification or + --for backwards compatibility the TextDocumentSyncKind number. If omitted it defaults to `TextDocumentSyncKind.None`. + textDocumentSync?: TextDocumentSyncOptions | number; + --The server provides hover support. + hoverProvider?: boolean; + --The server provides completion support. + completionProvider?: CompletionOptions; + --The server provides signature help support. + signatureHelpProvider?: SignatureHelpOptions; + --The server provides goto definition support. + definitionProvider?: boolean; + --The server provides Goto Type Definition support. + -- + --Since 3.6.0 + typeDefinitionProvider?: boolean | (TextDocumentRegistrationOptions & StaticRegistrationOptions); + --The server provides Goto Implementation support. + -- + --Since 3.6.0 + implementationProvider?: boolean | (TextDocumentRegistrationOptions & StaticRegistrationOptions); + --The server provides find references support. + referencesProvider?: boolean; + --The server provides document highlight support. + documentHighlightProvider?: boolean; + --The server provides document symbol support. + documentSymbolProvider?: boolean; + --The server provides workspace symbol support. + workspaceSymbolProvider?: boolean; + --The server provides code actions. The `CodeActionOptions` return type is only + --valid if the client signals code action literal support via the property + --`textDocument.codeAction.codeActionLiteralSupport`. + codeActionProvider?: boolean | CodeActionOptions; + --The server provides code lens. + codeLensProvider?: CodeLensOptions; + --The server provides document formatting. + documentFormattingProvider?: boolean; + --The server provides document range formatting. + documentRangeFormattingProvider?: boolean; + --The server provides document formatting on typing. + documentOnTypeFormattingProvider?: DocumentOnTypeFormattingOptions; + --The server provides rename support. RenameOptions may only be + --specified if the client states that it supports + --`prepareSupport` in its initial `initialize` request. + renameProvider?: boolean | RenameOptions; + --The server provides document link support. + documentLinkProvider?: DocumentLinkOptions; + --The server provides color provider support. + -- + --Since 3.6.0 + colorProvider?: boolean | ColorProviderOptions | (ColorProviderOptions & TextDocumentRegistrationOptions & StaticRegistrationOptions); + --The server provides folding provider support. + -- + --Since 3.10.0 + foldingRangeProvider?: boolean | FoldingRangeProviderOptions | (FoldingRangeProviderOptions & TextDocumentRegistrationOptions & StaticRegistrationOptions); + --The server provides go to declaration support. + -- + --Since 3.14.0 + declarationProvider?: boolean | (TextDocumentRegistrationOptions & StaticRegistrationOptions); + --The server provides execute command support. + executeCommandProvider?: ExecuteCommandOptions; + --Workspace specific server capabilities + workspace?: { + --The server supports workspace folder. + -- + --Since 3.6.0 + workspaceFolders?: { + * The server has support for workspace folders + supported?: boolean; + * Whether the server wants to receive workspace folder + * change notifications. + * + * If a strings is provided the string is treated as a ID + * under which the notification is registered on the client + * side. The ID can be used to unregister for these events + * using the `client/unregisterCapability` request. + changeNotifications?: string | boolean; + } + } + --Experimental server capabilities. + experimental?: any; +} +--]] +function protocol.resolve_capabilities(server_capabilities) + local general_properties = {} + local text_document_sync_properties + do + local TextDocumentSyncKind = protocol.TextDocumentSyncKind + local textDocumentSync = server_capabilities.textDocumentSync + if textDocumentSync == nil then + -- Defaults if omitted. + text_document_sync_properties = { + text_document_open_close = false; + text_document_did_change = TextDocumentSyncKind.None; +-- text_document_did_change = false; + text_document_will_save = false; + text_document_will_save_wait_until = false; + text_document_save = false; + text_document_save_include_text = false; + } + elseif type(textDocumentSync) == 'number' then + -- Backwards compatibility + if not TextDocumentSyncKind[textDocumentSync] then + return nil, "Invalid server TextDocumentSyncKind for textDocumentSync" + end + text_document_sync_properties = { + text_document_open_close = true; + text_document_did_change = textDocumentSync; + text_document_will_save = false; + text_document_will_save_wait_until = false; + text_document_save = false; + text_document_save_include_text = false; + } + elseif type(textDocumentSync) == 'table' then + text_document_sync_properties = { + text_document_open_close = ifnil(textDocumentSync.openClose, false); + text_document_did_change = ifnil(textDocumentSync.change, TextDocumentSyncKind.None); + text_document_will_save = ifnil(textDocumentSync.willSave, false); + text_document_will_save_wait_until = ifnil(textDocumentSync.willSaveWaitUntil, false); + text_document_save = ifnil(textDocumentSync.save, false); + text_document_save_include_text = ifnil(textDocumentSync.save and textDocumentSync.save.includeText, false); + } + else + return nil, string.format("Invalid type for textDocumentSync: %q", type(textDocumentSync)) + end + end + general_properties.hover = server_capabilities.hoverProvider or false + general_properties.goto_definition = server_capabilities.definitionProvider or false + general_properties.find_references = server_capabilities.referencesProvider or false + general_properties.document_highlight = server_capabilities.documentHighlightProvider or false + general_properties.document_symbol = server_capabilities.documentSymbolProvider or false + general_properties.workspace_symbol = server_capabilities.workspaceSymbolProvider or false + general_properties.document_formatting = server_capabilities.documentFormattingProvider or false + general_properties.document_range_formatting = server_capabilities.documentRangeFormattingProvider or false + + if server_capabilities.codeActionProvider == nil then + general_properties.code_action = false + elseif type(server_capabilities.codeActionProvider) == 'boolean' then + general_properties.code_action = server_capabilities.codeActionProvider + elseif type(server_capabilities.codeActionProvider) == 'table' then + -- TODO(ashkan) support CodeActionKind + general_properties.code_action = false + else + error("The server sent invalid codeActionProvider") + end + + if server_capabilities.implementationProvider == nil then + general_properties.implementation = false + elseif type(server_capabilities.implementationProvider) == 'boolean' then + general_properties.implementation = server_capabilities.implementationProvider + elseif type(server_capabilities.implementationProvider) == 'table' then + -- TODO(ashkan) support more detailed implementation options. + general_properties.implementation = false + else + error("The server sent invalid implementationProvider") + end + + local signature_help_properties + if server_capabilities.signatureHelpProvider == nil then + signature_help_properties = { + signature_help = false; + signature_help_trigger_characters = {}; + } + elseif type(server_capabilities.signatureHelpProvider) == 'table' then + signature_help_properties = { + signature_help = true; + -- The characters that trigger signature help automatically. + signature_help_trigger_characters = server_capabilities.signatureHelpProvider.triggerCharacters or {}; + } + else + error("The server sent invalid signatureHelpProvider") + end + + return vim.tbl_extend("error" + , text_document_sync_properties + , signature_help_properties + , general_properties + ) +end + +return protocol +-- vim:sw=2 ts=2 et diff --git a/runtime/lua/vim/lsp/rpc.lua b/runtime/lua/vim/lsp/rpc.lua new file mode 100644 index 0000000000..e0ec8863d6 --- /dev/null +++ b/runtime/lua/vim/lsp/rpc.lua @@ -0,0 +1,451 @@ +local uv = vim.loop +local log = require('vim.lsp.log') +local protocol = require('vim.lsp.protocol') +local validate, schedule, schedule_wrap = vim.validate, vim.schedule, vim.schedule_wrap + +-- TODO replace with a better implementation. +local function json_encode(data) + local status, result = pcall(vim.fn.json_encode, data) + if status then + return result + else + return nil, result + end +end +local function json_decode(data) + local status, result = pcall(vim.fn.json_decode, data) + if status then + return result + else + return nil, result + end +end + +local function is_dir(filename) + local stat = vim.loop.fs_stat(filename) + return stat and stat.type == 'directory' or false +end + +local NIL = vim.NIL +local function convert_NIL(v) + if v == NIL then return nil end + return v +end + +-- If a dictionary is passed in, turn it into a list of string of "k=v" +-- Accepts a table which can be composed of k=v strings or map-like +-- specification, such as: +-- +-- ``` +-- { +-- "PRODUCTION=false"; +-- "PATH=/usr/bin/"; +-- PORT = 123; +-- HOST = "0.0.0.0"; +-- } +-- ``` +-- +-- Non-string values will be cast with `tostring` +local function force_env_list(final_env) + if final_env then + local env = final_env + final_env = {} + for k,v in pairs(env) do + -- If it's passed in as a dict, then convert to list of "k=v" + if type(k) == "string" then + table.insert(final_env, k..'='..tostring(v)) + elseif type(v) == 'string' then + table.insert(final_env, v) + else + -- TODO is this right or should I exception here? + -- Try to coerce other values to string. + table.insert(final_env, tostring(v)) + end + end + return final_env + end +end + +local function format_message_with_content_length(encoded_message) + return table.concat { + 'Content-Length: '; tostring(#encoded_message); '\r\n\r\n'; + encoded_message; + } +end + +--- Parse an LSP Message's header +-- @param header: The header to parse. +local function parse_headers(header) + if type(header) ~= 'string' then + return nil + end + local headers = {} + for line in vim.gsplit(header, '\r\n', true) do + if line == '' then + break + end + local key, value = line:match("^%s*(%S+)%s*:%s*(.+)%s*$") + if key then + key = key:lower():gsub('%-', '_') + headers[key] = value + else + local _ = log.error() and log.error("invalid header line %q", line) + error(string.format("invalid header line %q", line)) + end + end + headers.content_length = tonumber(headers.content_length) + or error(string.format("Content-Length not found in headers. %q", header)) + return headers +end + +-- This is the start of any possible header patterns. The gsub converts it to a +-- case insensitive pattern. +local header_start_pattern = ("content"):gsub("%w", function(c) return "["..c..c:upper().."]" end) + +local function request_parser_loop() + local buffer = '' + while true do + -- A message can only be complete if it has a double CRLF and also the full + -- payload, so first let's check for the CRLFs + local start, finish = buffer:find('\r\n\r\n', 1, true) + -- Start parsing the headers + if start then + -- This is a workaround for servers sending initial garbage before + -- sending headers, such as if a bash script sends stdout. It assumes + -- that we know all of the headers ahead of time. At this moment, the + -- only valid headers start with "Content-*", so that's the thing we will + -- be searching for. + -- TODO(ashkan) I'd like to remove this, but it seems permanent :( + local buffer_start = buffer:find(header_start_pattern) + local headers = parse_headers(buffer:sub(buffer_start, start-1)) + buffer = buffer:sub(finish+1) + local content_length = headers.content_length + -- Keep waiting for data until we have enough. + while #buffer < content_length do + buffer = buffer..(coroutine.yield() + or error("Expected more data for the body. The server may have died.")) -- TODO hmm. + end + local body = buffer:sub(1, content_length) + buffer = buffer:sub(content_length + 1) + -- Yield our data. + buffer = buffer..(coroutine.yield(headers, body) + or error("Expected more data for the body. The server may have died.")) -- TODO hmm. + else + -- Get more data since we don't have enough. + buffer = buffer..(coroutine.yield() + or error("Expected more data for the header. The server may have died.")) -- TODO hmm. + end + end +end + +local client_errors = vim.tbl_add_reverse_lookup { + INVALID_SERVER_MESSAGE = 1; + INVALID_SERVER_JSON = 2; + NO_RESULT_CALLBACK_FOUND = 3; + READ_ERROR = 4; + NOTIFICATION_HANDLER_ERROR = 5; + SERVER_REQUEST_HANDLER_ERROR = 6; + SERVER_RESULT_CALLBACK_ERROR = 7; +} + +local function format_rpc_error(err) + validate { + err = { err, 't' }; + } + local code_name = assert(protocol.ErrorCodes[err.code], "err.code is invalid") + local message_parts = {"RPC", code_name} + if err.message then + table.insert(message_parts, "message = ") + table.insert(message_parts, string.format("%q", err.message)) + end + if err.data then + table.insert(message_parts, "data = ") + table.insert(message_parts, vim.inspect(err.data)) + end + return table.concat(message_parts, ' ') +end + +local function rpc_response_error(code, message, data) + -- TODO should this error or just pick a sane error (like InternalError)? + local code_name = assert(protocol.ErrorCodes[code], 'Invalid rpc error code') + return setmetatable({ + code = code; + message = message or code_name; + data = data; + }, { + __tostring = format_rpc_error; + }) +end + +local default_handlers = {} +function default_handlers.notification(method, params) + local _ = log.debug() and log.debug('notification', method, params) +end +function default_handlers.server_request(method, params) + local _ = log.debug() and log.debug('server_request', method, params) + return nil, rpc_response_error(protocol.ErrorCodes.MethodNotFound) +end +function default_handlers.on_exit(code, signal) + local _ = log.info() and log.info("client exit", { code = code, signal = signal }) +end +function default_handlers.on_error(code, err) + local _ = log.error() and log.error('client_error:', client_errors[code], err) +end + +--- Create and start an RPC client. +-- @param cmd [ +local function create_and_start_client(cmd, cmd_args, handlers, 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' }; + handlers = { handlers, 't', true }; + } + + if not (vim.fn.executable(cmd) == 1) then + error(string.format("The given command %q is not executable.", cmd)) + end + if handlers then + local user_handlers = handlers + handlers = {} + for handle_name, default_handler in pairs(default_handlers) do + local user_handler = user_handlers[handle_name] + if user_handler then + if type(user_handler) ~= 'function' then + error(string.format("handler.%s must be a function", handle_name)) + end + -- server_request is wrapped elsewhere. + if not (handle_name == 'server_request' + or handle_name == 'on_exit') -- TODO this blocks the loop exiting for some reason. + then + user_handler = schedule_wrap(user_handler) + end + handlers[handle_name] = user_handler + else + handlers[handle_name] = default_handler + end + end + else + handlers = default_handlers + 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 handle, pid + do + local function onexit(code, signal) + stdin:close() + stdout:close() + stderr:close() + handle:close() + -- Make sure that message_callbacks can be gc'd. + message_callbacks = nil + handlers.on_exit(code, signal) + end + local spawn_params = { + args = cmd_args; + stdio = {stdin, stdout, stderr}; + } + if extra_spawn_params then + spawn_params.cwd = extra_spawn_params.cwd + if spawn_params.cwd then + assert(is_dir(spawn_params.cwd), "cwd must be a directory") + end + spawn_params.env = force_env_list(extra_spawn_params.env) + end + handle, pid = uv.spawn(cmd, spawn_params, onexit) + end + + local function encode_and_send(payload) + local _ = log.debug() and log.debug("rpc.send.payload", payload) + if handle:is_closing() then return false end + -- TODO(ashkan) remove this once we have a Lua json_encode + schedule(function() + local encoded = assert(json_encode(payload)) + stdin:write(format_message_with_content_length(encoded)) + end) + return true + end + + local function send_notification(method, params) + local _ = log.debug() and log.debug("rpc.notify", method, params) + return encode_and_send { + jsonrpc = "2.0"; + method = method; + params = params; + } + end + + local function send_response(request_id, err, result) + return encode_and_send { + id = request_id; + jsonrpc = "2.0"; + error = err; + result = result; + } + end + + local function send_request(method, params, callback) + validate { + callback = { callback, 'f' }; + } + 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 + message_callbacks[message_id] = schedule_wrap(callback) + return result, message_id + else + return false + end + end + + stderr:read_start(function(_err, chunk) + if chunk then + local _ = log.error() and log.error("rpc", cmd, "stderr", chunk) + end + end) + + local function on_error(errkind, ...) + assert(client_errors[errkind]) + -- TODO what to do if this fails? + pcall(handlers.on_error, errkind, ...) + end + local function pcall_handler(errkind, status, head, ...) + if not status then + on_error(errkind, head, ...) + return status, head + end + return status, head, ... + end + local function try_call(errkind, fn, ...) + return pcall_handler(errkind, pcall(fn, ...)) + 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. + + local function handle_body(body) + local decoded, err = json_decode(body) + if not decoded then + on_error(client_errors.INVALID_SERVER_JSON, err) + end + local _ = log.debug() and log.debug("decoded", decoded) + + if type(decoded.method) == 'string' and decoded.id then + -- Server Request + decoded.params = convert_NIL(decoded.params) + -- Schedule here so that the users functions don't trigger an error and + -- we can still use the result. + schedule(function() + local status, result + status, result, err = try_call(client_errors.SERVER_REQUEST_HANDLER_ERROR, + handlers.server_request, decoded.method, decoded.params) + local _ = log.debug() and log.debug("server_request: callback result", { 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 + error(string.format("method %q: either a result or an error must be sent to the server in response", decoded.method)) + end + if err then + assert(type(err) == 'table', "err must be a table. Use rpc_response_error to help format errors.") + local code_name = assert(protocol.ErrorCodes[err.code], "Errors must use protocol.ErrorCodes. Use rpc_response_error to help format errors.") + err.message = err.message or code_name + end + else + -- On an exception, result will contain the error message. + 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 or decoded.error) then + -- Server Result + decoded.error = convert_NIL(decoded.error) + decoded.result = convert_NIL(decoded.result) + + -- We sent a number, so we expect a number. + local result_id = tonumber(decoded.id) + local callback = 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; + }) + 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 + decoded.params = convert_NIL(decoded.params) + try_call(client_errors.NOTIFICATION_HANDLER_ERROR, + handlers.notification, decoded.method, decoded.params) + else + -- Invalid server message + on_error(client_errors.INVALID_SERVER_MESSAGE, decoded) + end + end + -- TODO(ashkan) remove this once we have a Lua json_decode + handle_body = schedule_wrap(handle_body) + + local request_parser = coroutine.wrap(request_parser_loop) + request_parser() + 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 = '' + else + break + end + end + end) + + return { + pid = pid; + handle = handle; + request = send_request; + notify = send_notification; + } +end + +return { + start = create_and_start_client; + rpc_response_error = rpc_response_error; + format_rpc_error = format_rpc_error; + client_errors = client_errors; +} +-- vim:sw=2 ts=2 et diff --git a/runtime/lua/vim/lsp/util.lua b/runtime/lua/vim/lsp/util.lua new file mode 100644 index 0000000000..f96e0f01a8 --- /dev/null +++ b/runtime/lua/vim/lsp/util.lua @@ -0,0 +1,557 @@ +local protocol = require 'vim.lsp.protocol' +local validate = vim.validate +local api = vim.api + +local M = {} + +local split = vim.split +local function split_lines(value) + return split(value, '\n', true) +end + +local list_extend = vim.list_extend + +--- Find the longest shared prefix between prefix and word. +-- e.g. remove_prefix("123tes", "testing") == "ting" +local function remove_prefix(prefix, word) + local max_prefix_length = math.min(#prefix, #word) + local prefix_length = 0 + for i = 1, max_prefix_length do + local current_line_suffix = prefix:sub(-i) + local word_prefix = word:sub(1, i) + if current_line_suffix == word_prefix then + prefix_length = i + end + end + return word:sub(prefix_length + 1) +end + +local function resolve_bufnr(bufnr) + if bufnr == nil or bufnr == 0 then + return api.nvim_get_current_buf() + end + return bufnr +end + +-- local valid_windows_path_characters = "[^<>:\"/\\|?*]" +-- local valid_unix_path_characters = "[^/]" +-- https://github.com/davidm/lua-glob-pattern +-- https://stackoverflow.com/questions/1976007/what-characters-are-forbidden-in-windows-and-linux-directory-names +-- function M.glob_to_regex(glob) +-- end + +--- Apply the TextEdit response. +-- @params TextEdit [table] see https://microsoft.github.io/language-server-protocol/specification +function M.text_document_apply_text_edit(text_edit, bufnr) + bufnr = resolve_bufnr(bufnr) + local range = text_edit.range + local start = range.start + local finish = range['end'] + local new_lines = split_lines(text_edit.newText) + if start.character == 0 and finish.character == 0 then + api.nvim_buf_set_lines(bufnr, start.line, finish.line, false, new_lines) + return + end + api.nvim_err_writeln('apply_text_edit currently only supports character ranges starting at 0') + error('apply_text_edit currently only supports character ranges starting at 0') + return + -- TODO test and finish this support for character ranges. +-- local lines = api.nvim_buf_get_lines(0, start.line, finish.line + 1, false) +-- local suffix = lines[#lines]:sub(finish.character+2) +-- local prefix = lines[1]:sub(start.character+2) +-- new_lines[#new_lines] = new_lines[#new_lines]..suffix +-- new_lines[1] = prefix..new_lines[1] +-- api.nvim_buf_set_lines(0, start.line, finish.line, false, new_lines) +end + +-- textDocument/completion response returns one of CompletionItem[], CompletionList or null. +-- https://microsoft.github.io/language-server-protocol/specification#textDocument_completion +function M.extract_completion_items(result) + if type(result) == 'table' and result.items then + return result.items + elseif result ~= nil then + return result + else + return {} + end +end + +--- Apply the TextDocumentEdit response. +-- @params TextDocumentEdit [table] see https://microsoft.github.io/language-server-protocol/specification +function M.text_document_apply_text_document_edit(text_document_edit, bufnr) + -- local text_document = text_document_edit.textDocument + -- TODO use text_document_version? + -- local text_document_version = text_document.version + + -- TODO technically, you could do this without doing multiple buf_get/set + -- by getting the full region (smallest line and largest line) and doing + -- the edits on the buffer, and then applying the buffer at the end. + -- I'm not sure if that's better. + for _, text_edit in ipairs(text_document_edit.edits) do + M.text_document_apply_text_edit(text_edit, bufnr) + end +end + +function M.get_current_line_to_cursor() + local pos = api.nvim_win_get_cursor(0) + local line = assert(api.nvim_buf_get_lines(0, pos[1]-1, pos[1], false)[1]) + return line:sub(pos[2]+1) +end + +--- Getting vim complete-items with incomplete flag. +-- @params CompletionItem[], CompletionList or nil (https://microsoft.github.io/language-server-protocol/specification#textDocument_completion) +-- @return { matches = complete-items table, incomplete = boolean } +function M.text_document_completion_list_to_complete_items(result, line_prefix) + local items = M.extract_completion_items(result) + if vim.tbl_isempty(items) then + return {} + end + -- Only initialize if we have some items. + if not line_prefix then + line_prefix = M.get_current_line_to_cursor() + end + + local matches = {} + + for _, completion_item in ipairs(items) do + local info = ' ' + local documentation = completion_item.documentation + if documentation then + if type(documentation) == 'string' and documentation ~= '' then + info = documentation + elseif type(documentation) == 'table' and type(documentation.value) == 'string' then + info = documentation.value + -- else + -- TODO(ashkan) Validation handling here? + end + end + + local word = completion_item.insertText or completion_item.label + + -- Ref: `:h complete-items` + table.insert(matches, { + word = remove_prefix(line_prefix, word), + abbr = completion_item.label, + kind = protocol.CompletionItemKind[completion_item.kind] or '', + menu = completion_item.detail or '', + info = info, + icase = 1, + dup = 0, + empty = 1, + }) + end + + return matches +end + +-- @params WorkspaceEdit [table] see https://microsoft.github.io/language-server-protocol/specification +function M.workspace_apply_workspace_edit(workspace_edit) + if workspace_edit.documentChanges then + for _, change in ipairs(workspace_edit.documentChanges) do + if change.kind then + -- TODO(ashkan) handle CreateFile/RenameFile/DeleteFile + error(string.format("Unsupported change: %q", vim.inspect(change))) + else + M.text_document_apply_text_document_edit(change) + end + end + return + end + + if workspace_edit.changes == nil or #workspace_edit.changes == 0 then + return + end + + for uri, changes in pairs(workspace_edit.changes) do + local fname = vim.uri_to_fname(uri) + -- TODO improve this approach. Try to edit open buffers without switching. + -- Not sure how to handle files which aren't open. This is deprecated + -- anyway, so I guess it could be left as is. + api.nvim_command('edit '..fname) + for _, change in ipairs(changes) do + M.text_document_apply_text_edit(change) + end + end +end + +--- Convert any of MarkedString | MarkedString[] | MarkupContent into markdown text lines +-- see https://microsoft.github.io/language-server-protocol/specifications/specification-3-14/#textDocument_hover +-- Useful for textDocument/hover, textDocument/signatureHelp, and potentially others. +function M.convert_input_to_markdown_lines(input, contents) + contents = contents or {} + -- MarkedString variation 1 + if type(input) == 'string' then + list_extend(contents, split_lines(input)) + else + assert(type(input) == 'table', "Expected a table for Hover.contents") + -- MarkupContent + if input.kind then + -- The kind can be either plaintext or markdown. However, either way we + -- will just be rendering markdown, so we handle them both the same way. + -- TODO these can have escaped/sanitized html codes in markdown. We + -- should make sure we handle this correctly. + + -- Some servers send input.value as empty, so let's ignore this :( + -- assert(type(input.value) == 'string') + list_extend(contents, split_lines(input.value or '')) + -- MarkupString variation 2 + elseif input.language then + -- Some servers send input.value as empty, so let's ignore this :( + -- assert(type(input.value) == 'string') + table.insert(contents, "```"..input.language) + list_extend(contents, split_lines(input.value or '')) + table.insert(contents, "```") + -- By deduction, this must be MarkedString[] + else + -- Use our existing logic to handle MarkedString + for _, marked_string in ipairs(input) do + M.convert_input_to_markdown_lines(marked_string, contents) + end + end + end + if contents[1] == '' or contents[1] == nil then + return {} + end + return contents +end + +function M.make_floating_popup_options(width, height, opts) + validate { + opts = { opts, 't', true }; + } + opts = opts or {} + validate { + ["opts.offset_x"] = { opts.offset_x, 'n', true }; + ["opts.offset_y"] = { opts.offset_y, 'n', true }; + } + + local anchor = '' + local row, col + + if vim.fn.winline() <= height then + anchor = anchor..'N' + row = 1 + else + anchor = anchor..'S' + row = 0 + end + + if vim.fn.wincol() + width <= api.nvim_get_option('columns') then + anchor = anchor..'W' + col = 0 + else + anchor = anchor..'E' + col = 1 + end + + return { + anchor = anchor, + col = col + (opts.offset_x or 0), + height = height, + relative = 'cursor', + row = row + (opts.offset_y or 0), + style = 'minimal', + width = width, + } +end + +function M.open_floating_preview(contents, filetype, opts) + validate { + contents = { contents, 't' }; + filetype = { filetype, 's', true }; + opts = { opts, 't', true }; + } + + -- Trim empty lines from the end. + for i = #contents, 1, -1 do + if #contents[i] == 0 then + table.remove(contents) + else + break + end + end + + local width = 0 + local height = #contents + for i, line in ipairs(contents) do + -- Clean up the input and add left pad. + line = " "..line:gsub("\r", "") + -- TODO(ashkan) use nvim_strdisplaywidth if/when that is introduced. + local line_width = vim.fn.strdisplaywidth(line) + width = math.max(line_width, width) + contents[i] = line + end + -- Add right padding of 1 each. + width = width + 1 + + local floating_bufnr = api.nvim_create_buf(false, true) + if filetype then + api.nvim_buf_set_option(floating_bufnr, 'filetype', filetype) + end + local float_option = M.make_floating_popup_options(width, height, opts) + local floating_winnr = api.nvim_open_win(floating_bufnr, false, float_option) + if filetype == 'markdown' then + api.nvim_win_set_option(floating_winnr, 'conceallevel', 2) + end + api.nvim_buf_set_lines(floating_bufnr, 0, -1, true, contents) + api.nvim_buf_set_option(floating_bufnr, 'modifiable', false) + api.nvim_command("autocmd CursorMoved ++once lua pcall(vim.api.nvim_win_close, "..floating_winnr..", true)") + return floating_bufnr, floating_winnr +end + +local function validate_lsp_position(pos) + validate { pos = {pos, 't'} } + validate { + line = {pos.line, 'n'}; + character = {pos.character, 'n'}; + } + return true +end + +function M.open_floating_peek_preview(bufnr, start, finish, opts) + validate { + bufnr = {bufnr, 'n'}; + start = {start, validate_lsp_position, 'valid start Position'}; + finish = {finish, validate_lsp_position, 'valid finish Position'}; + opts = { opts, 't', true }; + } + local width = math.max(finish.character - start.character + 1, 1) + local height = math.max(finish.line - start.line + 1, 1) + local floating_winnr = api.nvim_open_win(bufnr, false, M.make_floating_popup_options(width, height, opts)) + api.nvim_win_set_cursor(floating_winnr, {start.line+1, start.character}) + api.nvim_command("autocmd CursorMoved * ++once lua pcall(vim.api.nvim_win_close, "..floating_winnr..", true)") + return floating_winnr +end + + +local function highlight_range(bufnr, ns, hiname, start, finish) + if start[1] == finish[1] then + -- TODO care about encoding here since this is in byte index? + api.nvim_buf_add_highlight(bufnr, ns, hiname, start[1], start[2], finish[2]) + else + api.nvim_buf_add_highlight(bufnr, ns, hiname, start[1], start[2], -1) + for line = start[1] + 1, finish[1] - 1 do + api.nvim_buf_add_highlight(bufnr, ns, hiname, line, 0, -1) + end + api.nvim_buf_add_highlight(bufnr, ns, hiname, finish[1], 0, finish[2]) + end +end + +do + local all_buffer_diagnostics = {} + + local diagnostic_ns = api.nvim_create_namespace("vim_lsp_diagnostics") + + local default_severity_highlight = { + [protocol.DiagnosticSeverity.Error] = { guifg = "Red" }; + [protocol.DiagnosticSeverity.Warning] = { guifg = "Orange" }; + [protocol.DiagnosticSeverity.Information] = { guifg = "LightBlue" }; + [protocol.DiagnosticSeverity.Hint] = { guifg = "LightGrey" }; + } + + local underline_highlight_name = "LspDiagnosticsUnderline" + api.nvim_command(string.format("highlight %s gui=underline cterm=underline", underline_highlight_name)) + + local function find_color_rgb(color) + local rgb_hex = api.nvim_get_color_by_name(color) + validate { color = {color, function() return rgb_hex ~= -1 end, "valid color name"} } + return rgb_hex + end + + --- Determine whether to use black or white text + -- Ref: https://stackoverflow.com/a/1855903/837964 + -- https://stackoverflow.com/questions/596216/formula-to-determine-brightness-of-rgb-color + local function color_is_bright(r, g, b) + -- Counting the perceptive luminance - human eye favors green color + local luminance = (0.299*r + 0.587*g + 0.114*b)/255 + if luminance > 0.5 then + return true -- Bright colors, black font + else + return false -- Dark colors, white font + end + end + + local severity_highlights = {} + + function M.set_severity_highlights(highlights) + validate {highlights = {highlights, 't'}} + for severity, default_color in pairs(default_severity_highlight) do + local severity_name = protocol.DiagnosticSeverity[severity] + local highlight_name = "LspDiagnostics"..severity_name + local hi_info = highlights[severity] or default_color + -- Try to fill in the foreground color with a sane default. + if not hi_info.guifg and hi_info.guibg then + -- TODO(ashkan) move this out when bitop is guaranteed to be included. + local bit = require 'bit' + local band, rshift = bit.band, bit.rshift + local rgb = find_color_rgb(hi_info.guibg) + local is_bright = color_is_bright(rshift(rgb, 16), band(rshift(rgb, 8), 0xFF), band(rgb, 0xFF)) + hi_info.guifg = is_bright and "Black" or "White" + end + if not hi_info.ctermfg and hi_info.ctermbg then + -- TODO(ashkan) move this out when bitop is guaranteed to be included. + local bit = require 'bit' + local band, rshift = bit.band, bit.rshift + local rgb = find_color_rgb(hi_info.ctermbg) + local is_bright = color_is_bright(rshift(rgb, 16), band(rshift(rgb, 8), 0xFF), band(rgb, 0xFF)) + hi_info.ctermfg = is_bright and "Black" or "White" + end + local cmd_parts = {"highlight", highlight_name} + for k, v in pairs(hi_info) do + table.insert(cmd_parts, k.."="..v) + end + api.nvim_command(table.concat(cmd_parts, ' ')) + severity_highlights[severity] = highlight_name + end + end + + function M.buf_clear_diagnostics(bufnr) + validate { bufnr = {bufnr, 'n', true} } + bufnr = bufnr == 0 and api.nvim_get_current_buf() or bufnr + api.nvim_buf_clear_namespace(bufnr, diagnostic_ns, 0, -1) + end + + -- Initialize with the defaults. + M.set_severity_highlights(default_severity_highlight) + + function M.get_severity_highlight_name(severity) + return severity_highlights[severity] + end + + function M.show_line_diagnostics() + local bufnr = api.nvim_get_current_buf() + local line = api.nvim_win_get_cursor(0)[1] - 1 + -- local marks = api.nvim_buf_get_extmarks(bufnr, diagnostic_ns, {line, 0}, {line, -1}, {}) + -- if #marks == 0 then + -- return + -- end + -- local buffer_diagnostics = all_buffer_diagnostics[bufnr] + local lines = {"Diagnostics:"} + local highlights = {{0, "Bold"}} + + local buffer_diagnostics = all_buffer_diagnostics[bufnr] + if not buffer_diagnostics then return end + local line_diagnostics = buffer_diagnostics[line] + if not line_diagnostics then return end + + for i, diagnostic in ipairs(line_diagnostics) do + -- for i, mark in ipairs(marks) do + -- local mark_id = mark[1] + -- local diagnostic = buffer_diagnostics[mark_id] + + -- TODO(ashkan) make format configurable? + local prefix = string.format("%d. ", i) + local hiname = severity_highlights[diagnostic.severity] + local message_lines = split_lines(diagnostic.message) + table.insert(lines, prefix..message_lines[1]) + table.insert(highlights, {#prefix + 1, hiname}) + for j = 2, #message_lines do + table.insert(lines, message_lines[j]) + table.insert(highlights, {0, hiname}) + end + end + local popup_bufnr, winnr = M.open_floating_preview(lines, 'plaintext') + for i, hi in ipairs(highlights) do + local prefixlen, hiname = unpack(hi) + -- Start highlight after the prefix + api.nvim_buf_add_highlight(popup_bufnr, -1, hiname, i-1, prefixlen, -1) + end + return popup_bufnr, winnr + end + + function M.buf_diagnostics_save_positions(bufnr, diagnostics) + validate { + bufnr = {bufnr, 'n', true}; + diagnostics = {diagnostics, 't', true}; + } + if not diagnostics then return end + bufnr = bufnr == 0 and api.nvim_get_current_buf() or bufnr + + if not all_buffer_diagnostics[bufnr] then + -- Clean up our data when the buffer unloads. + api.nvim_buf_attach(bufnr, false, { + on_detach = function(b) + all_buffer_diagnostics[b] = nil + end + }) + end + all_buffer_diagnostics[bufnr] = {} + local buffer_diagnostics = all_buffer_diagnostics[bufnr] + + for _, diagnostic in ipairs(diagnostics) do + local start = diagnostic.range.start + -- local mark_id = api.nvim_buf_set_extmark(bufnr, diagnostic_ns, 0, start.line, 0, {}) + -- buffer_diagnostics[mark_id] = diagnostic + local line_diagnostics = buffer_diagnostics[start.line] + if not line_diagnostics then + line_diagnostics = {} + buffer_diagnostics[start.line] = line_diagnostics + end + table.insert(line_diagnostics, diagnostic) + end + end + + + function M.buf_diagnostics_underline(bufnr, diagnostics) + for _, diagnostic in ipairs(diagnostics) do + local start = diagnostic.range.start + local finish = diagnostic.range["end"] + + -- TODO care about encoding here since this is in byte index? + highlight_range(bufnr, diagnostic_ns, underline_highlight_name, + {start.line, start.character}, + {finish.line, finish.character} + ) + end + end + + function M.buf_diagnostics_virtual_text(bufnr, diagnostics) + local buffer_line_diagnostics = all_buffer_diagnostics[bufnr] + if not buffer_line_diagnostics then + M.buf_diagnostics_save_positions(bufnr, diagnostics) + end + buffer_line_diagnostics = all_buffer_diagnostics[bufnr] + if not buffer_line_diagnostics then + return + end + for line, line_diags in pairs(buffer_line_diagnostics) do + local virt_texts = {} + for i = 1, #line_diags - 1 do + table.insert(virt_texts, {"■", severity_highlights[line_diags[i].severity]}) + end + local last = line_diags[#line_diags] + -- TODO(ashkan) use first line instead of subbing 2 spaces? + table.insert(virt_texts, {"■ "..last.message:gsub("\r", ""):gsub("\n", " "), severity_highlights[last.severity]}) + api.nvim_buf_set_virtual_text(bufnr, diagnostic_ns, line, virt_texts, {}) + end + end +end + +function M.buf_loclist(bufnr, locations) + local targetwin + for _, winnr in ipairs(api.nvim_list_wins()) do + local winbuf = api.nvim_win_get_buf(winnr) + if winbuf == bufnr then + targetwin = winnr + break + end + end + if not targetwin then return end + + local items = {} + local path = api.nvim_buf_get_name(bufnr) + for _, d in ipairs(locations) do + -- TODO: URL parsing here? + local start = d.range.start + table.insert(items, { + filename = path, + lnum = start.line + 1, + col = start.character + 1, + text = d.message, + }) + end + vim.fn.setloclist(targetwin, items, ' ', 'Language Server') +end + +return M +-- vim:sw=2 ts=2 et -- cgit From 69a0712a9c2bae0969f7a84e5ec321ce6120982b Mon Sep 17 00:00:00 2001 From: Ashkan Kiani Date: Wed, 20 Nov 2019 11:23:50 -0800 Subject: Reduce code blocks in markdown previews. If the preview is just a code block, then use the language as the filetype instead of using markdown. This reduces the size of the preview. --- runtime/lua/vim/lsp/builtin_callbacks.lua | 54 ++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) (limited to 'runtime/lua/vim/lsp') diff --git a/runtime/lua/vim/lsp/builtin_callbacks.lua b/runtime/lua/vim/lsp/builtin_callbacks.lua index cc739ce3ad..717fda395e 100644 --- a/runtime/lua/vim/lsp/builtin_callbacks.lua +++ b/runtime/lua/vim/lsp/builtin_callbacks.lua @@ -81,6 +81,56 @@ builtin_callbacks['textDocument/publishDiagnostics'] = function(_, _, result) -- util.buf_loclist(bufnr, result.diagnostics) end +local function focusable_popup() + local popup_win + return function(winnr) + if popup_win and nvim.win_is_valid(popup_win) then + if nvim.get_current_win() == popup_win then + nvim.ex.wincmd "p" + else + nvim.set_current_win(popup_win) + end + return + end + popup_win = winnr + end +end + +local hover_popup = focusable_popup() + +local function trim_empty_lines(lines) + local result = {} + for _, line in ipairs(lines) do + if #line > 0 then + table.insert(result, line) + end + end + return result +end + +-- Accepts markdown lines and tries to reduce it to a filetype if it is +-- just a single code block. +local function try_trim_code_blocks(lines) + local language_id = lines[1]:match("^```(.*)") + if language_id then + local has_inner_code_fence = false + for i = 2, (#lines - 1) do + local line = lines[i] + if line:sub(1,3) == '```' then + has_inner_code_fence = true + break + end + end + -- No inner code fences + starting with code fence = hooray. + if not has_inner_code_fence then + table.remove(lines, 1) + table.remove(lines) + return lines, language_id + end + end + return lines, 'markdown' +end + -- textDocument/hover -- https://microsoft.github.io/language-server-protocol/specification#textDocument_hover -- @params MarkedString | MarkedString[] | MarkupContent @@ -91,10 +141,12 @@ builtin_callbacks['textDocument/hover'] = function(_, _, result) if result.contents ~= nil then local markdown_lines = util.convert_input_to_markdown_lines(result.contents) + markdown_lines = trim_empty_lines(markdown_lines) if vim.tbl_isempty(markdown_lines) then markdown_lines = { 'No information available' } end - util.open_floating_preview(markdown_lines, 'markdown') + local _, winnr = util.open_floating_preview(try_trim_code_blocks(markdown_lines)) + hover_popup(winnr) end end -- cgit From 3ae9b3781e0b66f5874971665b38314d61668627 Mon Sep 17 00:00:00 2001 From: Ashkan Kiani Date: Wed, 20 Nov 2019 11:34:10 -0800 Subject: Bugfix for floating_preview Don't modify your inputs. --- runtime/lua/vim/lsp/util.lua | 3 +++ 1 file changed, 3 insertions(+) (limited to 'runtime/lua/vim/lsp') diff --git a/runtime/lua/vim/lsp/util.lua b/runtime/lua/vim/lsp/util.lua index f96e0f01a8..707e2d0d3b 100644 --- a/runtime/lua/vim/lsp/util.lua +++ b/runtime/lua/vim/lsp/util.lua @@ -262,6 +262,9 @@ function M.open_floating_preview(contents, filetype, opts) opts = { opts, 't', true }; } + -- Don't modify our inputs. + contents = vim.deepcopy(contents) + -- Trim empty lines from the end. for i = #contents, 1, -1 do if #contents[i] == 0 then -- cgit From 0904ffe387f825404d8467f9787678c2251728dd Mon Sep 17 00:00:00 2001 From: Ashkan Kiani Date: Wed, 20 Nov 2019 11:36:50 -0800 Subject: Bugfixes. - Return after an error in RPC. - Use an empty vim table for serialization. --- runtime/lua/vim/lsp/rpc.lua | 1 + 1 file changed, 1 insertion(+) (limited to 'runtime/lua/vim/lsp') diff --git a/runtime/lua/vim/lsp/rpc.lua b/runtime/lua/vim/lsp/rpc.lua index e0ec8863d6..a558f66a42 100644 --- a/runtime/lua/vim/lsp/rpc.lua +++ b/runtime/lua/vim/lsp/rpc.lua @@ -340,6 +340,7 @@ local function create_and_start_client(cmd, cmd_args, handlers, extra_spawn_para local decoded, err = json_decode(body) if not decoded then on_error(client_errors.INVALID_SERVER_JSON, err) + return end local _ = log.debug() and log.debug("decoded", decoded) -- cgit From 568b4540884bb852b733dfcdd6a2da0d4fe2b42e Mon Sep 17 00:00:00 2001 From: Ashkan Kiani Date: Wed, 20 Nov 2019 11:39:54 -0800 Subject: Add vim.uri_to_bufnr --- runtime/lua/vim/lsp/builtin_callbacks.lua | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) (limited to 'runtime/lua/vim/lsp') diff --git a/runtime/lua/vim/lsp/builtin_callbacks.lua b/runtime/lua/vim/lsp/builtin_callbacks.lua index 717fda395e..a5e1162d02 100644 --- a/runtime/lua/vim/lsp/builtin_callbacks.lua +++ b/runtime/lua/vim/lsp/builtin_callbacks.lua @@ -62,14 +62,10 @@ builtin_callbacks['textDocument/rename'] = function(_, _, result) util.workspace_apply_workspace_edit(result) end -local function uri_to_bufnr(uri) - return vim.fn.bufadd((vim.uri_to_fname(uri))) -end - builtin_callbacks['textDocument/publishDiagnostics'] = function(_, _, result) if not result then return end local uri = result.uri - local bufnr = uri_to_bufnr(uri) + local bufnr = vim.uri_to_bufnr(uri) if not bufnr then api.nvim_err_writeln(string.format("LSP.publishDiagnostics: Couldn't find buffer for %s", uri)) return @@ -154,7 +150,7 @@ builtin_callbacks['textDocument/peekDefinition'] = function(_, _, result) if result == nil or vim.tbl_isempty(result) then return end -- TODO(ashkan) what to do with multiple locations? result = result[1] - local bufnr = uri_to_bufnr(result.uri) + local bufnr = vim.uri_to_bufnr(result.uri) assert(bufnr) local start = result.range.start local finish = result.range["end"] -- cgit From ff65cc990afba288df6d39d6f95901cf0786c668 Mon Sep 17 00:00:00 2001 From: Ashkan Kiani Date: Wed, 20 Nov 2019 11:41:45 -0800 Subject: Bugfix. Don't use nvim.lua that doesn't exist :) --- runtime/lua/vim/lsp/builtin_callbacks.lua | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) (limited to 'runtime/lua/vim/lsp') diff --git a/runtime/lua/vim/lsp/builtin_callbacks.lua b/runtime/lua/vim/lsp/builtin_callbacks.lua index a5e1162d02..2229547f20 100644 --- a/runtime/lua/vim/lsp/builtin_callbacks.lua +++ b/runtime/lua/vim/lsp/builtin_callbacks.lua @@ -80,11 +80,11 @@ end local function focusable_popup() local popup_win return function(winnr) - if popup_win and nvim.win_is_valid(popup_win) then - if nvim.get_current_win() == popup_win then - nvim.ex.wincmd "p" + if popup_win and api.nvim_win_is_valid(popup_win) then + if api.nvim_get_current_win() == popup_win then + api.nvim_command("wincmd p") else - nvim.set_current_win(popup_win) + api.nvim_set_current_win(popup_win) end return end -- cgit From c83380cf80561d45c067db5e70c7275eda7671af Mon Sep 17 00:00:00 2001 From: Ashkan Kiani Date: Wed, 20 Nov 2019 14:21:57 -0800 Subject: Add lsp.buf and hover implementation. --- runtime/lua/vim/lsp/buf.lua | 85 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 runtime/lua/vim/lsp/buf.lua (limited to 'runtime/lua/vim/lsp') diff --git a/runtime/lua/vim/lsp/buf.lua b/runtime/lua/vim/lsp/buf.lua new file mode 100644 index 0000000000..6398bcca74 --- /dev/null +++ b/runtime/lua/vim/lsp/buf.lua @@ -0,0 +1,85 @@ +local validate = vim.validate +local api = vim.api +local util = require 'vim.lsp.util' +local protocol = require 'vim.lsp.protocol' + +local M = {} + +local function resolve_bufnr(bufnr) + validate { bufnr = { bufnr, 'n', true } } + if bufnr == nil or bufnr == 0 then + return api.nvim_get_current_buf() + end + return bufnr +end + +local function ok_or_nil(status, ...) + if not status then return end + return ... +end +local function npcall(fn, ...) + return ok_or_nil(pcall(fn, ...)) +end + +local function find_window_by_var(name, value) + for _, win in ipairs(api.nvim_list_wins()) do + if npcall(api.nvim_win_get_var, win, name) == value then + return win + end + end +end + +local hover_window_tag = 'lsp_hover' +function M.hover(bufnr) + if npcall(api.nvim_win_get_var, 0, hover_window_tag) then + api.nvim_command("wincmd p") + return + end + + bufnr = resolve_bufnr(bufnr) + do + local win = find_window_by_var(hover_window_tag, bufnr) + if win then + api.nvim_set_current_win(win) + return + end + end + local params = protocol.make_text_document_position_params() + vim.lsp.buf_request(bufnr, 'textDocument/hover', params, function(_, _, result, _) + if result == nil or vim.tbl_isempty(result) then + return + end + + if result.contents ~= nil then + local markdown_lines = util.convert_input_to_markdown_lines(result.contents) + markdown_lines = util.trim_empty_lines(markdown_lines) + if vim.tbl_isempty(markdown_lines) then + markdown_lines = { 'No information available' } + end + local filetype = util.try_trim_markdown_code_blocks(markdown_lines) + local _, winnr = util.open_floating_preview(markdown_lines, filetype) + api.nvim_win_set_var(winnr, hover_window_tag, bufnr) + end + end) +end + +function M.signature_help() +end + +function M.declaration() +end + +function M.type_definition() +end + +function M.implementation() +end + +-- TODO(ashkan) ? +function M.completion() +end + +function M.range_formatting() +end + +return M -- cgit From 2d580756ca707945c01703d404e10f4bf412a72c Mon Sep 17 00:00:00 2001 From: Ashkan Kiani Date: Wed, 20 Nov 2019 15:35:18 -0800 Subject: Add everything to lsp.buf and get rid of autoload. --- runtime/lua/vim/lsp/buf.lua | 161 ++++++++++++++++++++++++++++++++++++------- runtime/lua/vim/lsp/util.lua | 88 +++++++++++++++++------ 2 files changed, 204 insertions(+), 45 deletions(-) (limited to 'runtime/lua/vim/lsp') diff --git a/runtime/lua/vim/lsp/buf.lua b/runtime/lua/vim/lsp/buf.lua index 6398bcca74..5f54196dcc 100644 --- a/runtime/lua/vim/lsp/buf.lua +++ b/runtime/lua/vim/lsp/buf.lua @@ -1,7 +1,9 @@ local validate = vim.validate local api = vim.api +local vfn = vim.fn local util = require 'vim.lsp.util' local protocol = require 'vim.lsp.protocol' +local log = require 'vim.lsp.log' local M = {} @@ -29,54 +31,165 @@ local function find_window_by_var(name, value) end end -local hover_window_tag = 'lsp_hover' -function M.hover(bufnr) - if npcall(api.nvim_win_get_var, 0, hover_window_tag) then - api.nvim_command("wincmd p") - return +local function request(method, params, callback) + -- TODO(ashkan) enable this. + -- callback = vim.lsp.default_callback[method] or callback + validate { + method = {method, 's'}; + callback = {callback, 'f'}; + } + return vim.lsp.buf_request(0, method, params, function(err, _, result, client_id) + if err then error(tostring(err)) end + return callback(err, method, result, client_id) + end) +end + +local function focusable_preview(method, params, fn) + if npcall(api.nvim_win_get_var, 0, method) then + return api.nvim_command("wincmd p") end - bufnr = resolve_bufnr(bufnr) + local bufnr = api.nvim_get_current_buf() do - local win = find_window_by_var(hover_window_tag, bufnr) + local win = find_window_by_var(method, bufnr) if win then - api.nvim_set_current_win(win) - return + return api.nvim_set_current_win(win) end end - local params = protocol.make_text_document_position_params() - vim.lsp.buf_request(bufnr, 'textDocument/hover', params, function(_, _, result, _) - if result == nil or vim.tbl_isempty(result) then - return + return request(method, params, function(_, _, result, _) + -- TODO(ashkan) could show error in preview... + local lines, filetype, opts = fn(result) + if lines then + local _, winnr = util.open_floating_preview(lines, filetype, opts) + api.nvim_win_set_var(winnr, method, bufnr) end + end) +end + +function M.hover() + local params = protocol.make_text_document_position_params() + focusable_preview('textDocument/hover', params, function(result) + if not (result and result.contents) then return end - if result.contents ~= nil then - local markdown_lines = util.convert_input_to_markdown_lines(result.contents) - markdown_lines = util.trim_empty_lines(markdown_lines) - if vim.tbl_isempty(markdown_lines) then - markdown_lines = { 'No information available' } - end - local filetype = util.try_trim_markdown_code_blocks(markdown_lines) - local _, winnr = util.open_floating_preview(markdown_lines, filetype) - api.nvim_win_set_var(winnr, hover_window_tag, bufnr) + local markdown_lines = util.convert_input_to_markdown_lines(result.contents) + markdown_lines = util.trim_empty_lines(markdown_lines) + if vim.tbl_isempty(markdown_lines) then + return { 'No information available' } end + return markdown_lines, util.try_trim_markdown_code_blocks(markdown_lines) end) end -function M.signature_help() +function M.peek_definition() + local params = protocol.make_text_document_position_params() + request('textDocument/peekDefinition', params, function(_, _, result, _) + if not (result and result[1]) then return end + local loc = result[1] + local bufnr = vim.uri_to_bufnr(loc.uri) or error("couldn't find file "..tostring(loc.uri)) + local start = loc.range.start + local finish = loc.range["end"] + util.open_floating_peek_preview(bufnr, start, finish, { offset_x = 1 }) + local headbuf = util.open_floating_preview({"Peek:"}, nil, { + offset_y = -(finish.line - start.line); + width = finish.character - start.character + 2; + }) + -- TODO(ashkan) change highlight group? + api.nvim_buf_add_highlight(headbuf, -1, 'Keyword', 0, -1) + end) +end + + +local function update_tagstack() + local bufnr = api.nvim_get_current_buf() + local line = vfn.line('.') + local col = vfn.col('.') + local tagname = vfn.expand('') + local item = { bufnr = bufnr, from = { bufnr, line, col, 0 }, tagname = tagname } + local winid = vfn.win_getid() + local tagstack = vfn.gettagstack(winid) + local action + if tagstack.length == tagstack.curidx then + action = 'r' + tagstack.items[tagstack.curidx] = item + elseif tagstack.length > tagstack.curidx then + action = 'r' + if tagstack.curidx > 1 then + tagstack.items = table.insert(tagstack.items[tagstack.curidx - 1], item) + else + tagstack.items = { item } + end + else + action = 'a' + tagstack.items = { item } + end + tagstack.curidx = tagstack.curidx + 1 + vfn.settagstack(winid, tagstack, action) +end +local function handle_location(result) + -- We can sometimes get a list of locations, so set the first value as the + -- only value we want to handle + -- TODO(ashkan) was this correct^? We could use location lists. + if result[1] ~= nil then + result = result[1] + end + if result.uri == nil then + api.nvim_err_writeln('[LSP] Could not find a valid location') + return + end + local result_file = vim.uri_to_fname(result.uri) + local bufnr = vfn.bufadd(result_file) + update_tagstack() + api.nvim_set_current_buf(bufnr) + local start = result.range.start + api.nvim_win_set_cursor(0, {start.line + 1, start.character}) + return true +end +local function location_callback(_, method, result) + if result == nil or vim.tbl_isempty(result) then + local _ = log.info() and log.info(method, 'No location found') + return nil + end + return handle_location(result) end function M.declaration() + local params = protocol.make_text_document_position_params() + request('textDocument/declaration', params, location_callback) +end + +function M.definition() + local params = protocol.make_text_document_position_params() + request('textDocument/definition', params, location_callback) end function M.type_definition() + local params = protocol.make_text_document_position_params() + request('textDocument/typeDefinition', params, location_callback) end function M.implementation() + local params = protocol.make_text_document_position_params() + request('textDocument/implementation', params, location_callback) +end + +function M.signature_help() + local params = protocol.make_text_document_position_params() + request('textDocument/signatureHelp', params, location_callback) end -- TODO(ashkan) ? -function M.completion() +function M.completion(context) + local params = protocol.make_text_document_position_params() + params.context = context + return request('textDocument/completion', params, function(_, _, result) + if vim.tbl_isempty(result or {}) then return end + local row, col = unpack(api.nvim_win_get_cursor(0)) + local line = assert(api.nvim_buf_get_lines(0, row-1, row, false)[1]) + local line_to_cursor = line:sub(col+1) + + local matches = util.text_document_completion_list_to_complete_items(result, line_to_cursor) + vim.fn.complete(col, matches) + end) end function M.range_formatting() diff --git a/runtime/lua/vim/lsp/util.lua b/runtime/lua/vim/lsp/util.lua index 707e2d0d3b..13c83fefd6 100644 --- a/runtime/lua/vim/lsp/util.lua +++ b/runtime/lua/vim/lsp/util.lua @@ -261,32 +261,27 @@ function M.open_floating_preview(contents, filetype, opts) filetype = { filetype, 's', true }; opts = { opts, 't', true }; } - - -- Don't modify our inputs. - contents = vim.deepcopy(contents) + opts = opts or {} -- Trim empty lines from the end. - for i = #contents, 1, -1 do - if #contents[i] == 0 then - table.remove(contents) - else - break + contents = M.trim_empty_lines(contents) + + local width = opts.width + local height = opts.height or #contents + if not width then + width = 0 + for i, line in ipairs(contents) do + -- Clean up the input and add left pad. + line = " "..line:gsub("\r", "") + -- TODO(ashkan) use nvim_strdisplaywidth if/when that is introduced. + local line_width = vim.fn.strdisplaywidth(line) + width = math.max(line_width, width) + contents[i] = line end + -- Add right padding of 1 each. + width = width + 1 end - local width = 0 - local height = #contents - for i, line in ipairs(contents) do - -- Clean up the input and add left pad. - line = " "..line:gsub("\r", "") - -- TODO(ashkan) use nvim_strdisplaywidth if/when that is introduced. - local line_width = vim.fn.strdisplaywidth(line) - width = math.max(line_width, width) - contents[i] = line - end - -- Add right padding of 1 each. - width = width + 1 - local floating_bufnr = api.nvim_create_buf(false, true) if filetype then api.nvim_buf_set_option(floating_bufnr, 'filetype', filetype) @@ -556,5 +551,56 @@ function M.buf_loclist(bufnr, locations) vim.fn.setloclist(targetwin, items, ' ', 'Language Server') end +-- Remove empty lines from the beginning and end. +function M.trim_empty_lines(lines) + local result = {} + local start = 1 + for i = 1, #lines do + if #lines[i] > 0 then + start = i + break + end + end + local finish = 1 + for i = #lines, 1, -1 do + if #lines[i] > 0 then + finish = i + break + end + end + -- TODO(ashkan) use tbl_slice. + for i = start, finish do + table.insert(result, lines[i]) + end + return result +end + +-- Accepts markdown lines and tries to reduce it to a filetype if it is +-- just a single code block. +-- Note: This modifies the input. +-- +-- Returns: 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 + local has_inner_code_fence = false + for i = 2, (#lines - 1) do + local line = lines[i] + if line:sub(1,3) == '```' then + has_inner_code_fence = true + break + end + end + -- No inner code fences + starting with code fence = hooray. + if not has_inner_code_fence then + table.remove(lines, 1) + table.remove(lines) + return language_id + end + end + return 'markdown' +end + + return M -- vim:sw=2 ts=2 et -- cgit From a4b7004f489030d9ee7e3bbfc156ab540744279b Mon Sep 17 00:00:00 2001 From: Ashkan Kiani Date: Wed, 20 Nov 2019 16:03:32 -0800 Subject: Move everything to buf & default_callbacks - Rename builtin_callbacks to default_callbacks and slightly change its semantics: - No longer contains the default implementations. Instead, any default_callbacks will be used in preference for our .buf methods. - Add this to the docs. --- runtime/lua/vim/lsp/buf.lua | 86 +++++++- runtime/lua/vim/lsp/builtin_callbacks.lua | 344 ------------------------------ runtime/lua/vim/lsp/default_callbacks.lua | 57 +++++ runtime/lua/vim/lsp/protocol.lua | 1 - 4 files changed, 141 insertions(+), 347 deletions(-) delete mode 100644 runtime/lua/vim/lsp/builtin_callbacks.lua create mode 100644 runtime/lua/vim/lsp/default_callbacks.lua (limited to 'runtime/lua/vim/lsp') diff --git a/runtime/lua/vim/lsp/buf.lua b/runtime/lua/vim/lsp/buf.lua index 5f54196dcc..e8a38aa6ef 100644 --- a/runtime/lua/vim/lsp/buf.lua +++ b/runtime/lua/vim/lsp/buf.lua @@ -33,7 +33,7 @@ end local function request(method, params, callback) -- TODO(ashkan) enable this. - -- callback = vim.lsp.default_callback[method] or callback + -- callback = vim.lsp.default_callbacks[method] or callback validate { method = {method, 's'}; callback = {callback, 'f'}; @@ -172,9 +172,78 @@ function M.implementation() request('textDocument/implementation', params, location_callback) end +--- Convert SignatureHelp response to preview contents. +-- https://microsoft.github.io/language-server-protocol/specifications/specification-3-14/#textDocument_signatureHelp +local function signature_help_to_preview_contents(input) + if not input.signatures then + return + end + --The active signature. If omitted or the value lies outside the range of + --`signatures` the value defaults to zero or is ignored if `signatures.length + --=== 0`. Whenever possible implementors should make an active decision about + --the active signature and shouldn't rely on a default value. + local contents = {} + local active_signature = input.activeSignature or 0 + -- If the activeSignature is not inside the valid range, then clip it. + if active_signature >= #input.signatures then + active_signature = 0 + end + local signature = input.signatures[active_signature + 1] + if not signature then + return + end + vim.list_extend(contents, split_lines(signature.label)) + if signature.documentation then + util.convert_input_to_markdown_lines(signature.documentation, contents) + end + if input.parameters then + local active_parameter = input.activeParameter or 0 + -- If the activeParameter is not inside the valid range, then clip it. + if active_parameter >= #input.parameters then + active_parameter = 0 + end + local parameter = signature.parameters and signature.parameters[active_parameter] + if parameter then + --[=[ + --Represents a parameter of a callable-signature. A parameter can + --have a label and a doc-comment. + interface ParameterInformation { + --The label of this parameter information. + -- + --Either a string or an inclusive start and exclusive end offsets within its containing + --signature label. (see SignatureInformation.label). The offsets are based on a UTF-16 + --string representation as `Position` and `Range` does. + -- + --*Note*: a label of type string should be a substring of its containing signature label. + --Its intended use case is to highlight the parameter label part in the `SignatureInformation.label`. + label: string | [number, number]; + --The human-readable doc-comment of this parameter. Will be shown + --in the UI but can be omitted. + documentation?: string | MarkupContent; + } + --]=] + -- TODO highlight parameter + if parameter.documentation then + util.convert_input_to_markdown_lines(parameter.documentation, contents) + end + end + end + return contents +end + function M.signature_help() local params = protocol.make_text_document_position_params() - request('textDocument/signatureHelp', params, location_callback) + focusable_preview('textDocument/signatureHelp', params, function(result) + if not (result and result.signatures and result.signatures[1]) then return end + + -- TODO show empty popup when signatures is empty? + local lines = signature_help_to_preview_contents(result) + lines = util.trim_empty_lines(lines) + if vim.tbl_isempty(lines) then + return { 'No signature available' } + end + return lines, util.try_trim_markdown_code_blocks(lines) + end) end -- TODO(ashkan) ? @@ -195,4 +264,17 @@ end function M.range_formatting() end +function M.rename(new_name) + -- TODO(ashkan) use prepareRename + -- * result: [`Range`](#range) \| `{ range: Range, placeholder: string }` \| `null` describing the range of the string to rename and optionally a placeholder text of the string content to be renamed. If `null` is returned then it is deemed that a 'textDocument/rename' request is not valid at the given position. + local params = protocol.make_text_document_position_params() + new_name = new_name or npcall(vfn.input, "New Name: ") + if not (new_name and #new_name > 0) then return end + params.newName = new_name + request('textDocument/rename', params, function(_, _, result) + if not result then return end + util.workspace_apply_workspace_edit(result) + end) +end + return M diff --git a/runtime/lua/vim/lsp/builtin_callbacks.lua b/runtime/lua/vim/lsp/builtin_callbacks.lua deleted file mode 100644 index 2229547f20..0000000000 --- a/runtime/lua/vim/lsp/builtin_callbacks.lua +++ /dev/null @@ -1,344 +0,0 @@ ---- Implements the following default callbacks: --- --- vim.api.nvim_buf_set_lines(0, 0, 0, false, vim.tbl_keys(vim.lsp.builtin_callbacks)) --- - --- textDocument/completion --- textDocument/declaration --- textDocument/definition --- textDocument/hover --- textDocument/implementation --- textDocument/publishDiagnostics --- textDocument/rename --- textDocument/signatureHelp --- textDocument/typeDefinition --- TODO codeLens/resolve --- TODO completionItem/resolve --- TODO documentLink/resolve --- TODO textDocument/codeAction --- TODO textDocument/codeLens --- TODO textDocument/documentHighlight --- TODO textDocument/documentLink --- TODO textDocument/documentSymbol --- TODO textDocument/formatting --- TODO textDocument/onTypeFormatting --- TODO textDocument/rangeFormatting --- TODO textDocument/references --- window/logMessage --- window/showMessage - -local log = require 'vim.lsp.log' -local protocol = require 'vim.lsp.protocol' -local util = require 'vim.lsp.util' -local api = vim.api - -local function split_lines(value) - return vim.split(value, '\n', true) -end - -local builtin_callbacks = {} - --- textDocument/completion --- https://microsoft.github.io/language-server-protocol/specification#textDocument_completion -builtin_callbacks['textDocument/completion'] = function(_, _, result) - if not result or vim.tbl_isempty(result) then - return - end - local pos = api.nvim_win_get_cursor(0) - local row, col = pos[1], pos[2] - local line = assert(api.nvim_buf_get_lines(0, row-1, row, false)[1]) - local line_to_cursor = line:sub(col+1) - - local matches = util.text_document_completion_list_to_complete_items(result, line_to_cursor) - local match_result = vim.fn.matchstrpos(line_to_cursor, '\\k\\+$') - local match_start, match_finish = match_result[2], match_result[3] - - vim.fn.complete(col + 1 - (match_finish - match_start), matches) -end - --- textDocument/rename -builtin_callbacks['textDocument/rename'] = function(_, _, result) - if not result then return end - util.workspace_apply_workspace_edit(result) -end - -builtin_callbacks['textDocument/publishDiagnostics'] = function(_, _, result) - if not result then return end - local uri = result.uri - local bufnr = vim.uri_to_bufnr(uri) - if not bufnr then - api.nvim_err_writeln(string.format("LSP.publishDiagnostics: Couldn't find buffer for %s", uri)) - return - end - util.buf_clear_diagnostics(bufnr) - util.buf_diagnostics_save_positions(bufnr, result.diagnostics) - util.buf_diagnostics_underline(bufnr, result.diagnostics) - util.buf_diagnostics_virtual_text(bufnr, result.diagnostics) - -- util.buf_loclist(bufnr, result.diagnostics) -end - -local function focusable_popup() - local popup_win - return function(winnr) - if popup_win and api.nvim_win_is_valid(popup_win) then - if api.nvim_get_current_win() == popup_win then - api.nvim_command("wincmd p") - else - api.nvim_set_current_win(popup_win) - end - return - end - popup_win = winnr - end -end - -local hover_popup = focusable_popup() - -local function trim_empty_lines(lines) - local result = {} - for _, line in ipairs(lines) do - if #line > 0 then - table.insert(result, line) - end - end - return result -end - --- Accepts markdown lines and tries to reduce it to a filetype if it is --- just a single code block. -local function try_trim_code_blocks(lines) - local language_id = lines[1]:match("^```(.*)") - if language_id then - local has_inner_code_fence = false - for i = 2, (#lines - 1) do - local line = lines[i] - if line:sub(1,3) == '```' then - has_inner_code_fence = true - break - end - end - -- No inner code fences + starting with code fence = hooray. - if not has_inner_code_fence then - table.remove(lines, 1) - table.remove(lines) - return lines, language_id - end - end - return lines, 'markdown' -end - --- textDocument/hover --- https://microsoft.github.io/language-server-protocol/specification#textDocument_hover --- @params MarkedString | MarkedString[] | MarkupContent -builtin_callbacks['textDocument/hover'] = function(_, _, result) - if result == nil or vim.tbl_isempty(result) then - return - end - - if result.contents ~= nil then - local markdown_lines = util.convert_input_to_markdown_lines(result.contents) - markdown_lines = trim_empty_lines(markdown_lines) - if vim.tbl_isempty(markdown_lines) then - markdown_lines = { 'No information available' } - end - local _, winnr = util.open_floating_preview(try_trim_code_blocks(markdown_lines)) - hover_popup(winnr) - end -end - -builtin_callbacks['textDocument/peekDefinition'] = function(_, _, result) - if result == nil or vim.tbl_isempty(result) then return end - -- TODO(ashkan) what to do with multiple locations? - result = result[1] - local bufnr = vim.uri_to_bufnr(result.uri) - assert(bufnr) - local start = result.range.start - local finish = result.range["end"] - util.open_floating_peek_preview(bufnr, start, finish, { offset_x = 1 }) - util.open_floating_preview({"*Peek:*", string.rep(" ", finish.character - start.character + 1) }, 'markdown', { offset_y = -(finish.line - start.line) }) -end - ---- Convert SignatureHelp response to preview contents. --- https://microsoft.github.io/language-server-protocol/specifications/specification-3-14/#textDocument_signatureHelp -local function signature_help_to_preview_contents(input) - if not input.signatures then - return - end - --The active signature. If omitted or the value lies outside the range of - --`signatures` the value defaults to zero or is ignored if `signatures.length - --=== 0`. Whenever possible implementors should make an active decision about - --the active signature and shouldn't rely on a default value. - local contents = {} - local active_signature = input.activeSignature or 0 - -- If the activeSignature is not inside the valid range, then clip it. - if active_signature >= #input.signatures then - active_signature = 0 - end - local signature = input.signatures[active_signature + 1] - if not signature then - return - end - vim.list_extend(contents, split_lines(signature.label)) - if signature.documentation then - util.convert_input_to_markdown_lines(signature.documentation, contents) - end - if input.parameters then - local active_parameter = input.activeParameter or 0 - -- If the activeParameter is not inside the valid range, then clip it. - if active_parameter >= #input.parameters then - active_parameter = 0 - end - local parameter = signature.parameters and signature.parameters[active_parameter] - if parameter then - --[=[ - --Represents a parameter of a callable-signature. A parameter can - --have a label and a doc-comment. - interface ParameterInformation { - --The label of this parameter information. - -- - --Either a string or an inclusive start and exclusive end offsets within its containing - --signature label. (see SignatureInformation.label). The offsets are based on a UTF-16 - --string representation as `Position` and `Range` does. - -- - --*Note*: a label of type string should be a substring of its containing signature label. - --Its intended use case is to highlight the parameter label part in the `SignatureInformation.label`. - label: string | [number, number]; - --The human-readable doc-comment of this parameter. Will be shown - --in the UI but can be omitted. - documentation?: string | MarkupContent; - } - --]=] - -- TODO highlight parameter - if parameter.documentation then - util.convert_input_to_markdown_lines(parameter.documentation, contents) - end - end - end - return contents -end - --- textDocument/signatureHelp --- https://microsoft.github.io/language-server-protocol/specification#textDocument_signatureHelp -builtin_callbacks['textDocument/signatureHelp'] = function(_, _, result) - if result == nil or vim.tbl_isempty(result) then - return - end - - -- TODO show empty popup when signatures is empty? - if #result.signatures > 0 then - local markdown_lines = signature_help_to_preview_contents(result) - if vim.tbl_isempty(markdown_lines) then - markdown_lines = { 'No signature available' } - end - util.open_floating_preview(markdown_lines, 'markdown') - end -end - -local function update_tagstack() - local bufnr = api.nvim_get_current_buf() - local line = vim.fn.line('.') - local col = vim.fn.col('.') - local tagname = vim.fn.expand('') - local item = { bufnr = bufnr, from = { bufnr, line, col, 0 }, tagname = tagname } - local winid = vim.fn.win_getid() - local tagstack = vim.fn.gettagstack(winid) - - local action - - if tagstack.length == tagstack.curidx then - action = 'r' - tagstack.items[tagstack.curidx] = item - elseif tagstack.length > tagstack.curidx then - action = 'r' - if tagstack.curidx > 1 then - tagstack.items = table.insert(tagstack.items[tagstack.curidx - 1], item) - else - tagstack.items = { item } - end - else - action = 'a' - tagstack.items = { item } - end - - tagstack.curidx = tagstack.curidx + 1 - vim.fn.settagstack(winid, tagstack, action) -end - -local function handle_location(result) - -- We can sometimes get a list of locations, so set the first value as the - -- only value we want to handle - -- TODO(ashkan) was this correct^? We could use location lists. - if result[1] ~= nil then - result = result[1] - end - if result.uri == nil then - api.nvim_err_writeln('[LSP] Could not find a valid location') - return - end - local result_file = vim.uri_to_fname(result.uri) - local bufnr = vim.fn.bufadd(result_file) - update_tagstack() - api.nvim_set_current_buf(bufnr) - local start = result.range.start - api.nvim_win_set_cursor(0, {start.line + 1, start.character}) -end - -local function location_callback(_, method, result) - if result == nil or vim.tbl_isempty(result) then - local _ = log.info() and log.info(method, 'No location found') - return nil - end - handle_location(result) - return true -end - -local location_callbacks = { - -- https://microsoft.github.io/language-server-protocol/specification#textDocument_declaration - 'textDocument/declaration'; - -- https://microsoft.github.io/language-server-protocol/specification#textDocument_definition - 'textDocument/definition'; - -- https://microsoft.github.io/language-server-protocol/specification#textDocument_implementation - 'textDocument/implementation'; - -- https://microsoft.github.io/language-server-protocol/specification#textDocument_typeDefinition - 'textDocument/typeDefinition'; -} - -for _, location_method in ipairs(location_callbacks) do - builtin_callbacks[location_method] = location_callback -end - -local function log_message(_, _, result, client_id) - local message_type = result.type - local message = result.message - 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 - api.nvim_err_writeln(string.format("LSP[%s] client has shut down after sending the message", client_name)) - end - if message_type == protocol.MessageType.Error then - -- Might want to not use err_writeln, - -- but displaying a message with red highlights or something - api.nvim_err_writeln(string.format("LSP[%s] %s", client_name, message)) - else - local message_type_name = protocol.MessageType[message_type] - api.nvim_out_write(string.format("LSP[%s][%s] %s\n", client_name, message_type_name, message)) - end - return result -end - -builtin_callbacks['window/showMessage'] = log_message -builtin_callbacks['window/logMessage'] = log_message - --- Add boilerplate error validation and logging for all of these. -for k, fn in pairs(builtin_callbacks) do - builtin_callbacks[k] = function(err, method, params, client_id) - local _ = log.debug() and log.debug('builtin_callback', method, { params = params, client_id = client_id, err = err }) - if err then - error(tostring(err)) - end - return fn(err, method, params, client_id) - end -end - -return builtin_callbacks --- vim:sw=2 ts=2 et diff --git a/runtime/lua/vim/lsp/default_callbacks.lua b/runtime/lua/vim/lsp/default_callbacks.lua new file mode 100644 index 0000000000..63e62075b4 --- /dev/null +++ b/runtime/lua/vim/lsp/default_callbacks.lua @@ -0,0 +1,57 @@ +local log = require 'vim.lsp.log' +local protocol = require 'vim.lsp.protocol' +local util = require 'vim.lsp.util' +local api = vim.api + +local M = {} + +M['textDocument/publishDiagnostics'] = function(_, _, result) + if not result then return end + local uri = result.uri + local bufnr = vim.uri_to_bufnr(uri) + if not bufnr then + api.nvim_err_writeln(string.format("LSP.publishDiagnostics: Couldn't find buffer for %s", uri)) + return + end + util.buf_clear_diagnostics(bufnr) + util.buf_diagnostics_save_positions(bufnr, result.diagnostics) + util.buf_diagnostics_underline(bufnr, result.diagnostics) + util.buf_diagnostics_virtual_text(bufnr, result.diagnostics) + -- util.buf_loclist(bufnr, result.diagnostics) +end + +local function log_message(_, _, result, client_id) + local message_type = result.type + local message = result.message + 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 + api.nvim_err_writeln(string.format("LSP[%s] client has shut down after sending the message", client_name)) + end + if message_type == protocol.MessageType.Error then + -- Might want to not use err_writeln, + -- but displaying a message with red highlights or something + api.nvim_err_writeln(string.format("LSP[%s] %s", client_name, message)) + else + local message_type_name = protocol.MessageType[message_type] + api.nvim_out_write(string.format("LSP[%s][%s] %s\n", client_name, message_type_name, message)) + end + return result +end + +M['window/showMessage'] = log_message +M['window/logMessage'] = log_message + +-- Add boilerplate error validation and logging for all of these. +for k, fn in pairs(M) do + M[k] = function(err, method, params, client_id) + local _ = log.debug() and log.debug('default_callback', method, { params = params, client_id = client_id, err = err }) + if err then + error(tostring(err)) + end + return fn(err, method, params, client_id) + end +end + +return M +-- vim:sw=2 ts=2 et diff --git a/runtime/lua/vim/lsp/protocol.lua b/runtime/lua/vim/lsp/protocol.lua index 1413a88ce2..1f51e7bef7 100644 --- a/runtime/lua/vim/lsp/protocol.lua +++ b/runtime/lua/vim/lsp/protocol.lua @@ -10,7 +10,6 @@ end --[=[ -- Useful for interfacing with: --- https://github.com/microsoft/language-server-protocol/blob/gh-pages/_specifications/specification-3-14.md -- https://github.com/microsoft/language-server-protocol/raw/gh-pages/_specifications/specification-3-14.md function transform_schema_comments() nvim.command [[silent! '<,'>g/\/\*\*\|\*\/\|^$/d]] -- cgit From c40f8600d2418dfdfeacbba3efe11ae7c6c70ad3 Mon Sep 17 00:00:00 2001 From: Ashkan Kiani Date: Wed, 20 Nov 2019 16:16:13 -0800 Subject: Change error writer to not be annoying. --- runtime/lua/vim/lsp/buf.lua | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) (limited to 'runtime/lua/vim/lsp') diff --git a/runtime/lua/vim/lsp/buf.lua b/runtime/lua/vim/lsp/buf.lua index e8a38aa6ef..ff045cbbfc 100644 --- a/runtime/lua/vim/lsp/buf.lua +++ b/runtime/lua/vim/lsp/buf.lua @@ -23,6 +23,11 @@ local function npcall(fn, ...) return ok_or_nil(pcall(fn, ...)) end +local function err_message(...) + api.nvim_err_writeln(table.concat(vim.tbl_flatten{...})) + api.nvim_command("redraw") +end + local function find_window_by_var(name, value) for _, win in ipairs(api.nvim_list_wins()) do if npcall(api.nvim_win_get_var, win, name) == value then @@ -133,7 +138,7 @@ local function handle_location(result) result = result[1] end if result.uri == nil then - api.nvim_err_writeln('[LSP] Could not find a valid location') + err_message('[LSP] Could not find a valid location') return end local result_file = vim.uri_to_fname(result.uri) -- cgit From 1e16b3cf281bec73ccbd155dd11b2db048d1219a Mon Sep 17 00:00:00 2001 From: Ashkan Kiani Date: Wed, 20 Nov 2019 16:16:36 -0800 Subject: Spaces not tabs. --- runtime/lua/vim/lsp/buf.lua | 201 ++++++++++++++++++++++---------------------- 1 file changed, 101 insertions(+), 100 deletions(-) (limited to 'runtime/lua/vim/lsp') diff --git a/runtime/lua/vim/lsp/buf.lua b/runtime/lua/vim/lsp/buf.lua index ff045cbbfc..8b21370800 100644 --- a/runtime/lua/vim/lsp/buf.lua +++ b/runtime/lua/vim/lsp/buf.lua @@ -16,11 +16,11 @@ local function resolve_bufnr(bufnr) end local function ok_or_nil(status, ...) - if not status then return end - return ... + if not status then return end + return ... end local function npcall(fn, ...) - return ok_or_nil(pcall(fn, ...)) + return ok_or_nil(pcall(fn, ...)) end local function err_message(...) @@ -29,78 +29,78 @@ local function err_message(...) end local function find_window_by_var(name, value) - for _, win in ipairs(api.nvim_list_wins()) do - if npcall(api.nvim_win_get_var, win, name) == value then - return win - end - end + for _, win in ipairs(api.nvim_list_wins()) do + if npcall(api.nvim_win_get_var, win, name) == value then + return win + end + end end local function request(method, params, callback) - -- TODO(ashkan) enable this. - -- callback = vim.lsp.default_callbacks[method] or callback - validate { - method = {method, 's'}; - callback = {callback, 'f'}; - } - return vim.lsp.buf_request(0, method, params, function(err, _, result, client_id) - if err then error(tostring(err)) end - return callback(err, method, result, client_id) - end) + -- TODO(ashkan) enable this. + -- callback = vim.lsp.default_callbacks[method] or callback + validate { + method = {method, 's'}; + callback = {callback, 'f'}; + } + return vim.lsp.buf_request(0, method, params, function(err, _, result, client_id) + if err then error(tostring(err)) end + return callback(err, method, result, client_id) + end) end local function focusable_preview(method, params, fn) - if npcall(api.nvim_win_get_var, 0, method) then - return api.nvim_command("wincmd p") - end + if npcall(api.nvim_win_get_var, 0, method) then + return api.nvim_command("wincmd p") + end - local bufnr = api.nvim_get_current_buf() - do - local win = find_window_by_var(method, bufnr) - if win then - return api.nvim_set_current_win(win) - end - end - return request(method, params, function(_, _, result, _) - -- TODO(ashkan) could show error in preview... - local lines, filetype, opts = fn(result) - if lines then - local _, winnr = util.open_floating_preview(lines, filetype, opts) - api.nvim_win_set_var(winnr, method, bufnr) - end - end) + local bufnr = api.nvim_get_current_buf() + do + local win = find_window_by_var(method, bufnr) + if win then + return api.nvim_set_current_win(win) + end + end + return request(method, params, function(_, _, result, _) + -- TODO(ashkan) could show error in preview... + local lines, filetype, opts = fn(result) + if lines then + local _, winnr = util.open_floating_preview(lines, filetype, opts) + api.nvim_win_set_var(winnr, method, bufnr) + end + end) end function M.hover() - local params = protocol.make_text_document_position_params() - focusable_preview('textDocument/hover', params, function(result) - if not (result and result.contents) then return end + local params = protocol.make_text_document_position_params() + focusable_preview('textDocument/hover', params, function(result) + if not (result and result.contents) then return end - local markdown_lines = util.convert_input_to_markdown_lines(result.contents) - markdown_lines = util.trim_empty_lines(markdown_lines) - if vim.tbl_isempty(markdown_lines) then - return { 'No information available' } - end - return markdown_lines, util.try_trim_markdown_code_blocks(markdown_lines) - end) + local markdown_lines = util.convert_input_to_markdown_lines(result.contents) + markdown_lines = util.trim_empty_lines(markdown_lines) + if vim.tbl_isempty(markdown_lines) then + return { 'No information available' } + end + return markdown_lines, util.try_trim_markdown_code_blocks(markdown_lines) + end) end function M.peek_definition() - local params = protocol.make_text_document_position_params() - request('textDocument/peekDefinition', params, function(_, _, result, _) - if not (result and result[1]) then return end - local loc = result[1] - local bufnr = vim.uri_to_bufnr(loc.uri) or error("couldn't find file "..tostring(loc.uri)) - local start = loc.range.start - local finish = loc.range["end"] - util.open_floating_peek_preview(bufnr, start, finish, { offset_x = 1 }) - local headbuf = util.open_floating_preview({"Peek:"}, nil, { - offset_y = -(finish.line - start.line); - width = finish.character - start.character + 2; - }) - -- TODO(ashkan) change highlight group? - api.nvim_buf_add_highlight(headbuf, -1, 'Keyword', 0, -1) - end) + local params = protocol.make_text_document_position_params() + request('textDocument/peekDefinition', params, function(_, _, result, _) + if not (result and result[1]) then return end + local loc = result[1] + local bufnr = vim.uri_to_bufnr(loc.uri) or error("couldn't find file "..tostring(loc.uri)) + local start = loc.range.start + local finish = loc.range["end"] + util.open_floating_peek_preview(bufnr, start, finish, { offset_x = 1 }) + local headbuf = util.open_floating_preview({"Peek:"}, nil, { + offset_y = -(finish.line - start.line); + width = finish.character - start.character + 2; + }) + -- TODO(ashkan) change highlight group? + api.nvim_buf_add_highlight(headbuf, -1, 'Keyword', 0, -1) + end) end @@ -158,23 +158,23 @@ local function location_callback(_, method, result) end function M.declaration() - local params = protocol.make_text_document_position_params() - request('textDocument/declaration', params, location_callback) + local params = protocol.make_text_document_position_params() + request('textDocument/declaration', params, location_callback) end function M.definition() - local params = protocol.make_text_document_position_params() - request('textDocument/definition', params, location_callback) + local params = protocol.make_text_document_position_params() + request('textDocument/definition', params, location_callback) end function M.type_definition() - local params = protocol.make_text_document_position_params() - request('textDocument/typeDefinition', params, location_callback) + local params = protocol.make_text_document_position_params() + request('textDocument/typeDefinition', params, location_callback) end function M.implementation() - local params = protocol.make_text_document_position_params() - request('textDocument/implementation', params, location_callback) + local params = protocol.make_text_document_position_params() + request('textDocument/implementation', params, location_callback) end --- Convert SignatureHelp response to preview contents. @@ -237,49 +237,50 @@ local function signature_help_to_preview_contents(input) end function M.signature_help() - local params = protocol.make_text_document_position_params() - focusable_preview('textDocument/signatureHelp', params, function(result) - if not (result and result.signatures and result.signatures[1]) then return end + local params = protocol.make_text_document_position_params() + focusable_preview('textDocument/signatureHelp', params, function(result) + if not (result and result.signatures and result.signatures[1]) then return end - -- TODO show empty popup when signatures is empty? - local lines = signature_help_to_preview_contents(result) - lines = util.trim_empty_lines(lines) - if vim.tbl_isempty(lines) then - return { 'No signature available' } - end - return lines, util.try_trim_markdown_code_blocks(lines) - end) + -- TODO show empty popup when signatures is empty? + local lines = signature_help_to_preview_contents(result) + lines = util.trim_empty_lines(lines) + if vim.tbl_isempty(lines) then + return { 'No signature available' } + end + return lines, util.try_trim_markdown_code_blocks(lines) + end) end -- TODO(ashkan) ? function M.completion(context) - local params = protocol.make_text_document_position_params() - params.context = context - return request('textDocument/completion', params, function(_, _, result) - if vim.tbl_isempty(result or {}) then return end - local row, col = unpack(api.nvim_win_get_cursor(0)) - local line = assert(api.nvim_buf_get_lines(0, row-1, row, false)[1]) - local line_to_cursor = line:sub(col+1) + local params = protocol.make_text_document_position_params() + params.context = context + return request('textDocument/completion', params, function(_, _, result) + if vim.tbl_isempty(result or {}) then return end + local row, col = unpack(api.nvim_win_get_cursor(0)) + local line = assert(api.nvim_buf_get_lines(0, row-1, row, false)[1]) + local line_to_cursor = line:sub(col+1) - local matches = util.text_document_completion_list_to_complete_items(result, line_to_cursor) - vim.fn.complete(col, matches) - end) + local matches = util.text_document_completion_list_to_complete_items(result, line_to_cursor) + vim.fn.complete(col, matches) + end) end function M.range_formatting() end function M.rename(new_name) - -- TODO(ashkan) use prepareRename - -- * result: [`Range`](#range) \| `{ range: Range, placeholder: string }` \| `null` describing the range of the string to rename and optionally a placeholder text of the string content to be renamed. If `null` is returned then it is deemed that a 'textDocument/rename' request is not valid at the given position. - local params = protocol.make_text_document_position_params() - new_name = new_name or npcall(vfn.input, "New Name: ") - if not (new_name and #new_name > 0) then return end - params.newName = new_name - request('textDocument/rename', params, function(_, _, result) - if not result then return end - util.workspace_apply_workspace_edit(result) - end) + -- TODO(ashkan) use prepareRename + -- * result: [`Range`](#range) \| `{ range: Range, placeholder: string }` \| `null` describing the range of the string to rename and optionally a placeholder text of the string content to be renamed. If `null` is returned then it is deemed that a 'textDocument/rename' request is not valid at the given position. + local params = protocol.make_text_document_position_params() + new_name = new_name or npcall(vfn.input, "New Name: ") + if not (new_name and #new_name > 0) then return end + params.newName = new_name + request('textDocument/rename', params, function(_, _, result) + if not result then return end + util.workspace_apply_workspace_edit(result) + end) end return M +-- vim:sw=2 ts=2 et -- cgit From 93beae4f31d42dc70c874020011220444d7f979c Mon Sep 17 00:00:00 2001 From: Ashkan Kiani Date: Wed, 20 Nov 2019 16:35:11 -0800 Subject: Fix rename support. --- runtime/lua/vim/lsp/buf.lua | 1 + runtime/lua/vim/lsp/util.lua | 29 +++++++++++------------------ 2 files changed, 12 insertions(+), 18 deletions(-) (limited to 'runtime/lua/vim/lsp') diff --git a/runtime/lua/vim/lsp/buf.lua b/runtime/lua/vim/lsp/buf.lua index 8b21370800..01174a1e48 100644 --- a/runtime/lua/vim/lsp/buf.lua +++ b/runtime/lua/vim/lsp/buf.lua @@ -44,6 +44,7 @@ local function request(method, params, callback) callback = {callback, 'f'}; } return vim.lsp.buf_request(0, method, params, function(err, _, result, client_id) + local _ = log.debug() and log.debug("vim.lsp.buf", method, client_id, err, result) if err then error(tostring(err)) end return callback(err, method, result, client_id) end) diff --git a/runtime/lua/vim/lsp/util.lua b/runtime/lua/vim/lsp/util.lua index 13c83fefd6..ab2c02ffcd 100644 --- a/runtime/lua/vim/lsp/util.lua +++ b/runtime/lua/vim/lsp/util.lua @@ -52,16 +52,12 @@ function M.text_document_apply_text_edit(text_edit, bufnr) api.nvim_buf_set_lines(bufnr, start.line, finish.line, false, new_lines) return end - api.nvim_err_writeln('apply_text_edit currently only supports character ranges starting at 0') - error('apply_text_edit currently only supports character ranges starting at 0') - return - -- TODO test and finish this support for character ranges. --- local lines = api.nvim_buf_get_lines(0, start.line, finish.line + 1, false) --- local suffix = lines[#lines]:sub(finish.character+2) --- local prefix = lines[1]:sub(start.character+2) --- new_lines[#new_lines] = new_lines[#new_lines]..suffix --- new_lines[1] = prefix..new_lines[1] --- api.nvim_buf_set_lines(0, start.line, finish.line, false, new_lines) + local lines = api.nvim_buf_get_lines(bufnr, start.line, finish.line + 1, false) + local suffix = lines[#lines]:sub(finish.character+1) + local prefix = lines[1]:sub(1, start.character) + new_lines[#new_lines] = new_lines[#new_lines]..suffix + new_lines[1] = prefix..new_lines[1] + api.nvim_buf_set_lines(bufnr, start.line, finish.line + 1, false, new_lines) end -- textDocument/completion response returns one of CompletionItem[], CompletionList or null. @@ -158,18 +154,15 @@ function M.workspace_apply_workspace_edit(workspace_edit) return end - if workspace_edit.changes == nil or #workspace_edit.changes == 0 then + local all_changes = workspace_edit.changes + if not (all_changes and not vim.tbl_isempty(all_changes)) then return end - for uri, changes in pairs(workspace_edit.changes) do - local fname = vim.uri_to_fname(uri) - -- TODO improve this approach. Try to edit open buffers without switching. - -- Not sure how to handle files which aren't open. This is deprecated - -- anyway, so I guess it could be left as is. - api.nvim_command('edit '..fname) + for uri, changes in pairs(all_changes) do + local bufnr = vim.uri_to_bufnr(uri) for _, change in ipairs(changes) do - M.text_document_apply_text_edit(change) + M.text_document_apply_text_edit(change, bufnr) end end end -- cgit From 4c7ef3754a7679a7f095fa9e739a665ead4c71b4 Mon Sep 17 00:00:00 2001 From: Ashkan Kiani Date: Wed, 20 Nov 2019 16:37:23 -0800 Subject: Satisfy lualint. --- runtime/lua/vim/lsp/buf.lua | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) (limited to 'runtime/lua/vim/lsp') diff --git a/runtime/lua/vim/lsp/buf.lua b/runtime/lua/vim/lsp/buf.lua index 01174a1e48..1c2e60f1ca 100644 --- a/runtime/lua/vim/lsp/buf.lua +++ b/runtime/lua/vim/lsp/buf.lua @@ -7,14 +7,6 @@ local log = require 'vim.lsp.log' local M = {} -local function resolve_bufnr(bufnr) - validate { bufnr = { bufnr, 'n', true } } - if bufnr == nil or bufnr == 0 then - return api.nvim_get_current_buf() - end - return bufnr -end - local function ok_or_nil(status, ...) if not status then return end return ... @@ -198,7 +190,7 @@ local function signature_help_to_preview_contents(input) if not signature then return end - vim.list_extend(contents, split_lines(signature.label)) + vim.list_extend(contents, vim.split(signature.label, '\n', true)) if signature.documentation then util.convert_input_to_markdown_lines(signature.documentation, contents) end -- cgit From 9b7f95671559a9189569dcb93778e54eaa87f255 Mon Sep 17 00:00:00 2001 From: Ashkan Kiani Date: Wed, 20 Nov 2019 16:39:45 -0800 Subject: Use err_message in default_callbacks --- runtime/lua/vim/lsp/default_callbacks.lua | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) (limited to 'runtime/lua/vim/lsp') diff --git a/runtime/lua/vim/lsp/default_callbacks.lua b/runtime/lua/vim/lsp/default_callbacks.lua index 63e62075b4..67c33a1667 100644 --- a/runtime/lua/vim/lsp/default_callbacks.lua +++ b/runtime/lua/vim/lsp/default_callbacks.lua @@ -20,18 +20,23 @@ M['textDocument/publishDiagnostics'] = function(_, _, result) -- util.buf_loclist(bufnr, result.diagnostics) end +local function err_message(...) + api.nvim_err_writeln(table.concat(vim.tbl_flatten{...})) + api.nvim_command("redraw") +end + local function log_message(_, _, result, client_id) local message_type = result.type local message = result.message 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 - api.nvim_err_writeln(string.format("LSP[%s] client has shut down after sending the message", client_name)) + err_message("LSP[", client_name, "] client has shut down after sending the message") end if message_type == protocol.MessageType.Error then -- Might want to not use err_writeln, -- but displaying a message with red highlights or something - api.nvim_err_writeln(string.format("LSP[%s] %s", client_name, message)) + err_message("LSP[", client_name, "] ", message) else local message_type_name = protocol.MessageType[message_type] api.nvim_out_write(string.format("LSP[%s][%s] %s\n", client_name, message_type_name, message)) -- cgit From b984f613c1e8dadbe59bf0d7093a6ed12af61b37 Mon Sep 17 00:00:00 2001 From: Ashkan Kiani Date: Wed, 20 Nov 2019 17:09:21 -0800 Subject: Extend list_extend to take start/finish. --- runtime/lua/vim/lsp/default_callbacks.lua | 14 ++++++-------- runtime/lua/vim/lsp/util.lua | 7 +------ 2 files changed, 7 insertions(+), 14 deletions(-) (limited to 'runtime/lua/vim/lsp') diff --git a/runtime/lua/vim/lsp/default_callbacks.lua b/runtime/lua/vim/lsp/default_callbacks.lua index 67c33a1667..5e8552307b 100644 --- a/runtime/lua/vim/lsp/default_callbacks.lua +++ b/runtime/lua/vim/lsp/default_callbacks.lua @@ -5,12 +5,17 @@ local api = vim.api local M = {} +local function err_message(...) + api.nvim_err_writeln(table.concat(vim.tbl_flatten{...})) + api.nvim_command("redraw") +end + M['textDocument/publishDiagnostics'] = function(_, _, result) if not result then return end local uri = result.uri local bufnr = vim.uri_to_bufnr(uri) if not bufnr then - api.nvim_err_writeln(string.format("LSP.publishDiagnostics: Couldn't find buffer for %s", uri)) + err_message("LSP.publishDiagnostics: Couldn't find buffer for ", uri) return end util.buf_clear_diagnostics(bufnr) @@ -20,11 +25,6 @@ M['textDocument/publishDiagnostics'] = function(_, _, result) -- util.buf_loclist(bufnr, result.diagnostics) end -local function err_message(...) - api.nvim_err_writeln(table.concat(vim.tbl_flatten{...})) - api.nvim_command("redraw") -end - local function log_message(_, _, result, client_id) local message_type = result.type local message = result.message @@ -34,8 +34,6 @@ local function log_message(_, _, result, client_id) err_message("LSP[", client_name, "] client has shut down after sending the message") end if message_type == protocol.MessageType.Error then - -- Might want to not use err_writeln, - -- but displaying a message with red highlights or something err_message("LSP[", client_name, "] ", message) else local message_type_name = protocol.MessageType[message_type] diff --git a/runtime/lua/vim/lsp/util.lua b/runtime/lua/vim/lsp/util.lua index ab2c02ffcd..3a2142a478 100644 --- a/runtime/lua/vim/lsp/util.lua +++ b/runtime/lua/vim/lsp/util.lua @@ -546,7 +546,6 @@ end -- Remove empty lines from the beginning and end. function M.trim_empty_lines(lines) - local result = {} local start = 1 for i = 1, #lines do if #lines[i] > 0 then @@ -561,11 +560,7 @@ function M.trim_empty_lines(lines) break end end - -- TODO(ashkan) use tbl_slice. - for i = start, finish do - table.insert(result, lines[i]) - end - return result + return vim.list_extend({}, lines, start, finish) end -- Accepts markdown lines and tries to reduce it to a filetype if it is -- cgit From 6fc409d5939500b038ba281ac1929fd053f35310 Mon Sep 17 00:00:00 2001 From: Ashkan Kiani Date: Wed, 20 Nov 2019 20:51:44 -0800 Subject: Add full text_edit implementation. - Implements textDocument/formatting, textDocument/rangeFormatting, workspace/applyEdit. TODO: - still has edge cases around replacement probably. Only tested with inserts on the same position. --- runtime/lua/vim/lsp/buf.lua | 122 ++++++++++++++++++++++++++- runtime/lua/vim/lsp/default_callbacks.lua | 9 ++ runtime/lua/vim/lsp/util.lua | 134 ++++++++++++++++++++++-------- 3 files changed, 228 insertions(+), 37 deletions(-) (limited to 'runtime/lua/vim/lsp') diff --git a/runtime/lua/vim/lsp/buf.lua b/runtime/lua/vim/lsp/buf.lua index 1c2e60f1ca..ea88207b5b 100644 --- a/runtime/lua/vim/lsp/buf.lua +++ b/runtime/lua/vim/lsp/buf.lua @@ -259,7 +259,127 @@ function M.completion(context) end) end -function M.range_formatting() +local function apply_edit_to_lines(lines, start_pos, end_pos, new_lines) + -- 0-indexing to 1-indexing makes things look a bit worse. + local i_0 = start_pos[1] + 1 + local i_n = end_pos[1] + 1 + local n = i_n - i_0 + 1 + if not lines[i_0] or not lines[i_n] then + error(vim.inspect{#lines, i_0, i_n, n, start_pos, end_pos, new_lines}) + end + local prefix = "" + local suffix = lines[i_n]:sub(end_pos[2]+1) + lines[i_n] = lines[i_n]:sub(1, end_pos[2]+1) + if start_pos[2] > 0 then + prefix = lines[i_0]:sub(1, start_pos[2]) + -- lines[i_0] = lines[i_0]:sub(start.character+1) + end + -- TODO(ashkan) figure out how to avoid copy here. likely by changing algo. + new_lines = vim.list_extend({}, new_lines) + if #suffix > 0 then + new_lines[#new_lines] = new_lines[#new_lines]..suffix + end + if #prefix > 0 then + new_lines[1] = prefix..new_lines[1] + end + if #new_lines >= n then + for i = 1, n do + lines[i + i_0 - 1] = new_lines[i] + end + for i = n+1,#new_lines do + table.insert(lines, i_n + 1, new_lines[i]) + end + else + for i = 1, #new_lines do + lines[i + i_0 - 1] = new_lines[i] + end + for _ = #new_lines+1, n do + table.remove(lines, i_0 + #new_lines + 1) + end + end +end + +local function apply_text_edits(text_edits, bufnr) + if not next(text_edits) then return end + -- nvim.print("Start", #text_edits) + local start_line, finish_line = math.huge, -1 + local cleaned = {} + for _, e in ipairs(text_edits) do + start_line = math.min(e.range.start.line, start_line) + finish_line = math.max(e.range["end"].line, finish_line) + table.insert(cleaned, { + A = {e.range.start.line; e.range.start.character}; + B = {e.range["end"].line; e.range["end"].character}; + lines = vim.split(e.newText, '\n', true); + }) + end + local lines = api.nvim_buf_get_lines(bufnr, start_line, finish_line + 1, false) + for i, e in ipairs(cleaned) do + -- nvim.print(i, "e", e.A, e.B, #e.lines[#e.lines], e.lines) + local y = 0 + local x = 0 + -- TODO(ashkan) this could be done in O(n) with dynamic programming + for j = 1, i-1 do + local o = cleaned[j] + -- nvim.print(i, "o", o.A, o.B, x, y, #o.lines[#o.lines], o.lines) + if o.A[1] <= e.A[1] and o.A[2] <= e.A[2] then + y = y - (o.B[1] - o.A[1] + 1) + #o.lines + -- Same line + if #o.lines > 1 then + x = -e.A[2] + #o.lines[#o.lines] + else + if o.A[1] == e.A[1] then + -- Try to account for insertions. + -- TODO how to account for deletions? + x = x - (o.B[2] - o.A[2]) + #o.lines[#o.lines] + end + end + end + end + local A = {e.A[1] + y - start_line, e.A[2] + x} + local B = {e.B[1] + y - start_line, e.B[2] + x} + -- if x ~= 0 or y ~= 0 then + -- nvim.print(i, "_", e.A, e.B, y, x, A, B, e.lines) + -- end + apply_edit_to_lines(lines, A, B, e.lines) + end + api.nvim_buf_set_lines(bufnr, start_line, finish_line + 1, false, lines) +end + +function M.formatting(options) + validate { options = {options, 't', true} } + local params = { + textDocument = { uri = vim.uri_from_bufnr(0) }; + options = options or {}; + } + params.options[vim.type_idx] = vim.types.dictionary + return request('textDocument/formatting', params, function(_, _, result) + if not result then return end + apply_text_edits(result) + end) +end + +function M.range_formatting(options, start_pos, end_pos) + validate { + options = {options, 't', true}; + start_pos = {start_pos, 't', true}; + end_pos = {end_pos, 't', true}; + } + start_pos = start_pos or vim.api.nvim_buf_get_mark(0, '<') + end_pos = end_pos or vim.api.nvim_buf_get_mark(0, '>') + local params = { + textDocument = { uri = vim.uri_from_bufnr(0) }; + range = { + start = { line = start_pos[1]; character = start_pos[2]; }; + ["end"] = { line = end_pos[1]; character = end_pos[2]; }; + }; + options = options or {}; + } + params.options[vim.type_idx] = vim.types.dictionary + return request('textDocument/rangeFormatting', params, function(_, _, result) + if not result then return end + apply_text_edits(result) + end) end function M.rename(new_name) diff --git a/runtime/lua/vim/lsp/default_callbacks.lua b/runtime/lua/vim/lsp/default_callbacks.lua index 5e8552307b..d58280e0f2 100644 --- a/runtime/lua/vim/lsp/default_callbacks.lua +++ b/runtime/lua/vim/lsp/default_callbacks.lua @@ -10,6 +10,15 @@ local function err_message(...) api.nvim_command("redraw") end +M['workspace/applyEdit'] = function(_, _, workspace_edit) + if not workspace_edit then return end + -- TODO(ashkan) Do something more with label? + if workspace_edit.label then + print("Workspace edit", workspace_edit.label) + end + util.apply_workspace_edit(workspace_edit.edit) +end + M['textDocument/publishDiagnostics'] = function(_, _, result) if not result then return end local uri = result.uri diff --git a/runtime/lua/vim/lsp/util.lua b/runtime/lua/vim/lsp/util.lua index 3a2142a478..004fb81cba 100644 --- a/runtime/lua/vim/lsp/util.lua +++ b/runtime/lua/vim/lsp/util.lua @@ -33,6 +33,93 @@ local function resolve_bufnr(bufnr) return bufnr end +function M.apply_edit_to_lines(lines, start_pos, end_pos, new_lines) + -- 0-indexing to 1-indexing makes things look a bit worse. + local i_0 = start_pos[1] + 1 + local i_n = end_pos[1] + 1 + local n = i_n - i_0 + 1 + if not lines[i_0] or not lines[i_n] then + error(vim.inspect{#lines, i_0, i_n, n, start_pos, end_pos, new_lines}) + end + local prefix = "" + local suffix = lines[i_n]:sub(end_pos[2]+1) + lines[i_n] = lines[i_n]:sub(1, end_pos[2]+1) + if start_pos[2] > 0 then + prefix = lines[i_0]:sub(1, start_pos[2]) + -- lines[i_0] = lines[i_0]:sub(start.character+1) + end + -- TODO(ashkan) figure out how to avoid copy here. likely by changing algo. + new_lines = vim.list_extend({}, new_lines) + if #suffix > 0 then + new_lines[#new_lines] = new_lines[#new_lines]..suffix + end + if #prefix > 0 then + new_lines[1] = prefix..new_lines[1] + end + if #new_lines >= n then + for i = 1, n do + lines[i + i_0 - 1] = new_lines[i] + end + for i = n+1,#new_lines do + table.insert(lines, i_n + 1, new_lines[i]) + end + else + for i = 1, #new_lines do + lines[i + i_0 - 1] = new_lines[i] + end + for _ = #new_lines+1, n do + table.remove(lines, i_0 + #new_lines + 1) + end + end +end + +function M.apply_text_edits(text_edits, bufnr) + if not next(text_edits) then return end + -- nvim.print("Start", #text_edits) + local start_line, finish_line = math.huge, -1 + local cleaned = {} + for _, e in ipairs(text_edits) do + start_line = math.min(e.range.start.line, start_line) + finish_line = math.max(e.range["end"].line, finish_line) + table.insert(cleaned, { + A = {e.range.start.line; e.range.start.character}; + B = {e.range["end"].line; e.range["end"].character}; + lines = vim.split(e.newText, '\n', true); + }) + end + local lines = api.nvim_buf_get_lines(bufnr, start_line, finish_line + 1, false) + for i, e in ipairs(cleaned) do + -- nvim.print(i, "e", e.A, e.B, #e.lines[#e.lines], e.lines) + local y = 0 + local x = 0 + -- TODO(ashkan) this could be done in O(n) with dynamic programming + for j = 1, i-1 do + local o = cleaned[j] + -- nvim.print(i, "o", o.A, o.B, x, y, #o.lines[#o.lines], o.lines) + if o.A[1] <= e.A[1] and o.A[2] <= e.A[2] then + y = y - (o.B[1] - o.A[1] + 1) + #o.lines + -- Same line + if #o.lines > 1 then + x = -e.A[2] + #o.lines[#o.lines] + else + if o.A[1] == e.A[1] then + -- Try to account for insertions. + -- TODO how to account for deletions? + x = x - (o.B[2] - o.A[2]) + #o.lines[#o.lines] + end + end + end + end + local A = {e.A[1] + y - start_line, e.A[2] + x} + local B = {e.B[1] + y - start_line, e.B[2] + x} + -- if x ~= 0 or y ~= 0 then + -- nvim.print(i, "_", e.A, e.B, y, x, A, B, e.lines) + -- end + M.apply_edit_to_lines(lines, A, B, e.lines) + end + api.nvim_buf_set_lines(bufnr, start_line, finish_line + 1, false, lines) +end + -- local valid_windows_path_characters = "[^<>:\"/\\|?*]" -- local valid_unix_path_characters = "[^/]" -- https://github.com/davidm/lua-glob-pattern @@ -40,26 +127,6 @@ end -- function M.glob_to_regex(glob) -- end ---- Apply the TextEdit response. --- @params TextEdit [table] see https://microsoft.github.io/language-server-protocol/specification -function M.text_document_apply_text_edit(text_edit, bufnr) - bufnr = resolve_bufnr(bufnr) - local range = text_edit.range - local start = range.start - local finish = range['end'] - local new_lines = split_lines(text_edit.newText) - if start.character == 0 and finish.character == 0 then - api.nvim_buf_set_lines(bufnr, start.line, finish.line, false, new_lines) - return - end - local lines = api.nvim_buf_get_lines(bufnr, start.line, finish.line + 1, false) - local suffix = lines[#lines]:sub(finish.character+1) - local prefix = lines[1]:sub(1, start.character) - new_lines[#new_lines] = new_lines[#new_lines]..suffix - new_lines[1] = prefix..new_lines[1] - api.nvim_buf_set_lines(bufnr, start.line, finish.line + 1, false, new_lines) -end - -- textDocument/completion response returns one of CompletionItem[], CompletionList or null. -- https://microsoft.github.io/language-server-protocol/specification#textDocument_completion function M.extract_completion_items(result) @@ -74,18 +141,15 @@ end --- Apply the TextDocumentEdit response. -- @params TextDocumentEdit [table] see https://microsoft.github.io/language-server-protocol/specification -function M.text_document_apply_text_document_edit(text_document_edit, bufnr) - -- local text_document = text_document_edit.textDocument - -- TODO use text_document_version? - -- local text_document_version = text_document.version - - -- TODO technically, you could do this without doing multiple buf_get/set - -- by getting the full region (smallest line and largest line) and doing - -- the edits on the buffer, and then applying the buffer at the end. - -- I'm not sure if that's better. - for _, text_edit in ipairs(text_document_edit.edits) do - M.text_document_apply_text_edit(text_edit, bufnr) +function M.apply_text_document_edit(text_document_edit) + local text_document = text_document_edit.textDocument + local bufnr = vim.uri_to_bufnr(text_document.uri) + -- TODO(ashkan) check this is correct. + if api.nvim_buf_get_changedtick(bufnr) > text_document.version then + print("Buffer ", text_document.uri, " newer than edits.") + return end + M.apply_text_edits(text_document_edit.edits, bufnr) end function M.get_current_line_to_cursor() @@ -141,14 +205,14 @@ function M.text_document_completion_list_to_complete_items(result, line_prefix) end -- @params WorkspaceEdit [table] see https://microsoft.github.io/language-server-protocol/specification -function M.workspace_apply_workspace_edit(workspace_edit) +function M.apply_workspace_edit(workspace_edit) if workspace_edit.documentChanges then for _, change in ipairs(workspace_edit.documentChanges) do if change.kind then -- TODO(ashkan) handle CreateFile/RenameFile/DeleteFile error(string.format("Unsupported change: %q", vim.inspect(change))) else - M.text_document_apply_text_document_edit(change) + M.apply_text_document_edit(change) end end return @@ -161,9 +225,7 @@ function M.workspace_apply_workspace_edit(workspace_edit) for uri, changes in pairs(all_changes) do local bufnr = vim.uri_to_bufnr(uri) - for _, change in ipairs(changes) do - M.text_document_apply_text_edit(change, bufnr) - end + M.apply_text_edits(changes, bufnr) end end -- cgit From 6d9f48ddcf3a4d0fbed0674ce180bcfb9340bb22 Mon Sep 17 00:00:00 2001 From: Ashkan Kiani Date: Wed, 20 Nov 2019 20:57:21 -0800 Subject: Fix reference in rename. --- runtime/lua/vim/lsp/buf.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'runtime/lua/vim/lsp') diff --git a/runtime/lua/vim/lsp/buf.lua b/runtime/lua/vim/lsp/buf.lua index ea88207b5b..68af34dac6 100644 --- a/runtime/lua/vim/lsp/buf.lua +++ b/runtime/lua/vim/lsp/buf.lua @@ -391,7 +391,7 @@ function M.rename(new_name) params.newName = new_name request('textDocument/rename', params, function(_, _, result) if not result then return end - util.workspace_apply_workspace_edit(result) + util.apply_workspace_edit(result) end) end -- cgit From 7bf766ad0903574b6202af0d2e11d306d656b491 Mon Sep 17 00:00:00 2001 From: Ashkan Kiani Date: Wed, 20 Nov 2019 20:59:12 -0800 Subject: Use the apply_text_edits from util. --- runtime/lua/vim/lsp/buf.lua | 91 +-------------------------------------------- 1 file changed, 2 insertions(+), 89 deletions(-) (limited to 'runtime/lua/vim/lsp') diff --git a/runtime/lua/vim/lsp/buf.lua b/runtime/lua/vim/lsp/buf.lua index 68af34dac6..260bca281f 100644 --- a/runtime/lua/vim/lsp/buf.lua +++ b/runtime/lua/vim/lsp/buf.lua @@ -259,93 +259,6 @@ function M.completion(context) end) end -local function apply_edit_to_lines(lines, start_pos, end_pos, new_lines) - -- 0-indexing to 1-indexing makes things look a bit worse. - local i_0 = start_pos[1] + 1 - local i_n = end_pos[1] + 1 - local n = i_n - i_0 + 1 - if not lines[i_0] or not lines[i_n] then - error(vim.inspect{#lines, i_0, i_n, n, start_pos, end_pos, new_lines}) - end - local prefix = "" - local suffix = lines[i_n]:sub(end_pos[2]+1) - lines[i_n] = lines[i_n]:sub(1, end_pos[2]+1) - if start_pos[2] > 0 then - prefix = lines[i_0]:sub(1, start_pos[2]) - -- lines[i_0] = lines[i_0]:sub(start.character+1) - end - -- TODO(ashkan) figure out how to avoid copy here. likely by changing algo. - new_lines = vim.list_extend({}, new_lines) - if #suffix > 0 then - new_lines[#new_lines] = new_lines[#new_lines]..suffix - end - if #prefix > 0 then - new_lines[1] = prefix..new_lines[1] - end - if #new_lines >= n then - for i = 1, n do - lines[i + i_0 - 1] = new_lines[i] - end - for i = n+1,#new_lines do - table.insert(lines, i_n + 1, new_lines[i]) - end - else - for i = 1, #new_lines do - lines[i + i_0 - 1] = new_lines[i] - end - for _ = #new_lines+1, n do - table.remove(lines, i_0 + #new_lines + 1) - end - end -end - -local function apply_text_edits(text_edits, bufnr) - if not next(text_edits) then return end - -- nvim.print("Start", #text_edits) - local start_line, finish_line = math.huge, -1 - local cleaned = {} - for _, e in ipairs(text_edits) do - start_line = math.min(e.range.start.line, start_line) - finish_line = math.max(e.range["end"].line, finish_line) - table.insert(cleaned, { - A = {e.range.start.line; e.range.start.character}; - B = {e.range["end"].line; e.range["end"].character}; - lines = vim.split(e.newText, '\n', true); - }) - end - local lines = api.nvim_buf_get_lines(bufnr, start_line, finish_line + 1, false) - for i, e in ipairs(cleaned) do - -- nvim.print(i, "e", e.A, e.B, #e.lines[#e.lines], e.lines) - local y = 0 - local x = 0 - -- TODO(ashkan) this could be done in O(n) with dynamic programming - for j = 1, i-1 do - local o = cleaned[j] - -- nvim.print(i, "o", o.A, o.B, x, y, #o.lines[#o.lines], o.lines) - if o.A[1] <= e.A[1] and o.A[2] <= e.A[2] then - y = y - (o.B[1] - o.A[1] + 1) + #o.lines - -- Same line - if #o.lines > 1 then - x = -e.A[2] + #o.lines[#o.lines] - else - if o.A[1] == e.A[1] then - -- Try to account for insertions. - -- TODO how to account for deletions? - x = x - (o.B[2] - o.A[2]) + #o.lines[#o.lines] - end - end - end - end - local A = {e.A[1] + y - start_line, e.A[2] + x} - local B = {e.B[1] + y - start_line, e.B[2] + x} - -- if x ~= 0 or y ~= 0 then - -- nvim.print(i, "_", e.A, e.B, y, x, A, B, e.lines) - -- end - apply_edit_to_lines(lines, A, B, e.lines) - end - api.nvim_buf_set_lines(bufnr, start_line, finish_line + 1, false, lines) -end - function M.formatting(options) validate { options = {options, 't', true} } local params = { @@ -355,7 +268,7 @@ function M.formatting(options) params.options[vim.type_idx] = vim.types.dictionary return request('textDocument/formatting', params, function(_, _, result) if not result then return end - apply_text_edits(result) + util.apply_text_edits(result) end) end @@ -378,7 +291,7 @@ function M.range_formatting(options, start_pos, end_pos) params.options[vim.type_idx] = vim.types.dictionary return request('textDocument/rangeFormatting', params, function(_, _, result) if not result then return end - apply_text_edits(result) + util.apply_text_edits(result) end) end -- cgit From b7170f2d722cee24a62eb74ac836d6192e5475dc Mon Sep 17 00:00:00 2001 From: Ashkan Kiani Date: Wed, 20 Nov 2019 20:59:52 -0800 Subject: Remove resolve_bufnr/lualint --- runtime/lua/vim/lsp/util.lua | 7 ------- 1 file changed, 7 deletions(-) (limited to 'runtime/lua/vim/lsp') diff --git a/runtime/lua/vim/lsp/util.lua b/runtime/lua/vim/lsp/util.lua index 004fb81cba..dcf2c17df4 100644 --- a/runtime/lua/vim/lsp/util.lua +++ b/runtime/lua/vim/lsp/util.lua @@ -26,13 +26,6 @@ local function remove_prefix(prefix, word) return word:sub(prefix_length + 1) end -local function resolve_bufnr(bufnr) - if bufnr == nil or bufnr == 0 then - return api.nvim_get_current_buf() - end - return bufnr -end - function M.apply_edit_to_lines(lines, start_pos, end_pos, new_lines) -- 0-indexing to 1-indexing makes things look a bit worse. local i_0 = start_pos[1] + 1 -- cgit From bcae04f6c62b23104e85bb08c54a16d0cdc33852 Mon Sep 17 00:00:00 2001 From: Ashkan Kiani Date: Thu, 21 Nov 2019 15:19:06 -0800 Subject: Updates - Use correct implementation of text_edits. - Send indent options to rangeFormatting and formatting. - Remove references to vim bindings and filetype from lsp.txt - Add more examples to docs. - Add before_init to allow changing initialize_params. --- runtime/lua/vim/lsp/buf.lua | 14 ++++-- runtime/lua/vim/lsp/util.lua | 110 ++++++++++++++++++++----------------------- 2 files changed, 62 insertions(+), 62 deletions(-) (limited to 'runtime/lua/vim/lsp') diff --git a/runtime/lua/vim/lsp/buf.lua b/runtime/lua/vim/lsp/buf.lua index 260bca281f..dbc141a906 100644 --- a/runtime/lua/vim/lsp/buf.lua +++ b/runtime/lua/vim/lsp/buf.lua @@ -261,11 +261,14 @@ end function M.formatting(options) validate { options = {options, 't', true} } + options = vim.tbl_extend('keep', options or {}, { + tabSize = api.nvim_buf_get_option(0, 'tabstop'); + insertSpaces = api.nvim_buf_get_option(0, 'expandtab'); + }) local params = { textDocument = { uri = vim.uri_from_bufnr(0) }; - options = options or {}; + options = options; } - params.options[vim.type_idx] = vim.types.dictionary return request('textDocument/formatting', params, function(_, _, result) if not result then return end util.apply_text_edits(result) @@ -278,6 +281,10 @@ function M.range_formatting(options, start_pos, end_pos) start_pos = {start_pos, 't', true}; end_pos = {end_pos, 't', true}; } + options = vim.tbl_extend('keep', options or {}, { + tabSize = api.nvim_buf_get_option(0, 'tabstop'); + insertSpaces = api.nvim_buf_get_option(0, 'expandtab'); + }) start_pos = start_pos or vim.api.nvim_buf_get_mark(0, '<') end_pos = end_pos or vim.api.nvim_buf_get_mark(0, '>') local params = { @@ -286,9 +293,8 @@ function M.range_formatting(options, start_pos, end_pos) start = { line = start_pos[1]; character = start_pos[2]; }; ["end"] = { line = end_pos[1]; character = end_pos[2]; }; }; - options = options or {}; + options = options; } - params.options[vim.type_idx] = vim.types.dictionary return request('textDocument/rangeFormatting', params, function(_, _, result) if not result then return end util.apply_text_edits(result) diff --git a/runtime/lua/vim/lsp/util.lua b/runtime/lua/vim/lsp/util.lua index dcf2c17df4..66430fffc7 100644 --- a/runtime/lua/vim/lsp/util.lua +++ b/runtime/lua/vim/lsp/util.lua @@ -26,89 +26,83 @@ local function remove_prefix(prefix, word) return word:sub(prefix_length + 1) end -function M.apply_edit_to_lines(lines, start_pos, end_pos, new_lines) - -- 0-indexing to 1-indexing makes things look a bit worse. - local i_0 = start_pos[1] + 1 - local i_n = end_pos[1] + 1 - local n = i_n - i_0 + 1 - if not lines[i_0] or not lines[i_n] then - error(vim.inspect{#lines, i_0, i_n, n, start_pos, end_pos, new_lines}) +-- TODO(ashkan) @performance this could do less copying. +function M.set_lines(lines, A, B, new_lines) + -- 0-indexing to 1-indexing + local i_0 = A[1] + 1 + local i_n = B[1] + 1 + if not (i_0 >= 1 and i_0 <= #lines and i_n >= 1 and i_n <= #lines) then + error("Invalid range: "..vim.inspect{A = A; B = B; #lines, new_lines}) end local prefix = "" - local suffix = lines[i_n]:sub(end_pos[2]+1) - lines[i_n] = lines[i_n]:sub(1, end_pos[2]+1) - if start_pos[2] > 0 then - prefix = lines[i_0]:sub(1, start_pos[2]) - -- lines[i_0] = lines[i_0]:sub(start.character+1) - end - -- TODO(ashkan) figure out how to avoid copy here. likely by changing algo. - new_lines = vim.list_extend({}, new_lines) + local suffix = lines[i_n]:sub(B[2]+1) + if A[2] > 0 then + prefix = lines[i_0]:sub(1, A[2]) + end + new_lines = list_extend({}, new_lines) if #suffix > 0 then new_lines[#new_lines] = new_lines[#new_lines]..suffix end if #prefix > 0 then new_lines[1] = prefix..new_lines[1] end - if #new_lines >= n then - for i = 1, n do - lines[i + i_0 - 1] = new_lines[i] - end - for i = n+1,#new_lines do - table.insert(lines, i_n + 1, new_lines[i]) - end - else - for i = 1, #new_lines do - lines[i + i_0 - 1] = new_lines[i] - end - for _ = #new_lines+1, n do - table.remove(lines, i_0 + #new_lines + 1) + local result = list_extend({}, lines, 1, i_0 - 1) + list_extend(result, new_lines) + list_extend(result, lines, i_n + 1) + return result +end + +local function sort_by_key(fn) + return function(a,b) + local ka, kb = fn(a), fn(b) + assert(#ka == #kb) + for i = 1, #ka do + if ka[i] ~= kb[i] then + return ka[i] < kb[i] + end end + -- every value must have been equal here, which means it's not less than. + return false end end +local edit_sort_key = sort_by_key(function(e) + return {e.A[1], e.A[2], e.i} +end) function M.apply_text_edits(text_edits, bufnr) if not next(text_edits) then return end - -- nvim.print("Start", #text_edits) local start_line, finish_line = math.huge, -1 local cleaned = {} - for _, e in ipairs(text_edits) do + for i, e in ipairs(text_edits) do start_line = math.min(e.range.start.line, start_line) finish_line = math.max(e.range["end"].line, finish_line) + -- TODO(ashkan) sanity check ranges for overlap. table.insert(cleaned, { + i = i; A = {e.range.start.line; e.range.start.character}; B = {e.range["end"].line; e.range["end"].character}; lines = vim.split(e.newText, '\n', true); }) end + + -- Reverse sort the orders so we can apply them without interfering with + -- eachother. Also add i as a sort key to mimic a stable sort. + table.sort(cleaned, edit_sort_key) local lines = api.nvim_buf_get_lines(bufnr, start_line, finish_line + 1, false) - for i, e in ipairs(cleaned) do - -- nvim.print(i, "e", e.A, e.B, #e.lines[#e.lines], e.lines) - local y = 0 - local x = 0 - -- TODO(ashkan) this could be done in O(n) with dynamic programming - for j = 1, i-1 do - local o = cleaned[j] - -- nvim.print(i, "o", o.A, o.B, x, y, #o.lines[#o.lines], o.lines) - if o.A[1] <= e.A[1] and o.A[2] <= e.A[2] then - y = y - (o.B[1] - o.A[1] + 1) + #o.lines - -- Same line - if #o.lines > 1 then - x = -e.A[2] + #o.lines[#o.lines] - else - if o.A[1] == e.A[1] then - -- Try to account for insertions. - -- TODO how to account for deletions? - x = x - (o.B[2] - o.A[2]) + #o.lines[#o.lines] - end - end - end - end - local A = {e.A[1] + y - start_line, e.A[2] + x} - local B = {e.B[1] + y - start_line, e.B[2] + x} - -- if x ~= 0 or y ~= 0 then - -- nvim.print(i, "_", e.A, e.B, y, x, A, B, e.lines) - -- end - M.apply_edit_to_lines(lines, A, B, e.lines) + local fix_eol = api.nvim_buf_get_option(bufnr, 'fixeol') + local set_eol = fix_eol and api.nvim_buf_line_count(bufnr) == finish_line + 1 + if set_eol and #lines[#lines] ~= 0 then + table.insert(lines, '') + end + + for i = #cleaned, 1, -1 do + local e = cleaned[i] + local A = {e.A[1] - start_line, e.A[2]} + local B = {e.B[1] - start_line, e.B[2]} + lines = M.set_lines(lines, A, B, e.lines) + end + if set_eol and #lines[#lines] == 0 then + table.remove(lines) end api.nvim_buf_set_lines(bufnr, start_line, finish_line + 1, false, lines) end -- cgit From b27965538dcc3faa8e1c5b3d64a98108c18981ec Mon Sep 17 00:00:00 2001 From: Ashkan Kiani Date: Thu, 21 Nov 2019 15:20:32 -0800 Subject: Fix hovers staying on bufhidden --- runtime/lua/vim/lsp/util.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'runtime/lua/vim/lsp') diff --git a/runtime/lua/vim/lsp/util.lua b/runtime/lua/vim/lsp/util.lua index 66430fffc7..77f2ae6fbc 100644 --- a/runtime/lua/vim/lsp/util.lua +++ b/runtime/lua/vim/lsp/util.lua @@ -335,7 +335,7 @@ function M.open_floating_preview(contents, filetype, opts) end api.nvim_buf_set_lines(floating_bufnr, 0, -1, true, contents) api.nvim_buf_set_option(floating_bufnr, 'modifiable', false) - api.nvim_command("autocmd CursorMoved ++once lua pcall(vim.api.nvim_win_close, "..floating_winnr..", true)") + api.nvim_command("autocmd CursorMoved,BufHidden ++once lua pcall(vim.api.nvim_win_close, "..floating_winnr..", true)") return floating_bufnr, floating_winnr end -- cgit From da07e71b189a51008a93783b5f7894dc495c277e Mon Sep 17 00:00:00 2001 From: Ashkan Kiani Date: Thu, 21 Nov 2019 15:34:28 -0800 Subject: Account for character length in jump position. --- runtime/lua/vim/lsp/buf.lua | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) (limited to 'runtime/lua/vim/lsp') diff --git a/runtime/lua/vim/lsp/buf.lua b/runtime/lua/vim/lsp/buf.lua index dbc141a906..0b4ac6ca9b 100644 --- a/runtime/lua/vim/lsp/buf.lua +++ b/runtime/lua/vim/lsp/buf.lua @@ -134,12 +134,14 @@ local function handle_location(result) err_message('[LSP] Could not find a valid location') return end - local result_file = vim.uri_to_fname(result.uri) - local bufnr = vfn.bufadd(result_file) + local bufnr = vim.uri_to_bufnr(result.uri) update_tagstack() api.nvim_set_current_buf(bufnr) - local start = result.range.start - api.nvim_win_set_cursor(0, {start.line + 1, start.character}) + local row = result.range.start.line + local col = result.range.start.character + local line = api.nvim_buf_get_lines(0, row, row+1, true)[1] + col = #line:sub(1, col) + api.nvim_win_set_cursor(0, {row + 1, col}) return true end local function location_callback(_, method, result) -- cgit From 43d73ee884f97861c44adab4797a8fc185af1436 Mon Sep 17 00:00:00 2001 From: Ashkan Kiani Date: Thu, 21 Nov 2019 15:41:32 -0800 Subject: Fix position params for encoding. --- runtime/lua/vim/lsp/buf.lua | 18 +++++++++--------- runtime/lua/vim/lsp/protocol.lua | 13 ------------- runtime/lua/vim/lsp/util.lua | 11 +++++++++++ 3 files changed, 20 insertions(+), 22 deletions(-) (limited to 'runtime/lua/vim/lsp') diff --git a/runtime/lua/vim/lsp/buf.lua b/runtime/lua/vim/lsp/buf.lua index 0b4ac6ca9b..ed26e80c3c 100644 --- a/runtime/lua/vim/lsp/buf.lua +++ b/runtime/lua/vim/lsp/buf.lua @@ -65,7 +65,7 @@ local function focusable_preview(method, params, fn) end function M.hover() - local params = protocol.make_text_document_position_params() + local params = util.make_position_params() focusable_preview('textDocument/hover', params, function(result) if not (result and result.contents) then return end @@ -79,7 +79,7 @@ function M.hover() end function M.peek_definition() - local params = protocol.make_text_document_position_params() + local params = util.make_position_params() request('textDocument/peekDefinition', params, function(_, _, result, _) if not (result and result[1]) then return end local loc = result[1] @@ -153,22 +153,22 @@ local function location_callback(_, method, result) end function M.declaration() - local params = protocol.make_text_document_position_params() + local params = util.make_position_params() request('textDocument/declaration', params, location_callback) end function M.definition() - local params = protocol.make_text_document_position_params() + local params = util.make_position_params() request('textDocument/definition', params, location_callback) end function M.type_definition() - local params = protocol.make_text_document_position_params() + local params = util.make_position_params() request('textDocument/typeDefinition', params, location_callback) end function M.implementation() - local params = protocol.make_text_document_position_params() + local params = util.make_position_params() request('textDocument/implementation', params, location_callback) end @@ -232,7 +232,7 @@ local function signature_help_to_preview_contents(input) end function M.signature_help() - local params = protocol.make_text_document_position_params() + local params = util.make_position_params() focusable_preview('textDocument/signatureHelp', params, function(result) if not (result and result.signatures and result.signatures[1]) then return end @@ -248,7 +248,7 @@ end -- TODO(ashkan) ? function M.completion(context) - local params = protocol.make_text_document_position_params() + local params = util.make_position_params() params.context = context return request('textDocument/completion', params, function(_, _, result) if vim.tbl_isempty(result or {}) then return end @@ -306,7 +306,7 @@ end function M.rename(new_name) -- TODO(ashkan) use prepareRename -- * result: [`Range`](#range) \| `{ range: Range, placeholder: string }` \| `null` describing the range of the string to rename and optionally a placeholder text of the string content to be renamed. If `null` is returned then it is deemed that a 'textDocument/rename' request is not valid at the given position. - local params = protocol.make_text_document_position_params() + local params = util.make_position_params() new_name = new_name or npcall(vfn.input, "New Name: ") if not (new_name and #new_name > 0) then return end params.newName = new_name diff --git a/runtime/lua/vim/lsp/protocol.lua b/runtime/lua/vim/lsp/protocol.lua index 1f51e7bef7..ead90cc75a 100644 --- a/runtime/lua/vim/lsp/protocol.lua +++ b/runtime/lua/vim/lsp/protocol.lua @@ -680,19 +680,6 @@ function protocol.make_client_capabilities() } end -function protocol.make_text_document_position_params() - local position = vim.api.nvim_win_get_cursor(0) - return { - textDocument = { - uri = vim.uri_from_bufnr() - }; - position = { - line = position[1] - 1; - character = position[2]; - } - } -end - --[=[ export interface DocumentFilter { --A language id, like `typescript`. diff --git a/runtime/lua/vim/lsp/util.lua b/runtime/lua/vim/lsp/util.lua index 77f2ae6fbc..76681920bd 100644 --- a/runtime/lua/vim/lsp/util.lua +++ b/runtime/lua/vim/lsp/util.lua @@ -638,6 +638,17 @@ function M.try_trim_markdown_code_blocks(lines) return 'markdown' end +function M.make_position_params() + local row, col = unpack(api.nvim_win_get_cursor(0)) + row = row - 1 + local line = api.nvim_buf_get_lines(0, row, row+1, true)[1] + col = vim.str_utfindex(line, col) + return { + textDocument = { uri = vim.uri_from_bufnr(0) }; + position = { line = row; character = col; } + } +end + return M -- vim:sw=2 ts=2 et -- cgit From a3d67dac5f9de6099890ae4b9fab2b70b2279a29 Mon Sep 17 00:00:00 2001 From: Ashkan Kiani Date: Thu, 21 Nov 2019 16:23:12 -0800 Subject: Fix encoding translation in other places. --- runtime/lua/vim/lsp/buf.lua | 22 ++++++++++++++++------ runtime/lua/vim/lsp/util.lua | 7 +++++++ 2 files changed, 23 insertions(+), 6 deletions(-) (limited to 'runtime/lua/vim/lsp') diff --git a/runtime/lua/vim/lsp/buf.lua b/runtime/lua/vim/lsp/buf.lua index ed26e80c3c..b4e0b9cbfc 100644 --- a/runtime/lua/vim/lsp/buf.lua +++ b/runtime/lua/vim/lsp/buf.lua @@ -2,8 +2,8 @@ local validate = vim.validate local api = vim.api local vfn = vim.fn local util = require 'vim.lsp.util' -local protocol = require 'vim.lsp.protocol' local log = require 'vim.lsp.log' +local list_extend = vim.list_extend local M = {} @@ -192,7 +192,7 @@ local function signature_help_to_preview_contents(input) if not signature then return end - vim.list_extend(contents, vim.split(signature.label, '\n', true)) + list_extend(contents, vim.split(signature.label, '\n', true)) if signature.documentation then util.convert_input_to_markdown_lines(signature.documentation, contents) end @@ -287,13 +287,23 @@ function M.range_formatting(options, start_pos, end_pos) tabSize = api.nvim_buf_get_option(0, 'tabstop'); insertSpaces = api.nvim_buf_get_option(0, 'expandtab'); }) - start_pos = start_pos or vim.api.nvim_buf_get_mark(0, '<') - end_pos = end_pos or vim.api.nvim_buf_get_mark(0, '>') + local A = list_extend({}, start_pos or api.nvim_buf_get_mark(0, '<')) + local B = list_extend({}, end_pos or api.nvim_buf_get_mark(0, '>')) + -- convert to 0-index + A[1] = A[1] - 1 + B[1] = B[1] - 1 + -- account for encoding. + if A[2] > 0 then + A = {A[1], util.character_offset(0, unpack(A))} + end + if B[2] > 0 then + B = {B[1], util.character_offset(0, unpack(B))} + end local params = { textDocument = { uri = vim.uri_from_bufnr(0) }; range = { - start = { line = start_pos[1]; character = start_pos[2]; }; - ["end"] = { line = end_pos[1]; character = end_pos[2]; }; + start = { line = A[1]; character = A[2]; }; + ["end"] = { line = B[1]; character = B[2]; }; }; options = options; } diff --git a/runtime/lua/vim/lsp/util.lua b/runtime/lua/vim/lsp/util.lua index 76681920bd..570c4df1dd 100644 --- a/runtime/lua/vim/lsp/util.lua +++ b/runtime/lua/vim/lsp/util.lua @@ -649,6 +649,13 @@ function M.make_position_params() } end +-- @param buf buffer handle or 0 for current. +-- @param row 0-indexed line +-- @param col 0-indexed byte offset in line +function M.character_offset(buf, row, col) + local line = api.nvim_buf_get_lines(buf, row, row+1, true)[1] + return vim.str_utfindex(line, col) +end return M -- vim:sw=2 ts=2 et -- cgit From 78991ffbf4357ba1ad477a13991078bb4a0bdc58 Mon Sep 17 00:00:00 2001 From: Ashkan Kiani Date: Thu, 21 Nov 2019 23:58:32 -0800 Subject: Improve performance of util.set_lines + bugfix Also permit character_offset for col past the end of line (useful in range formatting). --- runtime/lua/vim/lsp/util.lua | 36 ++++++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 10 deletions(-) (limited to 'runtime/lua/vim/lsp') diff --git a/runtime/lua/vim/lsp/util.lua b/runtime/lua/vim/lsp/util.lua index 570c4df1dd..94b4223a20 100644 --- a/runtime/lua/vim/lsp/util.lua +++ b/runtime/lua/vim/lsp/util.lua @@ -11,6 +11,14 @@ end local list_extend = vim.list_extend +local function ok_or_nil(status, ...) + if not status then return end + return ... +end +local function npcall(fn, ...) + return ok_or_nil(pcall(fn, ...)) +end + --- Find the longest shared prefix between prefix and word. -- e.g. remove_prefix("123tes", "testing") == "ting" local function remove_prefix(prefix, word) @@ -39,17 +47,22 @@ function M.set_lines(lines, A, B, new_lines) if A[2] > 0 then prefix = lines[i_0]:sub(1, A[2]) end - new_lines = list_extend({}, new_lines) + local n = i_n - i_0 + 1 + if n ~= #new_lines then + for _ = 1, n - #new_lines do table.remove(lines, i_0) end + for _ = 1, #new_lines - n do table.insert(lines, i_0, '') end + end + for i = 1, #new_lines do + lines[i - 1 + i_0] = new_lines[i] + end if #suffix > 0 then - new_lines[#new_lines] = new_lines[#new_lines]..suffix + local i = i_0 + #new_lines - 1 + lines[i] = lines[i]..suffix end if #prefix > 0 then - new_lines[1] = prefix..new_lines[1] + lines[i_0] = prefix..lines[i_0] end - local result = list_extend({}, lines, 1, i_0 - 1) - list_extend(result, new_lines) - list_extend(result, lines, i_n + 1) - return result + return lines end local function sort_by_key(fn) @@ -99,7 +112,7 @@ function M.apply_text_edits(text_edits, bufnr) local e = cleaned[i] local A = {e.A[1] - start_line, e.A[2]} local B = {e.B[1] - start_line, e.B[2]} - lines = M.set_lines(lines, A, B, e.lines) + M.set_lines(lines, A, B, e.lines) end if set_eol and #lines[#lines] == 0 then table.remove(lines) @@ -638,11 +651,12 @@ function M.try_trim_markdown_code_blocks(lines) return 'markdown' end +local str_utfindex = vim.str_utfindex function M.make_position_params() local row, col = unpack(api.nvim_win_get_cursor(0)) row = row - 1 local line = api.nvim_buf_get_lines(0, row, row+1, true)[1] - col = vim.str_utfindex(line, col) + col = str_utfindex(line, col) return { textDocument = { uri = vim.uri_from_bufnr(0) }; position = { line = row; character = col; } @@ -654,7 +668,9 @@ end -- @param col 0-indexed byte offset in line function M.character_offset(buf, row, col) local line = api.nvim_buf_get_lines(buf, row, row+1, true)[1] - return vim.str_utfindex(line, col) + -- TODO(ashkan) is there a better way to handle col being past line length? + -- If the col is past the EOL, use the line length. + return npcall(str_utfindex, line, col) or str_utfindex(line) end return M -- cgit From 73487f4130581da72c9e838189aab39c79c177c5 Mon Sep 17 00:00:00 2001 From: Ashkan Kiani Date: Fri, 22 Nov 2019 00:31:10 -0800 Subject: Improve the character_offset code. --- runtime/lua/vim/lsp/util.lua | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) (limited to 'runtime/lua/vim/lsp') diff --git a/runtime/lua/vim/lsp/util.lua b/runtime/lua/vim/lsp/util.lua index 94b4223a20..e2a8748a20 100644 --- a/runtime/lua/vim/lsp/util.lua +++ b/runtime/lua/vim/lsp/util.lua @@ -11,14 +11,6 @@ end local list_extend = vim.list_extend -local function ok_or_nil(status, ...) - if not status then return end - return ... -end -local function npcall(fn, ...) - return ok_or_nil(pcall(fn, ...)) -end - --- Find the longest shared prefix between prefix and word. -- e.g. remove_prefix("123tes", "testing") == "ting" local function remove_prefix(prefix, word) @@ -668,9 +660,11 @@ end -- @param col 0-indexed byte offset in line function M.character_offset(buf, row, col) local line = api.nvim_buf_get_lines(buf, row, row+1, true)[1] - -- TODO(ashkan) is there a better way to handle col being past line length? -- If the col is past the EOL, use the line length. - return npcall(str_utfindex, line, col) or str_utfindex(line) + if col > #line then + return str_utfindex(line) + end + return str_utfindex(line, col) end return M -- cgit From d410812311f7b462420690455914ea1316953b3a Mon Sep 17 00:00:00 2001 From: Ashkan Kiani Date: Sat, 23 Nov 2019 16:14:24 -0800 Subject: UI tweaks. - Hide diagnostics on client exit - Stop insert on popup focus. - Hide popup on insertchar (for signature_help) --- runtime/lua/vim/lsp/buf.lua | 8 ++++++-- runtime/lua/vim/lsp/util.lua | 5 +++-- 2 files changed, 9 insertions(+), 4 deletions(-) (limited to 'runtime/lua/vim/lsp') diff --git a/runtime/lua/vim/lsp/buf.lua b/runtime/lua/vim/lsp/buf.lua index b4e0b9cbfc..79d43fda4a 100644 --- a/runtime/lua/vim/lsp/buf.lua +++ b/runtime/lua/vim/lsp/buf.lua @@ -51,7 +51,9 @@ local function focusable_preview(method, params, fn) do local win = find_window_by_var(method, bufnr) if win then - return api.nvim_set_current_win(win) + api.nvim_set_current_win(win) + api.nvim_command("stopinsert") + return end end return request(method, params, function(_, _, result, _) @@ -234,7 +236,9 @@ end function M.signature_help() local params = util.make_position_params() focusable_preview('textDocument/signatureHelp', params, function(result) - if not (result and result.signatures and result.signatures[1]) then return end + if not (result and result.signatures and result.signatures[1]) then + return { 'No signature available' } + end -- TODO show empty popup when signatures is empty? local lines = signature_help_to_preview_contents(result) diff --git a/runtime/lua/vim/lsp/util.lua b/runtime/lua/vim/lsp/util.lua index e2a8748a20..0c53494f02 100644 --- a/runtime/lua/vim/lsp/util.lua +++ b/runtime/lua/vim/lsp/util.lua @@ -104,7 +104,7 @@ function M.apply_text_edits(text_edits, bufnr) local e = cleaned[i] local A = {e.A[1] - start_line, e.A[2]} local B = {e.B[1] - start_line, e.B[2]} - M.set_lines(lines, A, B, e.lines) + lines = M.set_lines(lines, A, B, e.lines) end if set_eol and #lines[#lines] == 0 then table.remove(lines) @@ -340,7 +340,8 @@ function M.open_floating_preview(contents, filetype, opts) end api.nvim_buf_set_lines(floating_bufnr, 0, -1, true, contents) api.nvim_buf_set_option(floating_bufnr, 'modifiable', false) - api.nvim_command("autocmd CursorMoved,BufHidden ++once lua pcall(vim.api.nvim_win_close, "..floating_winnr..", true)") + -- TODO make InsertCharPre disappearing optional? + api.nvim_command("autocmd CursorMoved,BufHidden,InsertCharPre ++once lua pcall(vim.api.nvim_win_close, "..floating_winnr..", true)") return floating_bufnr, floating_winnr end -- cgit From b35f6aa9dd686d082405132e3455cd0aff334361 Mon Sep 17 00:00:00 2001 From: Ashkan Kiani Date: Sun, 24 Nov 2019 03:01:18 -0800 Subject: Add support for textDocument/references. Add set_qflist and set_loclist. - Also add locations_to_items, which calculates byte offsets for character positions in files and avoids unnecessary operations. --- runtime/lua/vim/lsp/buf.lua | 15 ++++++ runtime/lua/vim/lsp/default_callbacks.lua | 2 +- runtime/lua/vim/lsp/util.lua | 80 +++++++++++++++++++++++-------- 3 files changed, 77 insertions(+), 20 deletions(-) (limited to 'runtime/lua/vim/lsp') diff --git a/runtime/lua/vim/lsp/buf.lua b/runtime/lua/vim/lsp/buf.lua index 79d43fda4a..51295e5570 100644 --- a/runtime/lua/vim/lsp/buf.lua +++ b/runtime/lua/vim/lsp/buf.lua @@ -1,3 +1,4 @@ +local vim = vim local validate = vim.validate local api = vim.api local vfn = vim.fn @@ -330,5 +331,19 @@ function M.rename(new_name) end) end +function M.references(context) + validate { context = { context, 't', true } } + local params = util.make_position_params() + params.context = context or { + includeDeclaration = true; + } + params[vim.type_idx] = vim.types.dictionary + request('textDocument/references', params, function(_, _, result) + if not result then return end + util.set_qflist(result) + vim.api.nvim_command("copen") + end) +end + return M -- vim:sw=2 ts=2 et diff --git a/runtime/lua/vim/lsp/default_callbacks.lua b/runtime/lua/vim/lsp/default_callbacks.lua index d58280e0f2..2a891e7d1d 100644 --- a/runtime/lua/vim/lsp/default_callbacks.lua +++ b/runtime/lua/vim/lsp/default_callbacks.lua @@ -31,7 +31,7 @@ M['textDocument/publishDiagnostics'] = function(_, _, result) util.buf_diagnostics_save_positions(bufnr, result.diagnostics) util.buf_diagnostics_underline(bufnr, result.diagnostics) util.buf_diagnostics_virtual_text(bufnr, result.diagnostics) - -- util.buf_loclist(bufnr, result.diagnostics) + -- util.set_loclist(result.diagnostics) end local function log_message(_, _, result, client_id) diff --git a/runtime/lua/vim/lsp/util.lua b/runtime/lua/vim/lsp/util.lua index 0c53494f02..2dfcdfc70c 100644 --- a/runtime/lua/vim/lsp/util.lua +++ b/runtime/lua/vim/lsp/util.lua @@ -1,4 +1,5 @@ local protocol = require 'vim.lsp.protocol' +local vim = vim local validate = vim.validate local api = vim.api @@ -573,30 +574,71 @@ do end end -function M.buf_loclist(bufnr, locations) - local targetwin - for _, winnr in ipairs(api.nvim_list_wins()) do - local winbuf = api.nvim_win_get_buf(winnr) - if winbuf == bufnr then - targetwin = winnr - break - end - end - if not targetwin then return end +local position_sort = sort_by_key(function(v) + return {v.line, v.character} +end) +-- Returns the items with the byte position calculated correctly and in sorted +-- order. +function M.locations_to_items(locations) local items = {} - local path = api.nvim_buf_get_name(bufnr) + local grouped = setmetatable({}, { + __index = function(t, k) + local v = {} + rawset(t, k, v) + return v + end; + }) for _, d in ipairs(locations) do - -- TODO: URL parsing here? local start = d.range.start - table.insert(items, { - filename = path, - lnum = start.line + 1, - col = start.character + 1, - text = d.message, - }) + local fname = assert(vim.uri_to_fname(d.uri)) + table.insert(grouped[fname], start) + end + local keys = vim.tbl_keys(grouped) + table.sort(keys) + -- TODO(ashkan) I wish we could do this lazily. + for _, fname in ipairs(keys) do + local rows = grouped[fname] + table.sort(rows, position_sort) + local i = 0 + for line in io.lines(fname) do + for _, pos in ipairs(rows) do + local row = pos.line + if i == row then + local col + if pos.character > #line then + col = #line + else + col = vim.str_byteindex(line, pos.character) + end + table.insert(items, { + filename = fname, + lnum = row + 1, + col = col + 1; + }) + end + end + i = i + 1 + end end - vim.fn.setloclist(targetwin, items, ' ', 'Language Server') + return items +end + +-- locations is Location[] +-- Only sets for the current window. +function M.set_loclist(locations) + vim.fn.setloclist(0, {}, ' ', { + title = 'Language Server'; + items = M.locations_to_items(locations); + }) +end + +-- locations is Location[] +function M.set_qflist(locations) + vim.fn.setqflist({}, ' ', { + title = 'Language Server'; + items = M.locations_to_items(locations); + }) end -- Remove empty lines from the beginning and end. -- cgit From 6e8c5779cf960893850501e4871dc9be671db298 Mon Sep 17 00:00:00 2001 From: Ashkan Kiani Date: Tue, 26 Nov 2019 05:59:40 -0800 Subject: LSP: Move default buf callbacks to vim.lsp.callbacks (#11452) - In the process, refactored focusable_preview to a util function. - Add text for locations_to_items of the current line. - Improve location callback to handle multiple return values by using set_qflist. - Remove update_tagstack and leave note for future travelers. --- runtime/lua/vim/lsp/buf.lua | 253 +++--------------------------- runtime/lua/vim/lsp/callbacks.lua | 223 ++++++++++++++++++++++++++ runtime/lua/vim/lsp/default_callbacks.lua | 69 -------- runtime/lua/vim/lsp/util.lua | 56 ++++++- 4 files changed, 297 insertions(+), 304 deletions(-) create mode 100644 runtime/lua/vim/lsp/callbacks.lua delete mode 100644 runtime/lua/vim/lsp/default_callbacks.lua (limited to 'runtime/lua/vim/lsp') diff --git a/runtime/lua/vim/lsp/buf.lua b/runtime/lua/vim/lsp/buf.lua index 51295e5570..a6a05fb095 100644 --- a/runtime/lua/vim/lsp/buf.lua +++ b/runtime/lua/vim/lsp/buf.lua @@ -3,7 +3,6 @@ local validate = vim.validate local api = vim.api local vfn = vim.fn local util = require 'vim.lsp.util' -local log = require 'vim.lsp.log' local list_extend = vim.list_extend local M = {} @@ -16,270 +15,68 @@ local function npcall(fn, ...) return ok_or_nil(pcall(fn, ...)) end -local function err_message(...) - api.nvim_err_writeln(table.concat(vim.tbl_flatten{...})) - api.nvim_command("redraw") -end - -local function find_window_by_var(name, value) - for _, win in ipairs(api.nvim_list_wins()) do - if npcall(api.nvim_win_get_var, win, name) == value then - return win - end - end -end - local function request(method, params, callback) - -- TODO(ashkan) enable this. - -- callback = vim.lsp.default_callbacks[method] or callback validate { method = {method, 's'}; - callback = {callback, 'f'}; + callback = {callback, 'f', true}; } - return vim.lsp.buf_request(0, method, params, function(err, _, result, client_id) - local _ = log.debug() and log.debug("vim.lsp.buf", method, client_id, err, result) - if err then error(tostring(err)) end - return callback(err, method, result, client_id) - end) -end - -local function focusable_preview(method, params, fn) - if npcall(api.nvim_win_get_var, 0, method) then - return api.nvim_command("wincmd p") - end - - local bufnr = api.nvim_get_current_buf() - do - local win = find_window_by_var(method, bufnr) - if win then - api.nvim_set_current_win(win) - api.nvim_command("stopinsert") - return - end - end - return request(method, params, function(_, _, result, _) - -- TODO(ashkan) could show error in preview... - local lines, filetype, opts = fn(result) - if lines then - local _, winnr = util.open_floating_preview(lines, filetype, opts) - api.nvim_win_set_var(winnr, method, bufnr) - end - end) + return vim.lsp.buf_request(0, method, params, callback) end function M.hover() local params = util.make_position_params() - focusable_preview('textDocument/hover', params, function(result) - if not (result and result.contents) then return end - - local markdown_lines = util.convert_input_to_markdown_lines(result.contents) - markdown_lines = util.trim_empty_lines(markdown_lines) - if vim.tbl_isempty(markdown_lines) then - return { 'No information available' } - end - return markdown_lines, util.try_trim_markdown_code_blocks(markdown_lines) - end) + request('textDocument/hover', params) end function M.peek_definition() local params = util.make_position_params() - request('textDocument/peekDefinition', params, function(_, _, result, _) - if not (result and result[1]) then return end - local loc = result[1] - local bufnr = vim.uri_to_bufnr(loc.uri) or error("couldn't find file "..tostring(loc.uri)) - local start = loc.range.start - local finish = loc.range["end"] - util.open_floating_peek_preview(bufnr, start, finish, { offset_x = 1 }) - local headbuf = util.open_floating_preview({"Peek:"}, nil, { - offset_y = -(finish.line - start.line); - width = finish.character - start.character + 2; - }) - -- TODO(ashkan) change highlight group? - api.nvim_buf_add_highlight(headbuf, -1, 'Keyword', 0, -1) - end) + request('textDocument/peekDefinition', params) end -local function update_tagstack() - local bufnr = api.nvim_get_current_buf() - local line = vfn.line('.') - local col = vfn.col('.') - local tagname = vfn.expand('') - local item = { bufnr = bufnr, from = { bufnr, line, col, 0 }, tagname = tagname } - local winid = vfn.win_getid() - local tagstack = vfn.gettagstack(winid) - local action - if tagstack.length == tagstack.curidx then - action = 'r' - tagstack.items[tagstack.curidx] = item - elseif tagstack.length > tagstack.curidx then - action = 'r' - if tagstack.curidx > 1 then - tagstack.items = table.insert(tagstack.items[tagstack.curidx - 1], item) - else - tagstack.items = { item } - end - else - action = 'a' - tagstack.items = { item } - end - tagstack.curidx = tagstack.curidx + 1 - vfn.settagstack(winid, tagstack, action) -end -local function handle_location(result) - -- We can sometimes get a list of locations, so set the first value as the - -- only value we want to handle - -- TODO(ashkan) was this correct^? We could use location lists. - if result[1] ~= nil then - result = result[1] - end - if result.uri == nil then - err_message('[LSP] Could not find a valid location') - return - end - local bufnr = vim.uri_to_bufnr(result.uri) - update_tagstack() - api.nvim_set_current_buf(bufnr) - local row = result.range.start.line - local col = result.range.start.character - local line = api.nvim_buf_get_lines(0, row, row+1, true)[1] - col = #line:sub(1, col) - api.nvim_win_set_cursor(0, {row + 1, col}) - return true -end -local function location_callback(_, method, result) - if result == nil or vim.tbl_isempty(result) then - local _ = log.info() and log.info(method, 'No location found') - return nil - end - return handle_location(result) -end - function M.declaration() local params = util.make_position_params() - request('textDocument/declaration', params, location_callback) + request('textDocument/declaration', params) end function M.definition() local params = util.make_position_params() - request('textDocument/definition', params, location_callback) + request('textDocument/definition', params) end function M.type_definition() local params = util.make_position_params() - request('textDocument/typeDefinition', params, location_callback) + request('textDocument/typeDefinition', params) end function M.implementation() local params = util.make_position_params() - request('textDocument/implementation', params, location_callback) -end - ---- Convert SignatureHelp response to preview contents. --- https://microsoft.github.io/language-server-protocol/specifications/specification-3-14/#textDocument_signatureHelp -local function signature_help_to_preview_contents(input) - if not input.signatures then - return - end - --The active signature. If omitted or the value lies outside the range of - --`signatures` the value defaults to zero or is ignored if `signatures.length - --=== 0`. Whenever possible implementors should make an active decision about - --the active signature and shouldn't rely on a default value. - local contents = {} - local active_signature = input.activeSignature or 0 - -- If the activeSignature is not inside the valid range, then clip it. - if active_signature >= #input.signatures then - active_signature = 0 - end - local signature = input.signatures[active_signature + 1] - if not signature then - return - end - list_extend(contents, vim.split(signature.label, '\n', true)) - if signature.documentation then - util.convert_input_to_markdown_lines(signature.documentation, contents) - end - if input.parameters then - local active_parameter = input.activeParameter or 0 - -- If the activeParameter is not inside the valid range, then clip it. - if active_parameter >= #input.parameters then - active_parameter = 0 - end - local parameter = signature.parameters and signature.parameters[active_parameter] - if parameter then - --[=[ - --Represents a parameter of a callable-signature. A parameter can - --have a label and a doc-comment. - interface ParameterInformation { - --The label of this parameter information. - -- - --Either a string or an inclusive start and exclusive end offsets within its containing - --signature label. (see SignatureInformation.label). The offsets are based on a UTF-16 - --string representation as `Position` and `Range` does. - -- - --*Note*: a label of type string should be a substring of its containing signature label. - --Its intended use case is to highlight the parameter label part in the `SignatureInformation.label`. - label: string | [number, number]; - --The human-readable doc-comment of this parameter. Will be shown - --in the UI but can be omitted. - documentation?: string | MarkupContent; - } - --]=] - -- TODO highlight parameter - if parameter.documentation then - util.convert_input_to_markdown_lines(parameter.documentation, contents) - end - end - end - return contents + request('textDocument/implementation', params) end function M.signature_help() local params = util.make_position_params() - focusable_preview('textDocument/signatureHelp', params, function(result) - if not (result and result.signatures and result.signatures[1]) then - return { 'No signature available' } - end - - -- TODO show empty popup when signatures is empty? - local lines = signature_help_to_preview_contents(result) - lines = util.trim_empty_lines(lines) - if vim.tbl_isempty(lines) then - return { 'No signature available' } - end - return lines, util.try_trim_markdown_code_blocks(lines) - end) + request('textDocument/signatureHelp', params) end -- TODO(ashkan) ? function M.completion(context) local params = util.make_position_params() params.context = context - return request('textDocument/completion', params, function(_, _, result) - if vim.tbl_isempty(result or {}) then return end - local row, col = unpack(api.nvim_win_get_cursor(0)) - local line = assert(api.nvim_buf_get_lines(0, row-1, row, false)[1]) - local line_to_cursor = line:sub(col+1) - - local matches = util.text_document_completion_list_to_complete_items(result, line_to_cursor) - vim.fn.complete(col, matches) - end) + return request('textDocument/completion', params) end function M.formatting(options) validate { options = {options, 't', true} } options = vim.tbl_extend('keep', options or {}, { - tabSize = api.nvim_buf_get_option(0, 'tabstop'); - insertSpaces = api.nvim_buf_get_option(0, 'expandtab'); + tabSize = vim.bo.tabstop; + insertSpaces = vim.bo.expandtab; }) local params = { textDocument = { uri = vim.uri_from_bufnr(0) }; options = options; } - return request('textDocument/formatting', params, function(_, _, result) - if not result then return end - util.apply_text_edits(result) - end) + return request('textDocument/formatting', params) end function M.range_formatting(options, start_pos, end_pos) @@ -289,8 +86,8 @@ function M.range_formatting(options, start_pos, end_pos) end_pos = {end_pos, 't', true}; } options = vim.tbl_extend('keep', options or {}, { - tabSize = api.nvim_buf_get_option(0, 'tabstop'); - insertSpaces = api.nvim_buf_get_option(0, 'expandtab'); + tabSize = vim.bo.tabstop; + insertSpaces = vim.bo.expandtab; }) local A = list_extend({}, start_pos or api.nvim_buf_get_mark(0, '<')) local B = list_extend({}, end_pos or api.nvim_buf_get_mark(0, '>')) @@ -299,10 +96,10 @@ function M.range_formatting(options, start_pos, end_pos) B[1] = B[1] - 1 -- account for encoding. if A[2] > 0 then - A = {A[1], util.character_offset(0, unpack(A))} + A = {A[1], util.character_offset(0, A[1], A[2])} end if B[2] > 0 then - B = {B[1], util.character_offset(0, unpack(B))} + B = {B[1], util.character_offset(0, B[1], B[2])} end local params = { textDocument = { uri = vim.uri_from_bufnr(0) }; @@ -312,10 +109,7 @@ function M.range_formatting(options, start_pos, end_pos) }; options = options; } - return request('textDocument/rangeFormatting', params, function(_, _, result) - if not result then return end - util.apply_text_edits(result) - end) + return request('textDocument/rangeFormatting', params) end function M.rename(new_name) @@ -325,10 +119,7 @@ function M.rename(new_name) new_name = new_name or npcall(vfn.input, "New Name: ") if not (new_name and #new_name > 0) then return end params.newName = new_name - request('textDocument/rename', params, function(_, _, result) - if not result then return end - util.apply_workspace_edit(result) - end) + request('textDocument/rename', params) end function M.references(context) @@ -338,11 +129,7 @@ function M.references(context) includeDeclaration = true; } params[vim.type_idx] = vim.types.dictionary - request('textDocument/references', params, function(_, _, result) - if not result then return end - util.set_qflist(result) - vim.api.nvim_command("copen") - end) + request('textDocument/references', params) end return M diff --git a/runtime/lua/vim/lsp/callbacks.lua b/runtime/lua/vim/lsp/callbacks.lua new file mode 100644 index 0000000000..4fc3f74519 --- /dev/null +++ b/runtime/lua/vim/lsp/callbacks.lua @@ -0,0 +1,223 @@ +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 = {} + +local function err_message(...) + api.nvim_err_writeln(table.concat(vim.tbl_flatten{...})) + api.nvim_command("redraw") +end + +M['workspace/applyEdit'] = function(_, _, workspace_edit) + if not workspace_edit then return end + -- TODO(ashkan) Do something more with label? + if workspace_edit.label then + print("Workspace edit", workspace_edit.label) + end + util.apply_workspace_edit(workspace_edit.edit) +end + +M['textDocument/publishDiagnostics'] = function(_, _, result) + if not result then return end + local uri = result.uri + local bufnr = vim.uri_to_bufnr(uri) + if not bufnr then + err_message("LSP.publishDiagnostics: Couldn't find buffer for ", uri) + return + end + util.buf_clear_diagnostics(bufnr) + util.buf_diagnostics_save_positions(bufnr, result.diagnostics) + util.buf_diagnostics_underline(bufnr, result.diagnostics) + util.buf_diagnostics_virtual_text(bufnr, result.diagnostics) + -- util.set_loclist(result.diagnostics) +end + +M['textDocument/references'] = function(_, _, result) + if not result then return end + util.set_qflist(result) + api.nvim_command("copen") + api.nvim_command("wincmd p") +end + +M['textDocument/rename'] = function(_, _, result) + if not result then return end + util.apply_workspace_edit(result) +end + +M['textDocument/rangeFormatting'] = function(_, _, result) + if not result then return end + util.apply_text_edits(result) +end + +M['textDocument/formatting'] = function(_, _, result) + if not result then return end + util.apply_text_edits(result) +end + +M['textDocument/completion'] = function(_, _, result) + if vim.tbl_isempty(result or {}) then return end + local row, col = unpack(api.nvim_win_get_cursor(0)) + local line = assert(api.nvim_buf_get_lines(0, row-1, row, false)[1]) + local line_to_cursor = line:sub(col+1) + + local matches = util.text_document_completion_list_to_complete_items(result, line_to_cursor) + vim.fn.complete(col, matches) +end + +M['textDocument/hover'] = function(_, method, result) + util.focusable_preview(method, function() + if not (result and result.contents) then + return { 'No information available' } + end + local markdown_lines = util.convert_input_to_markdown_lines(result.contents) + markdown_lines = util.trim_empty_lines(markdown_lines) + if vim.tbl_isempty(markdown_lines) then + return { 'No information available' } + end + return markdown_lines, util.try_trim_markdown_code_blocks(markdown_lines) + end) +end + +local function location_callback(_, method, result) + if result == nil or vim.tbl_isempty(result) then + local _ = log.info() and log.info(method, 'No location found') + return nil + end + util.jump_to_location(result[1]) + if #result > 1 then + util.set_qflist(result) + api.nvim_command("copen") + api.nvim_command("wincmd p") + end +end + +M['textDocument/declaration'] = location_callback +M['textDocument/definition'] = location_callback +M['textDocument/typeDefinition'] = location_callback +M['textDocument/implementation'] = location_callback + +--- Convert SignatureHelp response to preview contents. +-- https://microsoft.github.io/language-server-protocol/specifications/specification-3-14/#textDocument_signatureHelp +local function signature_help_to_preview_contents(input) + if not input.signatures then + return + end + --The active signature. If omitted or the value lies outside the range of + --`signatures` the value defaults to zero or is ignored if `signatures.length + --=== 0`. Whenever possible implementors should make an active decision about + --the active signature and shouldn't rely on a default value. + local contents = {} + local active_signature = input.activeSignature or 0 + -- If the activeSignature is not inside the valid range, then clip it. + if active_signature >= #input.signatures then + active_signature = 0 + end + local signature = input.signatures[active_signature + 1] + if not signature then + return + end + vim.list_extend(contents, vim.split(signature.label, '\n', true)) + if signature.documentation then + util.convert_input_to_markdown_lines(signature.documentation, contents) + end + if input.parameters then + local active_parameter = input.activeParameter or 0 + -- If the activeParameter is not inside the valid range, then clip it. + if active_parameter >= #input.parameters then + active_parameter = 0 + end + local parameter = signature.parameters and signature.parameters[active_parameter] + if parameter then + --[=[ + --Represents a parameter of a callable-signature. A parameter can + --have a label and a doc-comment. + interface ParameterInformation { + --The label of this parameter information. + -- + --Either a string or an inclusive start and exclusive end offsets within its containing + --signature label. (see SignatureInformation.label). The offsets are based on a UTF-16 + --string representation as `Position` and `Range` does. + -- + --*Note*: a label of type string should be a substring of its containing signature label. + --Its intended use case is to highlight the parameter label part in the `SignatureInformation.label`. + label: string | [number, number]; + --The human-readable doc-comment of this parameter. Will be shown + --in the UI but can be omitted. + documentation?: string | MarkupContent; + } + --]=] + -- TODO highlight parameter + if parameter.documentation then + util.convert_input_to_markdown_lines(parameter.documentation, contents) + end + end + end + return contents +end + +M['textDocument/signatureHelp'] = function(_, method, result) + util.focusable_preview(method, function() + if not (result and result.signatures and result.signatures[1]) then + return { 'No signature available' } + end + -- TODO show popup when signatures is empty? + local lines = signature_help_to_preview_contents(result) + lines = util.trim_empty_lines(lines) + if vim.tbl_isempty(lines) then + return { 'No signature available' } + end + return lines, util.try_trim_markdown_code_blocks(lines) + end) +end + +M['textDocument/peekDefinition'] = function(_, _, result, _) + if not (result and result[1]) then return end + local loc = result[1] + local bufnr = vim.uri_to_bufnr(loc.uri) or error("not found: "..tostring(loc.uri)) + local start = loc.range.start + local finish = loc.range["end"] + util.open_floating_peek_preview(bufnr, start, finish, { offset_x = 1 }) + local headbuf = util.open_floating_preview({"Peek:"}, nil, { + offset_y = -(finish.line - start.line); + width = finish.character - start.character + 2; + }) + -- TODO(ashkan) change highlight group? + api.nvim_buf_add_highlight(headbuf, -1, 'Keyword', 0, -1) +end + +local function log_message(_, _, result, client_id) + local message_type = result.type + local message = result.message + local client = vim.lsp.get_client_by_id(client_id) + local client_name = client and client.name or string.format("id=%d", client_id) + if not client then + err_message("LSP[", client_name, "] client has shut down after sending the message") + end + if message_type == protocol.MessageType.Error then + err_message("LSP[", client_name, "] ", message) + else + local message_type_name = protocol.MessageType[message_type] + api.nvim_out_write(string.format("LSP[%s][%s] %s\n", client_name, message_type_name, message)) + end + return result +end + +M['window/showMessage'] = log_message +M['window/logMessage'] = log_message + +-- Add boilerplate error validation and logging for all of these. +for k, fn in pairs(M) do + M[k] = function(err, method, params, client_id) + local _ = log.debug() and log.debug('default_callback', method, { params = params, client_id = client_id, err = err }) + if err then + error(tostring(err)) + end + return fn(err, method, params, client_id) + end +end + +return M +-- vim:sw=2 ts=2 et diff --git a/runtime/lua/vim/lsp/default_callbacks.lua b/runtime/lua/vim/lsp/default_callbacks.lua deleted file mode 100644 index 2a891e7d1d..0000000000 --- a/runtime/lua/vim/lsp/default_callbacks.lua +++ /dev/null @@ -1,69 +0,0 @@ -local log = require 'vim.lsp.log' -local protocol = require 'vim.lsp.protocol' -local util = require 'vim.lsp.util' -local api = vim.api - -local M = {} - -local function err_message(...) - api.nvim_err_writeln(table.concat(vim.tbl_flatten{...})) - api.nvim_command("redraw") -end - -M['workspace/applyEdit'] = function(_, _, workspace_edit) - if not workspace_edit then return end - -- TODO(ashkan) Do something more with label? - if workspace_edit.label then - print("Workspace edit", workspace_edit.label) - end - util.apply_workspace_edit(workspace_edit.edit) -end - -M['textDocument/publishDiagnostics'] = function(_, _, result) - if not result then return end - local uri = result.uri - local bufnr = vim.uri_to_bufnr(uri) - if not bufnr then - err_message("LSP.publishDiagnostics: Couldn't find buffer for ", uri) - return - end - util.buf_clear_diagnostics(bufnr) - util.buf_diagnostics_save_positions(bufnr, result.diagnostics) - util.buf_diagnostics_underline(bufnr, result.diagnostics) - util.buf_diagnostics_virtual_text(bufnr, result.diagnostics) - -- util.set_loclist(result.diagnostics) -end - -local function log_message(_, _, result, client_id) - local message_type = result.type - local message = result.message - local client = vim.lsp.get_client_by_id(client_id) - local client_name = client and client.name or string.format("id=%d", client_id) - if not client then - err_message("LSP[", client_name, "] client has shut down after sending the message") - end - if message_type == protocol.MessageType.Error then - err_message("LSP[", client_name, "] ", message) - else - local message_type_name = protocol.MessageType[message_type] - api.nvim_out_write(string.format("LSP[%s][%s] %s\n", client_name, message_type_name, message)) - end - return result -end - -M['window/showMessage'] = log_message -M['window/logMessage'] = log_message - --- Add boilerplate error validation and logging for all of these. -for k, fn in pairs(M) do - M[k] = function(err, method, params, client_id) - local _ = log.debug() and log.debug('default_callback', method, { params = params, client_id = client_id, err = err }) - if err then - error(tostring(err)) - end - return fn(err, method, params, client_id) - end -end - -return M --- vim:sw=2 ts=2 et diff --git a/runtime/lua/vim/lsp/util.lua b/runtime/lua/vim/lsp/util.lua index 2dfcdfc70c..6e0d3fd4ee 100644 --- a/runtime/lua/vim/lsp/util.lua +++ b/runtime/lua/vim/lsp/util.lua @@ -2,6 +2,7 @@ local protocol = require 'vim.lsp.protocol' local vim = vim local validate = vim.validate local api = vim.api +local list_extend = vim.list_extend local M = {} @@ -10,7 +11,13 @@ local function split_lines(value) return split(value, '\n', true) end -local list_extend = vim.list_extend +local function ok_or_nil(status, ...) + if not status then return end + return ... +end +local function npcall(fn, ...) + return ok_or_nil(pcall(fn, ...)) +end --- Find the longest shared prefix between prefix and word. -- e.g. remove_prefix("123tes", "testing") == "ting" @@ -303,6 +310,50 @@ function M.make_floating_popup_options(width, height, opts) } end +function M.jump_to_location(location) + if location.uri == nil then return end + local bufnr = vim.uri_to_bufnr(location.uri) + -- TODO(ashkan) use tagfunc here to update tagstack. + api.nvim_set_current_buf(bufnr) + local row = location.range.start.line + local col = location.range.start.character + local line = api.nvim_buf_get_lines(0, row, row+1, true)[1] + col = vim.str_byteindex(line, col) + api.nvim_win_set_cursor(0, {row + 1, col}) + return true +end + +local function find_window_by_var(name, value) + for _, win in ipairs(api.nvim_list_wins()) do + if npcall(api.nvim_win_get_var, win, name) == value then + return win + end + end +end + +-- Check if a window with `unique_name` tagged is associated with the current +-- buffer. If not, make a new preview. +-- +-- fn()'s return values will be passed directly to open_floating_preview in the +-- case that a new floating window should be created. +function M.focusable_preview(unique_name, fn) + if npcall(api.nvim_win_get_var, 0, unique_name) then + return api.nvim_command("wincmd p") + end + local bufnr = api.nvim_get_current_buf() + do + local win = find_window_by_var(unique_name, bufnr) + if win then + api.nvim_set_current_win(win) + api.nvim_command("stopinsert") + return + end + end + local pbufnr, pwinnr = M.open_floating_preview(fn()) + api.nvim_win_set_var(pwinnr, unique_name, bufnr) + return pbufnr, pwinnr +end + function M.open_floating_preview(contents, filetype, opts) validate { contents = { contents, 't' }; @@ -609,12 +660,13 @@ function M.locations_to_items(locations) if pos.character > #line then col = #line else - col = vim.str_byteindex(line, pos.character) + col = vim.str_byteindex(line, pos.character) end table.insert(items, { filename = fname, lnum = row + 1, col = col + 1; + text = line; }) end end -- cgit From 950690e48a6ff4788b3ad6c23caa99d253facd57 Mon Sep 17 00:00:00 2001 From: Björn Linse Date: Wed, 27 Nov 2019 19:45:03 +0100 Subject: lsp: allow the user to config LspDiagnosticError etc by standard means --- runtime/lua/vim/lsp/util.lua | 71 ++++++++++---------------------------------- 1 file changed, 15 insertions(+), 56 deletions(-) (limited to 'runtime/lua/vim/lsp') diff --git a/runtime/lua/vim/lsp/util.lua b/runtime/lua/vim/lsp/util.lua index 6e0d3fd4ee..b9990ed082 100644 --- a/runtime/lua/vim/lsp/util.lua +++ b/runtime/lua/vim/lsp/util.lua @@ -440,6 +440,11 @@ do local diagnostic_ns = api.nvim_create_namespace("vim_lsp_diagnostics") + local underline_highlight_name = "LspDiagnosticsUnderline" + api.nvim_command(string.format("highlight default %s gui=underline cterm=underline", underline_highlight_name)) + + local severity_highlights = {} + local default_severity_highlight = { [protocol.DiagnosticSeverity.Error] = { guifg = "Red" }; [protocol.DiagnosticSeverity.Warning] = { guifg = "Orange" }; @@ -447,60 +452,17 @@ do [protocol.DiagnosticSeverity.Hint] = { guifg = "LightGrey" }; } - local underline_highlight_name = "LspDiagnosticsUnderline" - api.nvim_command(string.format("highlight %s gui=underline cterm=underline", underline_highlight_name)) - - local function find_color_rgb(color) - local rgb_hex = api.nvim_get_color_by_name(color) - validate { color = {color, function() return rgb_hex ~= -1 end, "valid color name"} } - return rgb_hex - end - - --- Determine whether to use black or white text - -- Ref: https://stackoverflow.com/a/1855903/837964 - -- https://stackoverflow.com/questions/596216/formula-to-determine-brightness-of-rgb-color - local function color_is_bright(r, g, b) - -- Counting the perceptive luminance - human eye favors green color - local luminance = (0.299*r + 0.587*g + 0.114*b)/255 - if luminance > 0.5 then - return true -- Bright colors, black font - else - return false -- Dark colors, white font - end - end - - local severity_highlights = {} - - function M.set_severity_highlights(highlights) - validate {highlights = {highlights, 't'}} - for severity, default_color in pairs(default_severity_highlight) do - local severity_name = protocol.DiagnosticSeverity[severity] - local highlight_name = "LspDiagnostics"..severity_name - local hi_info = highlights[severity] or default_color - -- Try to fill in the foreground color with a sane default. - if not hi_info.guifg and hi_info.guibg then - -- TODO(ashkan) move this out when bitop is guaranteed to be included. - local bit = require 'bit' - local band, rshift = bit.band, bit.rshift - local rgb = find_color_rgb(hi_info.guibg) - local is_bright = color_is_bright(rshift(rgb, 16), band(rshift(rgb, 8), 0xFF), band(rgb, 0xFF)) - hi_info.guifg = is_bright and "Black" or "White" - end - if not hi_info.ctermfg and hi_info.ctermbg then - -- TODO(ashkan) move this out when bitop is guaranteed to be included. - local bit = require 'bit' - local band, rshift = bit.band, bit.rshift - local rgb = find_color_rgb(hi_info.ctermbg) - local is_bright = color_is_bright(rshift(rgb, 16), band(rshift(rgb, 8), 0xFF), band(rgb, 0xFF)) - hi_info.ctermfg = is_bright and "Black" or "White" - end - local cmd_parts = {"highlight", highlight_name} - for k, v in pairs(hi_info) do - table.insert(cmd_parts, k.."="..v) - end - api.nvim_command(table.concat(cmd_parts, ' ')) - severity_highlights[severity] = highlight_name + -- Initialize default severity highlights + for severity, hi_info in pairs(default_severity_highlight) do + local severity_name = protocol.DiagnosticSeverity[severity] + local highlight_name = "LspDiagnostics"..severity_name + -- Try to fill in the foreground color with a sane default. + local cmd_parts = {"highlight", "default", highlight_name} + for k, v in pairs(hi_info) do + table.insert(cmd_parts, k.."="..v) end + api.nvim_command(table.concat(cmd_parts, ' ')) + severity_highlights[severity] = highlight_name end function M.buf_clear_diagnostics(bufnr) @@ -509,9 +471,6 @@ do api.nvim_buf_clear_namespace(bufnr, diagnostic_ns, 0, -1) end - -- Initialize with the defaults. - M.set_severity_highlights(default_severity_highlight) - function M.get_severity_highlight_name(severity) return severity_highlights[severity] end -- cgit From 30ed245d008045a505a87ab21d22ada45dd1f4b5 Mon Sep 17 00:00:00 2001 From: Jakub Łuczyński Date: Sat, 7 Dec 2019 12:34:22 +0100 Subject: LSP: Add jump when calling gotodef (#11521) --- runtime/lua/vim/lsp/util.lua | 2 ++ 1 file changed, 2 insertions(+) (limited to 'runtime/lua/vim/lsp') diff --git a/runtime/lua/vim/lsp/util.lua b/runtime/lua/vim/lsp/util.lua index b9990ed082..3798b30a46 100644 --- a/runtime/lua/vim/lsp/util.lua +++ b/runtime/lua/vim/lsp/util.lua @@ -313,6 +313,8 @@ end function M.jump_to_location(location) if location.uri == nil then return end local bufnr = vim.uri_to_bufnr(location.uri) + -- Save position in jumplist + vim.cmd "normal! m'" -- TODO(ashkan) use tagfunc here to update tagstack. api.nvim_set_current_buf(bufnr) local row = location.range.start.line -- cgit