diff options
Diffstat (limited to 'runtime/lua/vim')
-rw-r--r-- | runtime/lua/vim/highlight.lua | 77 | ||||
-rw-r--r-- | runtime/lua/vim/inspect.lua | 7 | ||||
-rw-r--r-- | runtime/lua/vim/lsp.lua | 1013 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/buf.lua | 251 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/callbacks.lua | 293 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/log.lua | 94 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/protocol.lua | 988 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/rpc.lua | 472 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/util.lua | 1360 | ||||
-rw-r--r-- | runtime/lua/vim/shared.lua | 457 | ||||
-rw-r--r-- | runtime/lua/vim/treesitter.lua | 260 | ||||
-rw-r--r-- | runtime/lua/vim/tshighlighter.lua | 116 | ||||
-rw-r--r-- | runtime/lua/vim/uri.lua | 112 |
13 files changed, 5438 insertions, 62 deletions
diff --git a/runtime/lua/vim/highlight.lua b/runtime/lua/vim/highlight.lua new file mode 100644 index 0000000000..ce0a3de520 --- /dev/null +++ b/runtime/lua/vim/highlight.lua @@ -0,0 +1,77 @@ +local api = vim.api + +local highlight = {} + +--- Highlight range between two positions +--- +--@param bufnr number of buffer to apply highlighting to +--@param ns namespace to add highlight to +--@param higroup highlight group to use for highlighting +--@param rtype type of range (:help setreg, default charwise) +--@param inclusive boolean indicating whether the range is end-inclusive (default false) +function highlight.range(bufnr, ns, higroup, start, finish, rtype, inclusive) + rtype = rtype or 'v' + inclusive = inclusive or false + + -- sanity check + if start[2] < 0 or finish[2] < start[2] then return end + + local region = vim.region(bufnr, start, finish, rtype, inclusive) + for linenr, cols in pairs(region) do + api.nvim_buf_add_highlight(bufnr, ns, higroup, linenr, cols[1], cols[2]) + end + +end + +local yank_ns = api.nvim_create_namespace('hlyank') +--- Highlight the yanked region +--- +--- use from init.vim via +--- au TextYankPost * lua vim.highlight.on_yank() +--- customize highlight group and timeout via +--- au TextYankPost * lua vim.highlight.on_yank {higroup="IncSearch", timeout=150} +--- customize conditions (here: do not highlight a visual selection) via +--- au TextYankPost * lua vim.highlight.on_yank {on_visual=false} +--- +-- @param opts dictionary with options controlling the highlight: +-- - higroup highlight group for yanked region (default "IncSearch") +-- - timeout time in ms before highlight is cleared (default 150) +-- - on_macro highlight when executing macro (default false) +-- - on_visual highlight when yanking visual selection (default true) +-- - event event structure (default vim.v.event) +function highlight.on_yank(opts) + vim.validate { + opts = { opts, + function(t) if t == nil then return true else return type(t) == 'table' end end, + 'a table or nil to configure options (see `:h highlight.on_yank`)', + }} + opts = opts or {} + local event = opts.event or vim.v.event + local on_macro = opts.on_macro or false + local on_visual = (opts.on_visual ~= false) + + if (not on_macro) and vim.fn.reg_executing() ~= '' then return end + if event.operator ~= 'y' or event.regtype == '' then return end + if (not on_visual) and event.visual then return end + + local higroup = opts.higroup or "IncSearch" + local timeout = opts.timeout or 150 + + local bufnr = api.nvim_get_current_buf() + api.nvim_buf_clear_namespace(bufnr, yank_ns, 0, -1) + + local pos1 = vim.fn.getpos("'[") + local pos2 = vim.fn.getpos("']") + + pos1 = {pos1[2] - 1, pos1[3] - 1 + pos1[4]} + pos2 = {pos2[2] - 1, pos2[3] - 1 + pos2[4]} + + highlight.range(bufnr, yank_ns, higroup, pos1, pos2, event.regtype, event.inclusive) + + vim.defer_fn( + function() api.nvim_buf_clear_namespace(bufnr, yank_ns, 0, -1) end, + timeout + ) +end + +return highlight diff --git a/runtime/lua/vim/inspect.lua b/runtime/lua/vim/inspect.lua index 7cb40ca64d..0448ea487f 100644 --- a/runtime/lua/vim/inspect.lua +++ b/runtime/lua/vim/inspect.lua @@ -244,6 +244,11 @@ function Inspector:putTable(t) local nonSequentialKeys, nonSequentialKeysLength, sequenceLength = getNonSequentialKeys(t) local mt = getmetatable(t) + if (vim and sequenceLength == 0 and nonSequentialKeysLength == 0 + and mt == vim._empty_dict_mt) then + self:puts(tostring(t)) + return + end self:puts('{') self:down(function() @@ -289,7 +294,7 @@ function Inspector:putValue(v) if tv == 'string' then self:puts(smartQuote(escape(v))) elseif tv == 'number' or tv == 'boolean' or tv == 'nil' or - tv == 'cdata' or tv == 'ctype' then + tv == 'cdata' or tv == 'ctype' or (vim and v == vim.NIL) then self:puts(tostring(v)) elseif tv == 'table' then self:putTable(v) diff --git a/runtime/lua/vim/lsp.lua b/runtime/lua/vim/lsp.lua new file mode 100644 index 0000000000..6fe1d15b7e --- /dev/null +++ b/runtime/lua/vim/lsp.lua @@ -0,0 +1,1013 @@ +local default_callbacks = require 'vim.lsp.callbacks' +local log = require 'vim.lsp.log' +local lsp_rpc = require 'vim.lsp.rpc' +local protocol = require 'vim.lsp.protocol' +local util = require 'vim.lsp.util' + +local vim = vim +local nvim_err_writeln, nvim_buf_get_lines, nvim_command, nvim_buf_get_option + = vim.api.nvim_err_writeln, vim.api.nvim_buf_get_lines, vim.api.nvim_command, vim.api.nvim_buf_get_option +local uv = vim.loop +local tbl_isempty, tbl_extend = vim.tbl_isempty, vim.tbl_extend +local validate = vim.validate + +local lsp = { + protocol = protocol; + callbacks = default_callbacks; + buf = require'vim.lsp.buf'; + util = util; + -- Allow raw RPC access. + rpc = lsp_rpc; + -- Export these directly from rpc. + rpc_response_error = lsp_rpc.rpc_response_error; + -- You probably won't need this directly, since __tostring is set for errors + -- by the RPC. + -- format_rpc_error = lsp_rpc.format_rpc_error; +} + +-- TODO improve handling of scratch buffers with LSP attached. + +local function err_message(...) + nvim_err_writeln(table.concat(vim.tbl_flatten{...})) + nvim_command("redraw") +end + +local function resolve_bufnr(bufnr) + validate { bufnr = { bufnr, 'n', true } } + if bufnr == nil or bufnr == 0 then + return vim.api.nvim_get_current_buf() + end + return bufnr +end + +local function is_dir(filename) + validate{filename={filename,'s'}} + local stat = uv.fs_stat(filename) + return stat and stat.type == 'directory' or false +end + +local wait_result_reason = { [-1] = "timeout"; [-2] = "interrupted"; [-3] = "error" } + +local valid_encodings = { + ["utf-8"] = 'utf-8'; ["utf-16"] = 'utf-16'; ["utf-32"] = 'utf-32'; + ["utf8"] = 'utf-8'; ["utf16"] = 'utf-16'; ["utf32"] = 'utf-32'; + UTF8 = 'utf-8'; UTF16 = 'utf-16'; UTF32 = 'utf-32'; +} + +local client_index = 0 +local function next_client_id() + client_index = client_index + 1 + return client_index +end +-- Tracks all clients created via lsp.start_client +local active_clients = {} +local all_buffer_active_clients = {} +local uninitialized_clients = {} + +local function for_each_buffer_client(bufnr, callback) + validate { + callback = { callback, 'f' }; + } + bufnr = resolve_bufnr(bufnr) + local client_ids = all_buffer_active_clients[bufnr] + if not client_ids or tbl_isempty(client_ids) then + return + end + for client_id in pairs(client_ids) do + local client = active_clients[client_id] + if client then + callback(client, client_id, bufnr) + end + end +end + +-- Error codes to be used with `on_error` from |vim.lsp.start_client|. +-- Can be used to look up the string from a the number or the number +-- from the string. +lsp.client_errors = tbl_extend("error", lsp_rpc.client_errors, vim.tbl_add_reverse_lookup { + ON_INIT_CALLBACK_ERROR = table.maxn(lsp_rpc.client_errors) + 1; +}) + +local function validate_encoding(encoding) + validate { + encoding = { encoding, 's' }; + } + return valid_encodings[encoding:lower()] + or error(string.format("Invalid offset encoding %q. Must be one of: 'utf-8', 'utf-16', 'utf-32'", encoding)) +end + +function lsp._cmd_parts(input) + vim.validate{cmd={ + input, + function() return vim.tbl_islist(input) end, + "list"}} + + local cmd = input[1] + local cmd_args = {} + -- Don't mutate our input. + for i, v in ipairs(input) do + vim.validate{["cmd argument"]={v, "s"}} + if i > 1 then + table.insert(cmd_args, v) + end + end + return cmd, cmd_args +end + +local function optional_validator(fn) + return function(v) + return v == nil or fn(v) + end +end + +local function validate_client_config(config) + validate { + config = { config, 't' }; + } + validate { + root_dir = { config.root_dir, is_dir, "directory" }; + callbacks = { config.callbacks, "t", true }; + capabilities = { config.capabilities, "t", true }; + cmd_cwd = { config.cmd_cwd, optional_validator(is_dir), "directory" }; + cmd_env = { config.cmd_env, "t", true }; + name = { config.name, 's', true }; + on_error = { config.on_error, "f", true }; + on_exit = { config.on_exit, "f", true }; + on_init = { config.on_init, "f", true }; + before_init = { config.before_init, "f", true }; + offset_encoding = { config.offset_encoding, "s", true }; + } + local cmd, cmd_args = lsp._cmd_parts(config.cmd) + local offset_encoding = valid_encodings.UTF16 + if config.offset_encoding then + offset_encoding = validate_encoding(config.offset_encoding) + end + return { + cmd = cmd; cmd_args = cmd_args; + offset_encoding = offset_encoding; + } +end + +local function buf_get_full_text(bufnr) + local text = table.concat(nvim_buf_get_lines(bufnr, 0, -1, true), '\n') + if nvim_buf_get_option(bufnr, 'eol') then + text = text .. '\n' + end + return text +end + +local function text_document_did_open_handler(bufnr, client) + if not client.resolved_capabilities.text_document_open_close then + return + end + if not vim.api.nvim_buf_is_loaded(bufnr) then + return + end + local params = { + textDocument = { + version = 0; + uri = vim.uri_from_bufnr(bufnr); + -- TODO make sure our filetypes are compatible with languageId names. + languageId = nvim_buf_get_option(bufnr, 'filetype'); + text = buf_get_full_text(bufnr); + } + } + client.notify('textDocument/didOpen', params) + util.buf_versions[bufnr] = params.textDocument.version +end + +--- LSP client object. +--- +--- - Methods: +--- +--- - request(method, params, [callback]) +--- Send a request to the server. If callback is not specified, it will use +--- {client.callbacks} to try to find a callback. If one is not found there, +--- then an error will occur. +--- This is a thin wrapper around {client.rpc.request} with some additional +--- checking. +--- Returns a boolean to indicate if the notification was successful. If it +--- is false, then it will always be false (the client has shutdown). +--- If it was successful, then it will return the request id as the second +--- result. You can use this with `notify("$/cancel", { id = request_id })` +--- to cancel the request. This helper is made automatically with +--- |vim.lsp.buf_request()| +--- Returns: status, [client_id] +--- +--- - notify(method, params) +--- This is just {client.rpc.notify}() +--- Returns a boolean to indicate if the notification was successful. If it +--- is false, then it will always be false (the client has shutdown). +--- Returns: status +--- +--- - cancel_request(id) +--- This is just {client.rpc.notify}("$/cancelRequest", { id = id }) +--- Returns the same as `notify()`. +--- +--- - stop([force]) +--- Stop a client, optionally with force. +--- By default, it will just ask the server to shutdown without force. +--- If you request to stop a client which has previously been requested to +--- shutdown, it will automatically escalate and force shutdown. +--- +--- - is_stopped() +--- Returns true if the client is fully stopped. +--- +--- - Members +--- - id (number): The id allocated to the client. +--- +--- - name (string): If a name is specified on creation, that will be +--- used. Otherwise it is just the client id. This is used for +--- logs and messages. +--- +--- - offset_encoding (string): The encoding used for communicating +--- with the server. You can modify this in the `on_init` method +--- before text is sent to the server. +--- +--- - callbacks (table): The callbacks used by the client as +--- described in |lsp-callbacks|. +--- +--- - config (table): copy of the table that was passed by the user +--- to |vim.lsp.start_client()|. +--- +--- - server_capabilities (table): Response from the server sent on +--- `initialize` describing the server's capabilities. +--- +--- - resolved_capabilities (table): Normalized table of +--- capabilities that we have detected based on the initialize +--- response from the server in `server_capabilities`. +function lsp.client() + error() +end + +--- Starts and initializes a client with the given configuration. +--- +--- Parameters `cmd` and `root_dir` are required. +--- +--@param root_dir: (required, string) Directory where the LSP server will base +--- its rootUri on initialization. +--- +--@param cmd: (required, string or list treated like |jobstart()|) Base command +--- that initiates the LSP client. +--- +--@param cmd_cwd: (string, default=|getcwd()|) Directory to launch +--- the `cmd` process. Not related to `root_dir`. +--- +--@param cmd_env: (table) Environment flags to pass to the LSP on +--- spawn. Can be specified using keys like a map or as a list with `k=v` +--- pairs or both. Non-string values are coerced to string. +--- Example: +--- <pre> +--- { "PRODUCTION=true"; "TEST=123"; PORT = 8080; HOST = "0.0.0.0"; } +--- </pre> +--- +--@param capabilities Map overriding the default capabilities defined by +--- |vim.lsp.protocol.make_client_capabilities()|, passed to the language +--- server on initialization. Hint: use make_client_capabilities() and modify +--- its result. +--- - Note: To send an empty dictionary use +--- `{[vim.type_idx]=vim.types.dictionary}`, else it will be encoded as an +--- array. +--- +--@param callbacks Map of language server method names to +--- `function(err, method, params, client_id)` handler. Invoked for: +--- - Notifications from the server, where `err` will always be `nil`. +--- - Requests initiated by the server. For these you can respond by returning +--- two values: `result, err` where err must be shaped like a RPC error, +--- i.e. `{ code, message, data? }`. Use |vim.lsp.rpc_response_error()| to +--- help with this. +--- - Default callback for client requests not explicitly specifying +--- a callback. +--- +--@param init_options Values to pass in the initialization request +--- as `initializationOptions`. See `initialize` in the LSP spec. +--- +--@param name (string, default=client-id) Name in log messages. +--- +--@param offset_encoding (default="utf-16") One of "utf-8", "utf-16", +--- or "utf-32" which is the encoding that the LSP server expects. Client does +--- not verify this is correct. +--- +--@param on_error Callback with parameters (code, ...), invoked +--- when the client operation throws an error. `code` is a number describing +--- the error. Other arguments may be passed depending on the error kind. See +--- |vim.lsp.client_errors| for possible errors. +--- Use `vim.lsp.client_errors[code]` to get human-friendly name. +--- +--@param before_init Callback with parameters (initialize_params, config) +--- invoked before the LSP "initialize" phase, where `params` contains the +--- parameters being sent to the server and `config` is the config that was +--- passed to `start_client()`. You can use this to modify parameters before +--- they are sent. +--- +--@param on_init Callback (client, initialize_result) invoked after LSP +--- "initialize", where `result` is a table of `capabilities` and anything else +--- the server may send. For example, clangd sends +--- `initialize_result.offsetEncoding` if `capabilities.offsetEncoding` was +--- sent to it. You can only modify the `client.offset_encoding` here before +--- any notifications are sent. +--- +--@param on_exit Callback (code, signal, client_id) invoked on client +--- exit. +--- - code: exit code of the process +--- - signal: number describing the signal used to terminate (if any) +--- - client_id: client handle +--- +--@param on_attach Callback (client, bufnr) invoked when client +--- attaches to a buffer. +--- +--@param trace: "off" | "messages" | "verbose" | nil passed directly to the language +--- server in the initialize request. Invalid/empty values will default to "off" +--- +--@returns Client id. |vim.lsp.get_client_by_id()| Note: client is only +--- available after it has been initialized, which may happen after a small +--- delay (or never if there is an error). Use `on_init` to do any actions once +--- the client has been initialized. +function lsp.start_client(config) + local cleaned_config = validate_client_config(config) + local cmd, cmd_args, offset_encoding = cleaned_config.cmd, cleaned_config.cmd_args, cleaned_config.offset_encoding + + local client_id = next_client_id() + + local callbacks = config.callbacks or {} + local name = config.name or tostring(client_id) + local log_prefix = string.format("LSP[%s]", name) + + local handlers = {} + + local function resolve_callback(method) + return callbacks[method] or default_callbacks[method] + end + + function handlers.notification(method, params) + local _ = log.debug() and log.debug('notification', method, params) + local callback = resolve_callback(method) + if callback then + -- Method name is provided here for convenience. + callback(nil, method, params, client_id) + end + end + + function handlers.server_request(method, params) + local _ = log.debug() and log.debug('server_request', method, params) + local callback = resolve_callback(method) + if callback then + local _ = log.debug() and log.debug("server_request: found callback for", method) + return callback(nil, method, params, client_id) + end + local _ = log.debug() and log.debug("server_request: no callback found for", method) + return nil, lsp.rpc_response_error(protocol.ErrorCodes.MethodNotFound) + end + + function handlers.on_error(code, err) + local _ = log.error() and log.error(log_prefix, "on_error", { code = lsp.client_errors[code], err = err }) + err_message(log_prefix, ': Error ', lsp.client_errors[code], ': ', vim.inspect(err)) + if config.on_error then + local status, usererr = pcall(config.on_error, code, err) + if not status then + local _ = log.error() and log.error(log_prefix, "user on_error failed", { err = usererr }) + err_message(log_prefix, ' user on_error failed: ', tostring(usererr)) + end + end + end + + function handlers.on_exit(code, signal) + active_clients[client_id] = nil + uninitialized_clients[client_id] = nil + local active_buffers = {} + for bufnr, client_ids in pairs(all_buffer_active_clients) do + if client_ids[client_id] then + table.insert(active_buffers, bufnr) + end + client_ids[client_id] = nil + end + -- Buffer level cleanup + vim.schedule(function() + for _, bufnr in ipairs(active_buffers) do + util.buf_clear_diagnostics(bufnr) + end + end) + if config.on_exit then + pcall(config.on_exit, code, signal, client_id) + end + end + + -- Start the RPC client. + local rpc = lsp_rpc.start(cmd, cmd_args, handlers, { + cwd = config.cmd_cwd; + env = config.cmd_env; + }) + + local client = { + id = client_id; + name = name; + rpc = rpc; + offset_encoding = offset_encoding; + callbacks = callbacks; + config = config; + } + + -- Store the uninitialized_clients for cleanup in case we exit before + -- initialize finishes. + uninitialized_clients[client_id] = client; + + local function initialize() + local valid_traces = { + off = 'off'; messages = 'messages'; verbose = 'verbose'; + } + local initialize_params = { + -- The process Id of the parent process that started the server. Is null if + -- the process has not been started by another process. If the parent + -- process is not alive then the server should exit (see exit notification) + -- its process. + processId = uv.getpid(); + -- The rootPath of the workspace. Is null if no folder is open. + -- + -- @deprecated in favour of rootUri. + rootPath = config.root_dir; + -- The rootUri of the workspace. Is null if no folder is open. If both + -- `rootPath` and `rootUri` are set `rootUri` wins. + rootUri = vim.uri_from_fname(config.root_dir); + -- User provided initialization options. + initializationOptions = config.init_options; + -- The capabilities provided by the client (editor or tool) + capabilities = config.capabilities or protocol.make_client_capabilities(); + -- The initial trace setting. If omitted trace is disabled ("off"). + -- trace = "off" | "messages" | "verbose"; + trace = valid_traces[config.trace] or 'off'; + -- The workspace folders configured in the client when the server starts. + -- This property is only available if the client supports workspace folders. + -- It can be `null` if the client supports workspace folders but none are + -- configured. + -- + -- Since 3.6.0 + -- workspaceFolders?: WorkspaceFolder[] | null; + -- export interface WorkspaceFolder { + -- -- The associated URI for this workspace folder. + -- uri + -- -- The name of the workspace folder. Used to refer to this + -- -- workspace folder in the user interface. + -- name + -- } + workspaceFolders = nil; + } + if config.before_init then + -- TODO(ashkan) handle errors here. + pcall(config.before_init, initialize_params, config) + end + local _ = log.debug() and log.debug(log_prefix, "initialize_params", initialize_params) + rpc.request('initialize', initialize_params, function(init_err, result) + assert(not init_err, tostring(init_err)) + assert(result, "server sent empty result") + rpc.notify('initialized', {[vim.type_idx]=vim.types.dictionary}) + client.initialized = true + uninitialized_clients[client_id] = nil + client.server_capabilities = assert(result.capabilities, "initialize result doesn't contain capabilities") + -- These are the cleaned up capabilities we use for dynamically deciding + -- when to send certain events to clients. + client.resolved_capabilities = protocol.resolve_capabilities(client.server_capabilities) + if config.on_init then + local status, err = pcall(config.on_init, client, result) + if not status then + pcall(handlers.on_error, lsp.client_errors.ON_INIT_CALLBACK_ERROR, err) + end + end + local _ = log.debug() and log.debug(log_prefix, "server_capabilities", client.server_capabilities) + local _ = log.info() and log.info(log_prefix, "initialized", { resolved_capabilities = client.resolved_capabilities }) + + -- Only assign after initialized. + active_clients[client_id] = client + -- If we had been registered before we start, then send didOpen This can + -- happen if we attach to buffers before initialize finishes or if + -- someone restarts a client. + for bufnr, client_ids in pairs(all_buffer_active_clients) do + if client_ids[client_id] then + client._on_attach(bufnr) + end + end + end) + end + + local function unsupported_method(method) + local msg = "server doesn't support "..method + local _ = log.warn() and log.warn(msg) + err_message(msg) + return lsp.rpc_response_error(protocol.ErrorCodes.MethodNotFound, msg) + end + + --- Checks capabilities before rpc.request-ing. + function client.request(method, params, callback, bufnr) + if not callback then + callback = resolve_callback(method) + or error(string.format("not found: %q request callback for client %q.", method, client.name)) + end + local _ = log.debug() and log.debug(log_prefix, "client.request", client_id, method, params, callback, bufnr) + -- TODO keep these checks or just let it go anyway? + if (not client.resolved_capabilities.hover and method == 'textDocument/hover') + or (not client.resolved_capabilities.signature_help and method == 'textDocument/signatureHelp') + or (not client.resolved_capabilities.goto_definition and method == 'textDocument/definition') + or (not client.resolved_capabilities.implementation and method == 'textDocument/implementation') + or (not client.resolved_capabilities.declaration and method == 'textDocument/declaration') + or (not client.resolved_capabilities.type_definition and method == 'textDocument/typeDefinition') + or (not client.resolved_capabilities.document_symbol and method == 'textDocument/documentSymbol') + or (not client.resolved_capabilities.workspace_symbol and method == 'textDocument/workspaceSymbol') + or (not client.resolved_capabilities.call_hierarchy and method == 'textDocument/prepareCallHierarchy') + then + callback(unsupported_method(method), method, nil, client_id, bufnr) + return + end + return rpc.request(method, params, function(err, result) + callback(err, method, result, client_id, bufnr) + end) + end + + function client.notify(...) + return rpc.notify(...) + end + + function client.cancel_request(id) + validate{id = {id, 'n'}} + return rpc.notify("$/cancelRequest", { id = id }) + end + + -- Track this so that we can escalate automatically if we've alredy tried a + -- graceful shutdown + local tried_graceful_shutdown = false + function client.stop(force) + local handle = rpc.handle + if handle:is_closing() then + return + end + if force or (not client.initialized) or tried_graceful_shutdown then + handle:kill(15) + return + end + tried_graceful_shutdown = true + -- Sending a signal after a process has exited is acceptable. + rpc.request('shutdown', nil, function(err, _) + if err == nil then + rpc.notify('exit') + else + -- If there was an error in the shutdown request, then term to be safe. + handle:kill(15) + end + end) + end + + function client.is_stopped() + return rpc.handle:is_closing() + end + + function client._on_attach(bufnr) + text_document_did_open_handler(bufnr, client) + if config.on_attach then + -- TODO(ashkan) handle errors. + pcall(config.on_attach, client, bufnr) + end + end + + initialize() + + return client_id +end + +local function once(fn) + local value + return function(...) + if not value then value = fn(...) end + return value + end +end + +local text_document_did_change_handler +do + local encoding_index = { ["utf-8"] = 1; ["utf-16"] = 2; ["utf-32"] = 3; } + text_document_did_change_handler = function(_, bufnr, changedtick, + firstline, lastline, new_lastline, old_byte_size, old_utf32_size, + old_utf16_size) + local _ = log.debug() and log.debug("on_lines", bufnr, changedtick, firstline, + lastline, new_lastline, old_byte_size, old_utf32_size, old_utf16_size, nvim_buf_get_lines(bufnr, firstline, new_lastline, true)) + + -- Don't do anything if there are no clients attached. + if tbl_isempty(all_buffer_active_clients[bufnr] or {}) then + return + end + + util.buf_versions[bufnr] = changedtick + -- Lazy initialize these because clients may not even need them. + local incremental_changes = once(function(client) + local size_index = encoding_index[client.offset_encoding] + local length = select(size_index, old_byte_size, old_utf16_size, old_utf32_size) + local lines = nvim_buf_get_lines(bufnr, firstline, new_lastline, true) + -- This is necessary because we are specifying the full line including the + -- newline in range. Therefore, we must replace the newline as well. + if #lines > 0 then + table.insert(lines, '') + end + return { + range = { + start = { line = firstline, character = 0 }; + ["end"] = { line = lastline, character = 0 }; + }; + rangeLength = length; + text = table.concat(lines, '\n'); + }; + end) + local full_changes = once(function() + return { + text = buf_get_full_text(bufnr); + }; + end) + local uri = vim.uri_from_bufnr(bufnr) + for_each_buffer_client(bufnr, function(client, _client_id) + local text_document_did_change = client.resolved_capabilities.text_document_did_change + local changes + if text_document_did_change == protocol.TextDocumentSyncKind.None then + return + --[=[ TODO(ashkan) there seem to be problems with the byte_sizes sent by + -- neovim right now so only send the full content for now. In general, we + -- can assume that servers *will* support both versions anyway, as there + -- is no way to specify the sync capability by the client. + -- See https://github.com/palantir/python-language-server/commit/cfd6675bc10d5e8dbc50fc50f90e4a37b7178821#diff-f68667852a14e9f761f6ebf07ba02fc8 for an example of pyls handling both. + --]=] + elseif true or text_document_did_change == protocol.TextDocumentSyncKind.Full then + changes = full_changes(client) + elseif text_document_did_change == protocol.TextDocumentSyncKind.Incremental then + changes = incremental_changes(client) + end + client.notify("textDocument/didChange", { + textDocument = { + uri = uri; + version = changedtick; + }; + contentChanges = { changes; } + }) + end) + end +end + +-- Buffer lifecycle handler for textDocument/didSave +function lsp._text_document_did_save_handler(bufnr) + bufnr = resolve_bufnr(bufnr) + local uri = vim.uri_from_bufnr(bufnr) + local text = once(function() + return table.concat(nvim_buf_get_lines(bufnr, 0, -1, false), '\n') + end) + for_each_buffer_client(bufnr, function(client, _client_id) + if client.resolved_capabilities.text_document_save then + local included_text + if client.resolved_capabilities.text_document_save_include_text then + included_text = text() + end + client.notify('textDocument/didSave', { + textDocument = { + uri = uri; + text = included_text; + } + }) + end + end) +end + +--- Implements the `textDocument/did…` notifications required to track a buffer +--- for any language server. +--- +--- Without calling this, the server won't be notified of changes to a buffer. +--- +--- @param bufnr (number) Buffer handle, or 0 for current +--- @param client_id (number) Client id +function lsp.buf_attach_client(bufnr, client_id) + validate { + bufnr = {bufnr, 'n', true}; + client_id = {client_id, 'n'}; + } + bufnr = resolve_bufnr(bufnr) + local buffer_client_ids = all_buffer_active_clients[bufnr] + -- This is our first time attaching to this buffer. + if not buffer_client_ids then + buffer_client_ids = {} + all_buffer_active_clients[bufnr] = buffer_client_ids + + local uri = vim.uri_from_bufnr(bufnr) + nvim_command(string.format("autocmd BufWritePost <buffer=%d> lua vim.lsp._text_document_did_save_handler(0)", bufnr)) + -- First time, so attach and set up stuff. + vim.api.nvim_buf_attach(bufnr, false, { + on_lines = text_document_did_change_handler; + on_detach = function() + local params = { textDocument = { uri = uri; } } + for_each_buffer_client(bufnr, function(client, _client_id) + if client.resolved_capabilities.text_document_open_close then + client.notify('textDocument/didClose', params) + end + end) + util.buf_versions[bufnr] = nil + all_buffer_active_clients[bufnr] = nil + end; + -- TODO if we know all of the potential clients ahead of time, then we + -- could conditionally set this. + -- utf_sizes = size_index > 1; + utf_sizes = true; + }) + end + if buffer_client_ids[client_id] then return end + -- This is our first time attaching this client to this buffer. + buffer_client_ids[client_id] = true + + local client = active_clients[client_id] + -- Send didOpen for the client if it is initialized. If it isn't initialized + -- then it will send didOpen on initialize. + if client then + client._on_attach(bufnr) + end + return true +end + +--- Checks if a buffer is attached for a particular client. +--- +---@param bufnr (number) Buffer handle, or 0 for current +---@param client_id (number) the client id +function lsp.buf_is_attached(bufnr, client_id) + return (all_buffer_active_clients[bufnr] or {})[client_id] == true +end + +--- Gets an active client by id, or nil if the id is invalid or the +--- client is not yet initialized. +--- +--@param client_id client id number +--- +--@return |vim.lsp.client| object, or nil +function lsp.get_client_by_id(client_id) + return active_clients[client_id] +end + +--- Stops a client(s). +--- +--- You can also use the `stop()` function on a |vim.lsp.client| object. +--- To stop all clients: +--- +--- <pre> +--- vim.lsp.stop_client(lsp.get_active_clients()) +--- </pre> +--- +--- By default asks the server to shutdown, unless stop was requested +--- already for this client, then force-shutdown is attempted. +--- +--@param client_id client id or |vim.lsp.client| object, or list thereof +--@param force boolean (optional) shutdown forcefully +function lsp.stop_client(client_id, force) + local ids = type(client_id) == 'table' and client_id or {client_id} + for _, id in ipairs(ids) do + if type(id) == 'table' and id.stop ~= nil then + id.stop(force) + elseif active_clients[id] then + active_clients[id].stop(force) + elseif uninitialized_clients[id] then + uninitialized_clients[id].stop(true) + end + end +end + +--- Gets all active clients. +--- +--@return Table of |vim.lsp.client| objects +function lsp.get_active_clients() + return vim.tbl_values(active_clients) +end + +function lsp._vim_exit_handler() + log.info("exit_handler", active_clients) + for _, client in pairs(uninitialized_clients) do + client.stop(true) + end + -- TODO handle v:dying differently? + if tbl_isempty(active_clients) then + return + end + for _, client in pairs(active_clients) do + client.stop() + end + + if not vim.wait(500, function() return tbl_isempty(active_clients) end, 50) then + for _, client in pairs(active_clients) do + client.stop(true) + end + end +end + +nvim_command("autocmd VimLeavePre * lua vim.lsp._vim_exit_handler()") + + +--- Sends an async request for all active clients attached to the +--- buffer. +--- +--@param bufnr (number) Buffer handle, or 0 for current. +--@param method (string) LSP method name +--@param params (optional, table) Parameters to send to the server +--@param callback (optional, functionnil) Handler +-- `function(err, method, params, client_id)` for this request. Defaults +-- to the client callback in `client.callbacks`. See |lsp-callbacks|. +-- +--@returns 2-tuple: +--- - Map of client-id:request-id pairs for all successful requests. +--- - Function which can be used to cancel all the requests. You could instead +--- iterate all clients and call their `cancel_request()` methods. +function lsp.buf_request(bufnr, method, params, callback) + validate { + bufnr = { bufnr, 'n', true }; + method = { method, 's' }; + callback = { callback, 'f', true }; + } + local client_request_ids = {} + for_each_buffer_client(bufnr, function(client, client_id, resolved_bufnr) + local request_success, request_id = client.request(method, params, callback, resolved_bufnr) + + -- This could only fail if the client shut down in the time since we looked + -- it up and we did the request, which should be rare. + if request_success then + client_request_ids[client_id] = request_id + end + end) + + local function _cancel_all_requests() + for client_id, request_id in pairs(client_request_ids) do + local client = active_clients[client_id] + client.cancel_request(request_id) + end + end + + return client_request_ids, _cancel_all_requests +end + +--- Sends a request to a server and waits for the response. +--- +--- Calls |vim.lsp.buf_request()| but blocks Nvim while awaiting the result. +--- Parameters are the same as |vim.lsp.buf_request()| but the return result is +--- different. Wait maximum of {timeout_ms} (default 100) ms. +--- +--@param bufnr (number) Buffer handle, or 0 for current. +--@param method (string) LSP method name +--@param params (optional, table) Parameters to send to the server +--@param timeout_ms (optional, number, default=100) Maximum time in +--- milliseconds to wait for a result. +--- +--@returns Map of client_id:request_result. On timeout, cancel or error, +--- returns `(nil, err)` where `err` is a string describing the failure +--- reason. +function lsp.buf_request_sync(bufnr, method, params, timeout_ms) + local request_results = {} + local result_count = 0 + local function _callback(err, _method, result, client_id) + request_results[client_id] = { error = err, result = result } + result_count = result_count + 1 + end + local client_request_ids, cancel = lsp.buf_request(bufnr, method, params, _callback) + local expected_result_count = 0 + for _ in pairs(client_request_ids) do + expected_result_count = expected_result_count + 1 + end + + local wait_result, reason = vim.wait(timeout_ms or 100, function() + return result_count >= expected_result_count + end, 10) + + if not wait_result then + cancel() + return nil, wait_result_reason[reason] + end + return request_results +end + +--- Send a notification to a server +--@param bufnr [number] (optional): The number of the buffer +--@param method [string]: Name of the request method +--@param params [string]: Arguments to send to the server +--- +--@returns true if any client returns true; false otherwise +function lsp.buf_notify(bufnr, method, params) + validate { + bufnr = { bufnr, 'n', true }; + method = { method, 's' }; + } + local resp = false + for_each_buffer_client(bufnr, function(client, _client_id, _resolved_bufnr) + if client.rpc.notify(method, params) then resp = true end + end) + return resp +end + +--- Implements 'omnifunc' compatible LSP completion. +--- +--@see |complete-functions| +--@see |complete-items| +--@see |CompleteDone| +--- +--@param findstart 0 or 1, decides behavior +--@param base If findstart=0, text to match against +--- +--@return (number) Decided by `findstart`: +--- - findstart=0: column where the completion starts, or -2 or -3 +--- - findstart=1: list of matches (actually just calls |complete()|) +function lsp.omnifunc(findstart, base) + local _ = log.debug() and log.debug("omnifunc.findstart", { findstart = findstart, base = base }) + + local bufnr = resolve_bufnr() + local has_buffer_clients = not tbl_isempty(all_buffer_active_clients[bufnr] or {}) + if not has_buffer_clients then + if findstart == 1 then + return -1 + else + return {} + end + end + + -- Then, perform standard completion request + local _ = log.info() and log.info("base ", base) + + local pos = vim.api.nvim_win_get_cursor(0) + local line = vim.api.nvim_get_current_line() + local line_to_cursor = line:sub(1, pos[2]) + local _ = log.trace() and log.trace("omnifunc.line", pos, line) + + -- Get the start position of the current keyword + local textMatch = vim.fn.match(line_to_cursor, '\\k*$') + local prefix = line_to_cursor:sub(textMatch+1) + + local params = util.make_position_params() + + local items = {} + lsp.buf_request(bufnr, 'textDocument/completion', params, function(err, _, result) + if err or not result then return end + local matches = util.text_document_completion_list_to_complete_items(result, prefix) + -- TODO(ashkan): is this the best way to do this? + vim.list_extend(items, matches) + vim.fn.complete(textMatch+1, items) + end) + + -- Return -2 to signal that we should continue completion so that we can + -- async complete. + return -2 +end + +function lsp.client_is_stopped(client_id) + return active_clients[client_id] == nil +end + +--- Gets a map of client_id:client pairs for the given buffer, where each value +--- is a |vim.lsp.client| object. +--- +--@param bufnr (optional, number): Buffer handle, or 0 for current +function lsp.buf_get_clients(bufnr) + bufnr = resolve_bufnr(bufnr) + local result = {} + for_each_buffer_client(bufnr, function(client, client_id) + result[client_id] = client + end) + return result +end + +-- 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 +lsp.log_levels = log.levels + +--- Sets the global log level for LSP logging. +--- +--- Levels by name: "trace", "debug", "info", "warn", "error" +--- Level numbers begin with "trace" at 0 +--- +--- Use `lsp.log_levels` for reverse lookup. +--- +--@see |vim.lsp.log_levels| +--- +--@param level [number|string] the case insensitive level name or number +function lsp.set_log_level(level) + if type(level) == 'string' or type(level) == 'number' then + log.set_level(level) + else + error(string.format("Invalid log level: %q", level)) + end +end + +--- Gets the path of the logfile used by the LSP client. +function lsp.get_log_path() + return log.get_filename() +end + +-- Define the LspDiagnostics signs if they're not defined already. +do + local function define_default_sign(name, properties) + if vim.tbl_isempty(vim.fn.sign_getdefined(name)) then + vim.fn.sign_define(name, properties) + end + end + define_default_sign('LspDiagnosticsErrorSign', {text='E', texthl='LspDiagnosticsErrorSign', linehl='', numhl=''}) + define_default_sign('LspDiagnosticsWarningSign', {text='W', texthl='LspDiagnosticsWarningSign', linehl='', numhl=''}) + define_default_sign('LspDiagnosticsInformationSign', {text='I', texthl='LspDiagnosticsInformationSign', linehl='', numhl=''}) + define_default_sign('LspDiagnosticsHintSign', {text='H', texthl='LspDiagnosticsHintSign', linehl='', numhl=''}) +end + +return lsp +-- vim:sw=2 ts=2 et diff --git a/runtime/lua/vim/lsp/buf.lua b/runtime/lua/vim/lsp/buf.lua new file mode 100644 index 0000000000..2e27617997 --- /dev/null +++ b/runtime/lua/vim/lsp/buf.lua @@ -0,0 +1,251 @@ +local vim = vim +local validate = vim.validate +local api = vim.api +local vfn = vim.fn +local util = require 'vim.lsp.util' +local list_extend = vim.list_extend + +local M = {} + +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 request(method, params, callback) + validate { + method = {method, 's'}; + callback = {callback, 'f', true}; + } + return vim.lsp.buf_request(0, method, params, callback) +end + +--- Sends a notification through all clients associated with current buffer. +-- +--@return `true` if server responds. +function M.server_ready() + return not not vim.lsp.buf_notify(0, "window/progress", {}) +end + +--- Displays hover information about the symbol under the cursor in a floating +--- window. Calling the function twice will jump into the floating window. +function M.hover() + local params = util.make_position_params() + request('textDocument/hover', params) +end + +--- Jumps to the declaration of the symbol under the cursor. +--- +function M.declaration() + local params = util.make_position_params() + request('textDocument/declaration', params) +end + +--- Jumps to the definition of the symbol under the cursor. +--- +function M.definition() + local params = util.make_position_params() + request('textDocument/definition', params) +end + +--- Jumps to the definition of the type of the symbol under the cursor. +--- +function M.type_definition() + local params = util.make_position_params() + request('textDocument/typeDefinition', params) +end + +--- Lists all the implementations for the symbol under the cursor in the +--- quickfix window. +function M.implementation() + local params = util.make_position_params() + request('textDocument/implementation', params) +end + +--- Displays signature information about the symbol under the cursor in a +--- floating window. +function M.signature_help() + local params = util.make_position_params() + request('textDocument/signatureHelp', params) +end + +--- Retrieves the completion items at the current cursor position. Can only be +--- called in Insert mode. +function M.completion(context) + local params = util.make_position_params() + params.context = context + return request('textDocument/completion', params) +end + +--- Formats the current buffer. +--- +--- The optional {options} table can be used to specify FormattingOptions, a +--- list of which is available at +--- https://microsoft.github.io/language-server-protocol/specification#textDocument_formatting. +--- Some unspecified options will be automatically derived from the current +--- Neovim options. +function M.formatting(options) + local params = util.make_formatting_params(options) + return request('textDocument/formatting', params) +end + +--- Perform |vim.lsp.buf.formatting()| synchronously. +--- +--- Useful for running on save, to make sure buffer is formatted prior to being +--- saved. {timeout_ms} is passed on to |vim.lsp.buf_request_sync()|. +function M.formatting_sync(options, timeout_ms) + local params = util.make_formatting_params(options) + local result = vim.lsp.buf_request_sync(0, "textDocument/formatting", params, timeout_ms) + if not result then return end + result = result[1].result + vim.lsp.util.apply_text_edits(result) +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}; + } + local sts = vim.bo.softtabstop; + options = vim.tbl_extend('keep', options or {}, { + tabSize = (sts > 0 and sts) or (sts < 0 and vim.bo.shiftwidth) or 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, '>')) + -- 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, A[1], A[2])} + end + if B[2] > 0 then + B = {B[1], util.character_offset(0, B[1], B[2])} + end + local params = { + textDocument = { uri = vim.uri_from_bufnr(0) }; + range = { + start = { line = A[1]; character = A[2]; }; + ["end"] = { line = B[1]; character = B[2]; }; + }; + options = options; + } + return request('textDocument/rangeFormatting', params) +end + +--- Renames all references to the symbol under the cursor. If {new_name} is not +--- provided, the user will be prompted for a new name using |input()|. +function M.rename(new_name) + -- TODO(ashkan) use prepareRename + -- * result: [`Range`](#range) \| `{ range: Range, placeholder: string }` \| `null` describing the range of the string to rename and optionally a placeholder text of the string content to be renamed. If `null` is returned then it is deemed that a 'textDocument/rename' request is not valid at the given position. + local params = util.make_position_params() + new_name = new_name or npcall(vfn.input, "New Name: ", vfn.expand('<cword>')) + if not (new_name and #new_name > 0) then return end + params.newName = new_name + request('textDocument/rename', params) +end + +--- Lists all the references to the symbol under the cursor in the quickfix window. +--- +function M.references(context) + validate { context = { context, 't', true } } + local params = util.make_position_params() + params.context = context or { + includeDeclaration = true; + } + params[vim.type_idx] = vim.types.dictionary + request('textDocument/references', params) +end + +--- Lists all symbols in the current buffer in the quickfix window. +--- +function M.document_symbol() + local params = { textDocument = util.make_text_document_params() } + request('textDocument/documentSymbol', params) +end + +local function pick_call_hierarchy_item(call_hierarchy_items) + if not call_hierarchy_items then return end + if #call_hierarchy_items == 1 then + return call_hierarchy_items[1] + end + local items = {} + for i, item in ipairs(call_hierarchy_items) do + local entry = item.detail or item.name + table.insert(items, string.format("%d. %s", i, entry)) + end + local choice = vim.fn.inputlist(items) + if choice < 1 or choice > #items then + return + end + return choice +end + +function M.incoming_calls() + local params = util.make_position_params() + request('textDocument/prepareCallHierarchy', params, function(_, _, result) + local call_hierarchy_item = pick_call_hierarchy_item(result) + vim.lsp.buf_request(0, 'callHierarchy/incomingCalls', { item = call_hierarchy_item }) + end) +end + +function M.outgoing_calls() + local params = util.make_position_params() + request('textDocument/prepareCallHierarchy', params, function(_, _, result) + local call_hierarchy_item = pick_call_hierarchy_item(result) + vim.lsp.buf_request(0, 'callHierarchy/outgoingCalls', { item = call_hierarchy_item }) + end) +end + +--- Lists all symbols in the current workspace in the quickfix window. +--- +--- The list is filtered against the optional argument {query}; +--- if the argument is omitted from the call, the user is prompted to enter a string on the command line. +--- An empty string means no filtering is done. +function M.workspace_symbol(query) + query = query or npcall(vfn.input, "Query: ") + local params = {query = query} + request('workspace/symbol', params) +end + +--- Send request to server to resolve document highlights for the +--- current text document position. This request can be associated +--- to key mapping or to events such as `CursorHold`, eg: +--- +--- <pre> +--- vim.api.nvim_command [[autocmd CursorHold <buffer> lua vim.lsp.buf.document_highlight()]] +--- vim.api.nvim_command [[autocmd CursorHoldI <buffer> lua vim.lsp.buf.document_highlight()]] +--- vim.api.nvim_command [[autocmd CursorMoved <buffer> lua vim.lsp.buf.clear_references()]] +--- </pre> +function M.document_highlight() + local params = util.make_position_params() + request('textDocument/documentHighlight', params) +end + +function M.clear_references() + util.buf_clear_references() +end + +function M.code_action(context) + validate { context = { context, 't', true } } + context = context or { diagnostics = util.get_line_diagnostics() } + local params = util.make_range_params() + params.context = context + request('textDocument/codeAction', params) +end + +function M.execute_command(command) + validate { + command = { command.command, 's' }, + arguments = { command.arguments, 't', true } + } + request('workspace/executeCommand', command) +end + +return M +-- vim:sw=2 ts=2 et diff --git a/runtime/lua/vim/lsp/callbacks.lua b/runtime/lua/vim/lsp/callbacks.lua new file mode 100644 index 0000000000..1ed58995d0 --- /dev/null +++ b/runtime/lua/vim/lsp/callbacks.lua @@ -0,0 +1,293 @@ +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 buf = require 'vim.lsp.buf' + +local M = {} + +local function err_message(...) + api.nvim_err_writeln(table.concat(vim.tbl_flatten{...})) + api.nvim_command("redraw") +end + +M['workspace/executeCommand'] = function(err, _) + if err then + error("Could not execute code action: "..err.message) + end +end + +M['textDocument/codeAction'] = function(_, _, actions) + if actions == nil or vim.tbl_isempty(actions) then + print("No code actions available") + return + end + + local option_strings = {"Code Actions:"} + for i, action in ipairs(actions) do + local title = action.title:gsub('\r\n', '\\r\\n') + title = title:gsub('\n', '\\n') + table.insert(option_strings, string.format("%d. %s", i, title)) + end + + local choice = vim.fn.inputlist(option_strings) + if choice < 1 or choice > #actions then + return + end + local action_chosen = actions[choice] + -- textDocument/codeAction can return either Command[] or CodeAction[]. + -- If it is a CodeAction, it can have either an edit, a command or both. + -- Edits should be executed first + if action_chosen.edit or type(action_chosen.command) == "table" then + if action_chosen.edit then + util.apply_workspace_edit(action_chosen.edit) + end + if type(action_chosen.command) == "table" then + buf.execute_command(action_chosen.command) + end + else + buf.execute_command(action_chosen) + end +end + +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 + local status, result = pcall(util.apply_workspace_edit, workspace_edit.edit) + return { + applied = status; + failureReason = result; + } +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 + + -- Unloaded buffers should not handle diagnostics. + -- When the buffer is loaded, we'll call on_attach, which sends textDocument/didOpen. + -- This should trigger another publish of the diagnostics. + -- + -- In particular, this stops a ton of spam when first starting a server for current + -- unloaded buffers. + if not api.nvim_buf_is_loaded(bufnr) then + return + end + + util.buf_clear_diagnostics(bufnr) + + -- https://microsoft.github.io/language-server-protocol/specifications/specification-current/#diagnostic + -- The diagnostic's severity. Can be omitted. If omitted it is up to the + -- client to interpret diagnostics as error, warning, info or hint. + -- TODO: Replace this with server-specific heuristics to infer severity. + for _, diagnostic in ipairs(result.diagnostics) do + if diagnostic.severity == nil then + diagnostic.severity = protocol.DiagnosticSeverity.Error + end + end + + 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_diagnostics_signs(bufnr, result.diagnostics) + vim.api.nvim_command("doautocmd User LspDiagnosticsChanged") +end + +M['textDocument/references'] = function(_, _, result) + if not result then return end + util.set_qflist(util.locations_to_items(result)) + api.nvim_command("copen") + api.nvim_command("wincmd p") +end + +local symbol_callback = function(_, _, result, _, bufnr) + if not result or vim.tbl_isempty(result) then return end + + util.set_qflist(util.symbols_to_items(result, bufnr)) + api.nvim_command("copen") + api.nvim_command("wincmd p") +end +M['textDocument/documentSymbol'] = symbol_callback +M['workspace/symbol'] = symbol_callback + +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 textMatch = vim.fn.match(line_to_cursor, '\\k*$') + local prefix = line_to_cursor:sub(textMatch+1) + + local matches = util.text_document_completion_list_to_complete_items(result, prefix) + vim.fn.complete(textMatch+1, matches) +end + +M['textDocument/hover'] = function(_, method, result) + util.focusable_float(method, function() + if not (result and result.contents) then + -- return { 'No information available' } + 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' } + return + end + local bufnr, winnr = util.fancy_floating_markdown(markdown_lines, { + pad_left = 1; pad_right = 1; + }) + util.close_preview_autocmd({"CursorMoved", "BufHidden", "InsertCharPre"}, winnr) + return bufnr, winnr + 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 + + -- textDocument/definition can return Location or Location[] + -- https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_definition + + if vim.tbl_islist(result) then + util.jump_to_location(result[1]) + + if #result > 1 then + util.set_qflist(util.locations_to_items(result)) + api.nvim_command("copen") + api.nvim_command("wincmd p") + end + else + util.jump_to_location(result) + end +end + +M['textDocument/declaration'] = location_callback +M['textDocument/definition'] = location_callback +M['textDocument/typeDefinition'] = location_callback +M['textDocument/implementation'] = location_callback + +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 = util.convert_signature_help_to_markdown_lines(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/documentHighlight'] = function(_, _, result, _) + if not result then return end + local bufnr = api.nvim_get_current_buf() + util.buf_highlight_references(bufnr, result) +end + +-- direction is "from" for incoming calls and "to" for outgoing calls +local make_call_hierarchy_callback = function(direction) + -- result is a CallHierarchy{Incoming,Outgoing}Call[] + return function(_, _, result) + if not result then return end + local items = {} + for _, call_hierarchy_call in pairs(result) do + local call_hierarchy_item = call_hierarchy_call[direction] + for _, range in pairs(call_hierarchy_call.fromRanges) do + table.insert(items, { + filename = assert(vim.uri_to_fname(call_hierarchy_item.uri)), + text = call_hierarchy_item.name, + lnum = range.start.line + 1, + col = range.start.character + 1, + }) + end + end + util.set_qflist(items) + api.nvim_command("copen") + api.nvim_command("wincmd p") + end +end + +M['callHierarchy/incomingCalls'] = make_call_hierarchy_callback('from') + +M['callHierarchy/outgoingCalls'] = make_call_hierarchy_callback('to') + +M['window/logMessage'] = function(_, _, 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 + log.error(message) + elseif message_type == protocol.MessageType.Warning then + log.warn(message) + elseif message_type == protocol.MessageType.Info then + log.info(message) + else + log.debug(message) + end + return result +end + +M['window/showMessage'] = function(_, _, 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 + +-- Add boilerplate error validation and logging for all of these. +for k, fn in pairs(M) do + M[k] = function(err, method, params, client_id, bufnr) + log.debug('default_callback', method, { params = params, client_id = client_id, err = err, bufnr = bufnr }) + if err then + error(tostring(err)) + end + return fn(err, method, params, client_id, bufnr) + end +end + +return M +-- 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..696ce43a59 --- /dev/null +++ b/runtime/lua/vim/lsp/log.lua @@ -0,0 +1,94 @@ +-- 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; +} + +-- Default log level is warn. +local current_log_level = log.levels.WARN +local log_date_format = "%FT%H:%M:%S%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'), '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..ef5e08680e --- /dev/null +++ b/runtime/lua/vim/lsp/protocol.lua @@ -0,0 +1,988 @@ +-- 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/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; +} +--]=] + +--- Gets a new ClientCapabilities object describing the LSP client +--- capabilities. +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; + }; + codeAction = { + dynamicRegistration = false; + + codeActionLiteralSupport = { + codeActionKind = { + valueSet = {}; + }; + }; + }; + completion = { + dynamicRegistration = false; + completionItem = { + + snippetSupport = true; + 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; + }; + declaration = { + linkSupport = true; + }; + definition = { + linkSupport = true; + }; + implementation = { + linkSupport = true; + }; + typeDefinition = { + linkSupport = true; + }; + 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) == 'number' then table.insert(res, k) end + end + return res + end)(); + }; + hierarchicalDocumentSymbolSupport = true; + }; + }; + workspace = { + symbol = { + dynamicRegistration = false; + symbolKind = { + valueSet = (function() + local res = {} + for k in pairs(protocol.SymbolKind) do + if type(k) == 'number' then table.insert(res, k) end + end + return res + end)(); + }; + hierarchicalWorkspaceSymbolSupport = true; + }; + applyEdit = true; + }; + callHierarchy = { + dynamicRegistration = false; + }; + experimental = nil; + } +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; +} +--]] + +--- Creates a normalized object describing LSP server capabilities. +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(type(textDocumentSync.save) == 'table' + 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 + general_properties.call_hierarchy = server_capabilities.callHierarchyProvider 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.declarationProvider == nil then + general_properties.declaration = false + elseif type(server_capabilities.declarationProvider) == 'boolean' then + general_properties.declaration = server_capabilities.declarationProvider + elseif type(server_capabilities.declarationProvider) == 'table' then + -- TODO: support more detailed declarationProvider options. + general_properties.declaration = false + else + error("The server sent invalid declarationProvider") + end + + if server_capabilities.typeDefinitionProvider == nil then + general_properties.type_definition = false + elseif type(server_capabilities.typeDefinitionProvider) == 'boolean' then + general_properties.type_definition = server_capabilities.typeDefinitionProvider + elseif type(server_capabilities.typeDefinitionProvider) == 'table' then + -- TODO: support more detailed typeDefinitionProvider options. + general_properties.type_definition = false + else + error("The server sent invalid typeDefinitionProvider") + 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..81c92bfe05 --- /dev/null +++ b/runtime/lua/vim/lsp/rpc.lua @@ -0,0 +1,472 @@ +local vim = vim +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 + +--- Merges current process env with the given env and returns the result as +--- a list of "k=v" strings. +--- +--- <pre> +--- Example: +--- +--- in: { PRODUCTION="false", PATH="/usr/bin/", PORT=123, HOST="0.0.0.0", } +--- out: { "PRODUCTION=false", "PATH=/usr/bin/", "PORT=123", "HOST=0.0.0.0", } +--- </pre> +local function env_merge(env) + if env == nil then + return env + end + -- Merge. + env = vim.tbl_extend('force', vim.fn.environ(), env) + local final_env = {} + for k,v in pairs(env) do + assert(type(k) == 'string', 'env must be a dict') + table.insert(final_env, k..'='..tostring(v)) + end + return final_env +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' }; + } + + -- There is ErrorCodes in the LSP specification, + -- but in ResponseError.code it is not used and the actual type is number. + local code + if protocol.ErrorCodes[err.code] then + code = string.format("code_name = %s,", protocol.ErrorCodes[err.code]) + else + code = string.format("code_name = unknown, code = %s,", err.code) + end + + local message_parts = {"RPC[Error]", code} + 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 + +--- Creates an RPC response object/table. +--- +--@param code RPC error code defined in `vim.lsp.protocol.ErrorCodes` +--@param message (optional) arbitrary message to send to server +--@param data (optional) arbitrary data to send to server +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 = env_merge(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) + return + 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) + + -- Do not surface RequestCancelled to users, it is RPC-internal. + if decoded.error + and decoded.error.code == protocol.ErrorCodes.RequestCancelled then + local _ = log.debug() and log.debug("Received cancellation ack", decoded) + local result_id = tonumber(decoded.id) + -- Clear any callback since this is cancelled now. + -- This is safe to do assuming that these conditions hold: + -- - The server will not send a result callback after this cancellation. + -- - If the server sent this cancellation ACK after sending the result, the user of this RPC + -- client will ignore the result themselves. + if result_id then + message_callbacks[result_id] = nil + end + return + end + + -- 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..d286f28d0c --- /dev/null +++ b/runtime/lua/vim/lsp/util.lua @@ -0,0 +1,1360 @@ +local protocol = require 'vim.lsp.protocol' +local vim = vim +local validate = vim.validate +local api = vim.api +local list_extend = vim.list_extend +local highlight = require 'vim.highlight' + +local M = {} + +--- Diagnostics received from the server via `textDocument/publishDiagnostics` +-- by buffer. +-- +-- {<bufnr>: {diagnostics}} +-- +-- This contains only entries for active buffers. Entries for detached buffers +-- are discarded. +-- +-- If you override the `textDocument/publishDiagnostic` callback, +-- this will be empty unless you call `buf_diagnostics_save_positions`. +-- +-- +-- Diagnostic is: +-- +-- { +-- range: Range +-- message: string +-- severity?: DiagnosticSeverity +-- code?: number | string +-- source?: string +-- tags?: DiagnosticTag[] +-- relatedInformation?: DiagnosticRelatedInformation[] +-- } +M.diagnostics_by_buf = {} + +local split = vim.split +local function split_lines(value) + return split(value, '\n', true) +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 + +function M.set_lines(lines, A, B, new_lines) + -- 0-indexing to 1-indexing + local i_0 = A[1] + 1 + -- If it extends past the end, truncate it to the end. This is because the + -- way the LSP describes the range including the last newline is by + -- specifying a line number after what we would call the last line. + local i_n = math.min(B[1] + 1, #lines) + 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(B[2]+1) + if A[2] > 0 then + prefix = lines[i_0]:sub(1, A[2]) + end + 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 + local i = i_0 + #new_lines - 1 + lines[i] = lines[i]..suffix + end + if #prefix > 0 then + lines[i_0] = prefix..lines[i_0] + end + return lines +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) + +--- Position is a https://microsoft.github.io/language-server-protocol/specifications/specification-current/#position +-- Returns a zero-indexed column, since set_lines() does the conversion to +-- 1-indexed +local function get_line_byte_from_position(bufnr, position) + -- LSP's line and characters are 0-indexed + -- Vim's line and columns are 1-indexed + local col = position.character + -- When on the first character, we can ignore the difference between byte and + -- character + if col > 0 then + local line = position.line + local lines = api.nvim_buf_get_lines(bufnr, line, line + 1, false) + if #lines > 0 then + return vim.str_byteindex(lines[1], col) + end + end + return col +end + +function M.apply_text_edits(text_edits, bufnr) + if not next(text_edits) then return end + if not api.nvim_buf_is_loaded(bufnr) then + vim.fn.bufload(bufnr) + end + api.nvim_buf_set_option(bufnr, 'buflisted', true) + local start_line, finish_line = math.huge, -1 + local cleaned = {} + for i, e in ipairs(text_edits) do + -- adjust start and end column for UTF-16 encoding of non-ASCII characters + local start_row = e.range.start.line + local start_col = get_line_byte_from_position(bufnr, e.range.start) + local end_row = e.range["end"].line + local end_col = get_line_byte_from_position(bufnr, e.range['end']) + start_line = math.min(e.range.start.line, start_line) + finish_line = math.max(e.range["end"].line, finish_line) + -- TODO(ashkan) sanity check ranges for overlap. + table.insert(cleaned, { + i = i; + A = {start_row; start_col}; + B = {end_row; end_col}; + lines = vim.split(e.newText, '\n', true); + }) + end + + -- Reverse sort the orders so we can apply them without interfering with + -- eachother. Also add i as a sort key to mimic a stable sort. + table.sort(cleaned, edit_sort_key) + local lines = api.nvim_buf_get_lines(bufnr, start_line, finish_line + 1, false) + local fix_eol = api.nvim_buf_get_option(bufnr, 'fixeol') + local set_eol = fix_eol and api.nvim_buf_line_count(bufnr) <= finish_line + 1 + if set_eol and #lines[#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 + +-- 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 + +-- 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.apply_text_document_edit(text_document_edit) + local text_document = text_document_edit.textDocument + local bufnr = vim.uri_to_bufnr(text_document.uri) + if text_document.version then + -- `VersionedTextDocumentIdentifier`s version may be null https://microsoft.github.io/language-server-protocol/specification#versionedTextDocumentIdentifier + if text_document.version ~= vim.NIL and M.buf_versions[bufnr] ~= nil and M.buf_versions[bufnr] > text_document.version then + print("Buffer ", text_document.uri, " newer than edits.") + return + end + end + M.apply_text_edits(text_document_edit.edits, bufnr) +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 + +local function parse_snippet_rec(input, inner) + local res = "" + + local close, closeend = nil, nil + if inner then + close, closeend = input:find("}", 1, true) + while close ~= nil and input:sub(close-1,close-1) == "\\" do + close, closeend = input:find("}", closeend+1, true) + end + end + + local didx = input:find('$', 1, true) + if didx == nil and close == nil then + return input, "" + elseif close ~=nil and (didx == nil or close < didx) then + -- No inner placeholders + return input:sub(0, close-1), input:sub(closeend+1) + end + + res = res .. input:sub(0, didx-1) + input = input:sub(didx+1) + + local tabstop, tabstopend = input:find('^%d+') + local placeholder, placeholderend = input:find('^{%d+:') + local choice, choiceend = input:find('^{%d+|') + + if tabstop then + input = input:sub(tabstopend+1) + elseif choice then + input = input:sub(choiceend+1) + close, closeend = input:find("|}", 1, true) + + res = res .. input:sub(0, close-1) + input = input:sub(closeend+1) + elseif placeholder then + -- TODO: add support for variables + input = input:sub(placeholderend+1) + + -- placeholders and variables are recursive + while input ~= "" do + local r, tail = parse_snippet_rec(input, true) + r = r:gsub("\\}", "}") + + res = res .. r + input = tail + end + else + res = res .. "$" + end + + return res, input +end + +-- Parse completion entries, consuming snippet tokens +function M.parse_snippet(input) + local res, _ = parse_snippet_rec(input, false) + + return res +end + +-- Sort by CompletionItem.sortText +-- https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_completion +local function sort_completion_items(items) + if items[1] and items[1].sortText then + table.sort(items, function(a, b) return a.sortText < b.sortText + end) + end +end + +-- Returns text that should be inserted when selecting completion item. The precedence is as follows: +-- textEdit.newText > insertText > label +-- https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_completion +local function get_completion_word(item) + if item.textEdit ~= nil and item.textEdit.newText ~= nil then + if protocol.InsertTextFormat[item.insertTextFormat] == "PlainText" then + return item.textEdit.newText + else + return M.parse_snippet(item.textEdit.newText) + end + elseif item.insertText ~= nil then + if protocol.InsertTextFormat[item.insertTextFormat] == "PlainText" then + return item.insertText + else + return M.parse_snippet(item.insertText) + end + end + return item.label +end + +-- Some language servers return complementary candidates whose prefixes do not match are also returned. +-- So we exclude completion candidates whose prefix does not match. +local function remove_unmatch_completion_items(items, prefix) + return vim.tbl_filter(function(item) + local word = get_completion_word(item) + return vim.startswith(word, prefix) + end, items) +end + +-- Acording to LSP spec, if the client set "completionItemKind.valueSet", +-- the client must handle it properly even if it receives a value outside the specification. +-- https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_completion +function M._get_completion_item_kind_name(completion_item_kind) + return protocol.CompletionItemKind[completion_item_kind] or "Unknown" +end + +--- 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, prefix) + local items = M.extract_completion_items(result) + if vim.tbl_isempty(items) then + return {} + end + + items = remove_unmatch_completion_items(items, prefix) + sort_completion_items(items) + + 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 = get_completion_word(completion_item) + table.insert(matches, { + word = word, + abbr = completion_item.label, + kind = M._get_completion_item_kind_name(completion_item.kind), + menu = completion_item.detail or '', + info = info, + icase = 1, + dup = 1, + empty = 1, + user_data = { + nvim = { + lsp = { + completion_item = completion_item + } + } + }, + }) + end + + return matches +end + +-- @params WorkspaceEdit [table] see https://microsoft.github.io/language-server-protocol/specification +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.apply_text_document_edit(change) + end + end + return + end + + 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(all_changes) do + local bufnr = vim.uri_to_bufnr(uri) + M.apply_text_edits(changes, bufnr) + 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) and #contents == 1 then + return {} + end + return contents +end + +--- Convert SignatureHelp response to markdown lines. +-- https://microsoft.github.io/language-server-protocol/specifications/specification-3-14/#textDocument_signatureHelp +function M.convert_signature_help_to_markdown_lines(signature_help) + if not signature_help.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 = signature_help.activeSignature or 0 + -- If the activeSignature is not inside the valid range, then clip it. + if active_signature >= #signature_help.signatures then + active_signature = 0 + end + local signature = signature_help.signatures[active_signature + 1] + if not signature then + return + end + vim.list_extend(contents, vim.split(signature.label, '\n', true)) + if signature.documentation then + M.convert_input_to_markdown_lines(signature.documentation, contents) + end + if signature_help.parameters then + local active_parameter = signature_help.activeParameter or 0 + -- If the activeParameter is not inside the valid range, then clip it. + if active_parameter >= #signature_help.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 + M.convert_input_help_to_markdown_lines(parameter.documentation, contents) + end + end + 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 + + local lines_above = vim.fn.winline() - 1 + local lines_below = vim.fn.winheight(0) - lines_above + + if lines_above < lines_below then + anchor = anchor..'N' + height = math.min(lines_below, height) + row = 1 + else + anchor = anchor..'S' + height = math.min(lines_above, height) + 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.jump_to_location(location) + -- location may be Location or LocationLink + local uri = location.uri or location.targetUri + if uri == nil then return end + local bufnr = vim.uri_to_bufnr(uri) + -- Save position in jumplist + vim.cmd "normal! m'" + + -- Push a new item into tagstack + local from = {vim.fn.bufnr('%'), vim.fn.line('.'), vim.fn.col('.'), 0} + local items = {{tagname=vim.fn.expand('<cword>'), from=from}} + vim.fn.settagstack(vim.fn.win_getid(), {items=items}, 't') + + --- Jump to new location (adjusting for UTF-16 encoding of characters) + api.nvim_set_current_buf(bufnr) + api.nvim_buf_set_option(0, 'buflisted', true) + local range = location.range or location.targetSelectionRange + local row = range.start.line + local col = get_line_byte_from_position(0, range.start) + api.nvim_win_set_cursor(0, {row + 1, col}) + return true +end + +--- Preview a location in a floating windows +--- +--- behavior depends on type of location: +--- - for Location, range is shown (e.g., function definition) +--- - for LocationLink, targetRange is shown (e.g., body of function definition) +--- +--@param location a single Location or LocationLink +--@return bufnr,winnr buffer and window number of floating window or nil +function M.preview_location(location) + -- location may be LocationLink or Location (more useful for the former) + local uri = location.targetUri or location.uri + if uri == nil then return end + local bufnr = vim.uri_to_bufnr(uri) + if not api.nvim_buf_is_loaded(bufnr) then + vim.fn.bufload(bufnr) + end + local range = location.targetRange or location.range + local contents = api.nvim_buf_get_lines(bufnr, range.start.line, range["end"].line+1, false) + local filetype = api.nvim_buf_get_option(bufnr, 'filetype') + return M.open_floating_preview(contents, filetype) +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 bufnr, winnr +-- case that a new floating window should be created. +function M.focusable_float(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 = fn() + if pbufnr then + api.nvim_win_set_var(pwinnr, unique_name, bufnr) + return pbufnr, pwinnr + 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) + return M.focusable_float(unique_name, function() + return M.open_floating_preview(fn()) + end) +end + +--- Trim empty lines from input and pad left and right with spaces +--- +--@param contents table of lines to trim and pad +--@param opts dictionary with optional fields +-- - pad_left number of columns to pad contents at left (default 1) +-- - pad_right number of columns to pad contents at right (default 1) +-- - pad_top number of lines to pad contents at top (default 0) +-- - pad_bottom number of lines to pad contents at bottom (default 0) +--@return contents table of trimmed and padded lines +function M._trim_and_pad(contents, opts) + validate { + contents = { contents, 't' }; + opts = { opts, 't', true }; + } + opts = opts or {} + local left_padding = (" "):rep(opts.pad_left or 1) + local right_padding = (" "):rep(opts.pad_right or 1) + contents = M.trim_empty_lines(contents) + for i, line in ipairs(contents) do + contents[i] = string.format('%s%s%s', left_padding, line:gsub("\r", ""), right_padding) + end + if opts.pad_top then + for _ = 1, opts.pad_top do + table.insert(contents, 1, "") + end + end + if opts.pad_bottom then + for _ = 1, opts.pad_bottom do + table.insert(contents, "") + end + end + return contents +end + + + +--- Convert markdown into syntax highlighted regions by stripping the code +--- blocks and converting them into highlighted code. +--- This will by default insert a blank line separator after those code block +--- regions to improve readability. +--- The result is shown in a floating preview +--- TODO: refactor to separate stripping/converting and make use of open_floating_preview +--- +--@param contents table of lines to show in window +--@param opts dictionary with optional fields +-- - height of floating window +-- - width of floating window +-- - wrap_at character to wrap at for computing height +-- - max_width maximal width of floating window +-- - max_height maximal height of floating window +-- - pad_left number of columns to pad contents at left +-- - pad_right number of columns to pad contents at right +-- - pad_top number of lines to pad contents at top +-- - pad_bottom number of lines to pad contents at bottom +-- - separator insert separator after code block +--@return width,height size of float +function M.fancy_floating_markdown(contents, opts) + validate { + contents = { contents, 't' }; + opts = { opts, 't', true }; + } + opts = opts or {} + + local stripped = {} + local highlights = {} + do + local i = 1 + while i <= #contents do + local line = contents[i] + -- TODO(ashkan): use a more strict regex for filetype? + local ft = line:match("^```([a-zA-Z0-9_]*)$") + -- local ft = line:match("^```(.*)$") + -- TODO(ashkan): validate the filetype here. + if ft then + local start = #stripped + i = i + 1 + while i <= #contents do + line = contents[i] + if line == "```" then + i = i + 1 + break + end + table.insert(stripped, line) + i = i + 1 + end + table.insert(highlights, { + ft = ft; + start = start + 1; + finish = #stripped + 1 - 1; + }) + else + table.insert(stripped, line) + i = i + 1 + end + end + end + -- Clean up and add padding + stripped = M._trim_and_pad(stripped, opts) + + -- Compute size of float needed to show (wrapped) lines + opts.wrap_at = opts.wrap_at or (vim.wo["wrap"] and api.nvim_win_get_width(0)) + local width, height = M._make_floating_popup_size(stripped, opts) + + -- Insert blank line separator after code block + local insert_separator = opts.separator or true + if insert_separator then + for i, h in ipairs(highlights) do + h.start = h.start + i - 1 + h.finish = h.finish + i - 1 + if h.finish + 1 <= #stripped then + table.insert(stripped, h.finish + 1, string.rep("─", width)) + height = height + 1 + end + end + end + + -- Make the floating window. + local bufnr = api.nvim_create_buf(false, true) + local winnr = api.nvim_open_win(bufnr, false, M.make_floating_popup_options(width, height, opts)) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, stripped) + api.nvim_buf_set_option(bufnr, 'modifiable', false) + + -- Switch to the floating window to apply the syntax highlighting. + -- This is because the syntax command doesn't accept a target. + local cwin = vim.api.nvim_get_current_win() + vim.api.nvim_set_current_win(winnr) + + vim.cmd("ownsyntax markdown") + local idx = 1 + local function apply_syntax_to_region(ft, start, finish) + if ft == '' then return end + local name = ft..idx + idx = idx + 1 + local lang = "@"..ft:upper() + -- TODO(ashkan): better validation before this. + if not pcall(vim.cmd, string.format("syntax include %s syntax/%s.vim", lang, ft)) then + return + end + vim.cmd(string.format("syntax region %s start=+\\%%%dl+ end=+\\%%%dl+ contains=%s", name, start, finish + 1, lang)) + end + -- Previous highlight region. + -- TODO(ashkan): this wasn't working for some reason, but I would like to + -- make sure that regions between code blocks are definitely markdown. + -- local ph = {start = 0; finish = 1;} + for _, h in ipairs(highlights) do + -- apply_syntax_to_region('markdown', ph.finish, h.start) + apply_syntax_to_region(h.ft, h.start, h.finish) + -- ph = h + end + + vim.api.nvim_set_current_win(cwin) + return bufnr, winnr +end + +function M.close_preview_autocmd(events, winnr) + api.nvim_command("autocmd "..table.concat(events, ',').." <buffer> ++once lua pcall(vim.api.nvim_win_close, "..winnr..", true)") +end + +--- Compute size of float needed to show contents (with optional wrapping) +--- +--@param contents table of lines to show in window +--@param opts dictionary with optional fields +-- - height of floating window +-- - width of floating window +-- - wrap_at character to wrap at for computing height +-- - max_width maximal width of floating window +-- - max_height maximal height of floating window +--@return width,height size of float +function M._make_floating_popup_size(contents, opts) + validate { + contents = { contents, 't' }; + opts = { opts, 't', true }; + } + opts = opts or {} + + local width = opts.width + local height = opts.height + local wrap_at = opts.wrap_at + local max_width = opts.max_width + local max_height = opts.max_height + local line_widths = {} + + if not width then + width = 0 + for i, line in ipairs(contents) do + -- TODO(ashkan) use nvim_strdisplaywidth if/when that is introduced. + line_widths[i] = vim.fn.strdisplaywidth(line) + width = math.max(line_widths[i], width) + end + end + if max_width then + width = math.min(width, max_width) + wrap_at = math.min(wrap_at or max_width, max_width) + end + + if not height then + height = #contents + if wrap_at and width >= wrap_at then + height = 0 + if vim.tbl_isempty(line_widths) then + for _, line in ipairs(contents) do + local line_width = vim.fn.strdisplaywidth(line) + height = height + math.ceil(line_width/wrap_at) + end + else + for i = 1, #contents do + height = height + math.max(1, math.ceil(line_widths[i]/wrap_at)) + end + end + end + end + if max_height then + height = math.min(height, max_height) + end + + return width, height +end + +--- Show contents in a floating window +--- +--@param contents table of lines to show in window +--@param filetype string of filetype to set for opened buffer +--@param opts dictionary with optional fields +-- - height of floating window +-- - width of floating window +-- - wrap_at character to wrap at for computing height +-- - max_width maximal width of floating window +-- - max_height maximal height of floating window +-- - pad_left number of columns to pad contents at left +-- - pad_right number of columns to pad contents at right +-- - pad_top number of lines to pad contents at top +-- - pad_bottom number of lines to pad contents at bottom +--@return bufnr,winnr buffer and window number of floating window or nil +function M.open_floating_preview(contents, filetype, opts) + validate { + contents = { contents, 't' }; + filetype = { filetype, 's', true }; + opts = { opts, 't', true }; + } + opts = opts or {} + + -- Clean up input: trim empty lines from the end, pad + contents = M._trim_and_pad(contents, opts) + + -- Compute size of float needed to show (wrapped) lines + opts.wrap_at = opts.wrap_at or (vim.wo["wrap"] and api.nvim_win_get_width(0)) + local width, height = M._make_floating_popup_size(contents, opts) + + 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) + M.close_preview_autocmd({"CursorMoved", "CursorMovedI", "BufHidden", "BufLeave"}, floating_winnr) + return floating_bufnr, floating_winnr +end + +do + local diagnostic_ns = api.nvim_create_namespace("vim_lsp_diagnostics") + local reference_ns = api.nvim_create_namespace("vim_lsp_references") + local sign_ns = 'vim_lsp_signs' + local underline_highlight_name = "LspDiagnosticsUnderline" + vim.cmd(string.format("highlight default %s gui=underline cterm=underline", underline_highlight_name)) + for kind, _ in pairs(protocol.DiagnosticSeverity) do + if type(kind) == 'string' then + vim.cmd(string.format("highlight default link %s%s %s", underline_highlight_name, kind, underline_highlight_name)) + end + end + + local severity_highlights = {} + + local severity_floating_highlights = {} + + local default_severity_highlight = { + [protocol.DiagnosticSeverity.Error] = { guifg = "Red" }; + [protocol.DiagnosticSeverity.Warning] = { guifg = "Orange" }; + [protocol.DiagnosticSeverity.Information] = { guifg = "LightBlue" }; + [protocol.DiagnosticSeverity.Hint] = { guifg = "LightGrey" }; + } + + -- 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 + local floating_highlight_name = highlight_name.."Floating" + -- 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, ' ')) + api.nvim_command('highlight link ' .. highlight_name .. 'Sign ' .. highlight_name) + api.nvim_command('highlight link ' .. highlight_name .. 'Floating ' .. highlight_name) + severity_highlights[severity] = highlight_name + severity_floating_highlights[severity] = floating_highlight_name + end + + function M.buf_clear_diagnostics(bufnr) + validate { bufnr = {bufnr, 'n', true} } + bufnr = bufnr == 0 and api.nvim_get_current_buf() or bufnr + + -- clear sign group + vim.fn.sign_unplace(sign_ns, {buffer=bufnr}) + + -- clear virtual text namespace + api.nvim_buf_clear_namespace(bufnr, diagnostic_ns, 0, -1) + end + + function M.get_severity_highlight_name(severity) + return severity_highlights[severity] + end + + function M.get_line_diagnostics() + local bufnr = api.nvim_get_current_buf() + local linenr = api.nvim_win_get_cursor(0)[1] - 1 + + local buffer_diagnostics = M.diagnostics_by_buf[bufnr] + + if not buffer_diagnostics then + return {} + end + + local diagnostics_by_line = M.diagnostics_group_by_line(buffer_diagnostics) + return diagnostics_by_line[linenr] or {} + end + + function M.show_line_diagnostics() + -- local marks = api.nvim_buf_get_extmarks(bufnr, diagnostic_ns, {line, 0}, {line, -1}, {}) + -- if #marks == 0 then + -- return + -- end + local lines = {"Diagnostics:"} + local highlights = {{0, "Bold"}} + local line_diagnostics = M.get_line_diagnostics() + if vim.tbl_isempty(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_floating_highlights[diagnostic.severity] + assert(hiname, 'unknown severity: ' .. tostring(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 + + --- Saves the diagnostics (Diagnostic[]) into diagnostics_by_buf + --- + --@param bufnr bufnr for which the diagnostics are for. + --@param diagnostics Diagnostics[] received from the language server. + 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 M.diagnostics_by_buf[bufnr] then + -- Clean up our data when the buffer unloads. + api.nvim_buf_attach(bufnr, false, { + on_detach = function(b) + M.diagnostics_by_buf[b] = nil + end + }) + end + M.diagnostics_by_buf[bufnr] = diagnostics + end + + function M.buf_diagnostics_underline(bufnr, diagnostics) + for _, diagnostic in ipairs(diagnostics) do + local start = diagnostic.range["start"] + local finish = diagnostic.range["end"] + + local hlmap = { + [protocol.DiagnosticSeverity.Error]='Error', + [protocol.DiagnosticSeverity.Warning]='Warning', + [protocol.DiagnosticSeverity.Information]='Information', + [protocol.DiagnosticSeverity.Hint]='Hint', + } + + highlight.range(bufnr, diagnostic_ns, + underline_highlight_name..hlmap[diagnostic.severity], + {start.line, start.character}, + {finish.line, finish.character} + ) + end + end + + function M.buf_clear_references(bufnr) + validate { bufnr = {bufnr, 'n', true} } + api.nvim_buf_clear_namespace(bufnr, reference_ns, 0, -1) + end + + function M.buf_highlight_references(bufnr, references) + validate { bufnr = {bufnr, 'n', true} } + for _, reference in ipairs(references) do + local start_pos = {reference["range"]["start"]["line"], reference["range"]["start"]["character"]} + local end_pos = {reference["range"]["end"]["line"], reference["range"]["end"]["character"]} + local document_highlight_kind = { + [protocol.DocumentHighlightKind.Text] = "LspReferenceText"; + [protocol.DocumentHighlightKind.Read] = "LspReferenceRead"; + [protocol.DocumentHighlightKind.Write] = "LspReferenceWrite"; + } + local kind = reference["kind"] or protocol.DocumentHighlightKind.Text + highlight.range(bufnr, reference_ns, document_highlight_kind[kind], start_pos, end_pos) + end + end + + function M.diagnostics_group_by_line(diagnostics) + if not diagnostics then return end + local diagnostics_by_line = {} + for _, diagnostic in ipairs(diagnostics) do + local start = diagnostic.range.start + local line_diagnostics = diagnostics_by_line[start.line] + if not line_diagnostics then + line_diagnostics = {} + diagnostics_by_line[start.line] = line_diagnostics + end + table.insert(line_diagnostics, diagnostic) + end + return diagnostics_by_line + end + + function M.buf_diagnostics_virtual_text(bufnr, diagnostics) + if not diagnostics then + return + end + local buffer_line_diagnostics = M.diagnostics_group_by_line(diagnostics) + 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 + + --- Returns the number of diagnostics of given kind for current buffer. + --- + --- Useful for showing diagnostic counts in statusline. eg: + --- + --- <pre> + --- function! LspStatus() abort + --- let sl = '' + --- if luaeval('not vim.tbl_isempty(vim.lsp.buf_get_clients(0))') + --- let sl.='%#MyStatuslineLSP#E:' + --- let sl.='%#MyStatuslineLSPErrors#%{luaeval("vim.lsp.util.buf_diagnostics_count([[Error]])")}' + --- let sl.='%#MyStatuslineLSP# W:' + --- let sl.='%#MyStatuslineLSPWarnings#%{luaeval("vim.lsp.util.buf_diagnostics_count([[Warning]])")}' + --- else + --- let sl.='%#MyStatuslineLSPErrors#off' + --- endif + --- return sl + --- endfunction + --- let &l:statusline = '%#MyStatuslineLSP#LSP '.LspStatus() + --- </pre> + --- + --@param kind Diagnostic severity kind: See |vim.lsp.protocol.DiagnosticSeverity| + --- + --@return Count of diagnostics + function M.buf_diagnostics_count(kind) + local bufnr = vim.api.nvim_get_current_buf() + local diagnostics = M.diagnostics_by_buf[bufnr] + if not diagnostics then return end + local count = 0 + for _, diagnostic in pairs(diagnostics) do + if protocol.DiagnosticSeverity[kind] == diagnostic.severity then + count = count + 1 + end + end + return count + end + + local diagnostic_severity_map = { + [protocol.DiagnosticSeverity.Error] = "LspDiagnosticsErrorSign"; + [protocol.DiagnosticSeverity.Warning] = "LspDiagnosticsWarningSign"; + [protocol.DiagnosticSeverity.Information] = "LspDiagnosticsInformationSign"; + [protocol.DiagnosticSeverity.Hint] = "LspDiagnosticsHintSign"; + } + + --- Place signs for each diagnostic in the sign column. + --- + --- Sign characters can be customized with the following commands: + --- + --- <pre> + --- sign define LspDiagnosticsErrorSign text=E texthl=LspDiagnosticsError linehl= numhl= + --- sign define LspDiagnosticsWarningSign text=W texthl=LspDiagnosticsWarning linehl= numhl= + --- sign define LspDiagnosticsInformationSign text=I texthl=LspDiagnosticsInformation linehl= numhl= + --- sign define LspDiagnosticsHintSign text=H texthl=LspDiagnosticsHint linehl= numhl= + --- </pre> + function M.buf_diagnostics_signs(bufnr, diagnostics) + for _, diagnostic in ipairs(diagnostics) do + vim.fn.sign_place(0, sign_ns, diagnostic_severity_map[diagnostic.severity], bufnr, {lnum=(diagnostic.range.start.line+1)}) + end + end +end + +local position_sort = sort_by_key(function(v) + return {v.start.line, v.start.character} +end) + +-- Returns the items with the byte position calculated correctly and in sorted +-- order. +function M.locations_to_items(locations) + local items = {} + local grouped = setmetatable({}, { + __index = function(t, k) + local v = {} + rawset(t, k, v) + return v + end; + }) + for _, d in ipairs(locations) do + -- locations may be Location or LocationLink + local uri = d.uri or d.targetUri + local range = d.range or d.targetSelectionRange + table.insert(grouped[uri], {start = range.start}) + end + + + local keys = vim.tbl_keys(grouped) + table.sort(keys) + -- TODO(ashkan) I wish we could do this lazily. + for _, uri in ipairs(keys) do + local rows = grouped[uri] + table.sort(rows, position_sort) + local bufnr = vim.uri_to_bufnr(uri) + vim.fn.bufload(bufnr) + local filename = vim.uri_to_fname(uri) + for _, temp in ipairs(rows) do + local pos = temp.start + local row = pos.line + local line = (api.nvim_buf_get_lines(bufnr, row, row + 1, false) or {""})[1] + local col = M.character_offset(bufnr, row, pos.character) + table.insert(items, { + filename = filename, + lnum = row + 1, + col = col + 1; + text = line; + }) + end + end + return items +end + +function M.set_loclist(items) + vim.fn.setloclist(0, {}, ' ', { + title = 'Language Server'; + items = items; + }) +end + +function M.set_qflist(items) + vim.fn.setqflist({}, ' ', { + title = 'Language Server'; + items = items; + }) +end + +-- Acording to LSP spec, if the client set "symbolKind.valueSet", +-- the client must handle it properly even if it receives a value outside the specification. +-- https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_documentSymbol +function M._get_symbol_kind_name(symbol_kind) + return protocol.SymbolKind[symbol_kind] or "Unknown" +end + +--- Convert symbols to quickfix list items +--- +--@param symbols DocumentSymbol[] or SymbolInformation[] +function M.symbols_to_items(symbols, bufnr) + local function _symbols_to_items(_symbols, _items, _bufnr) + for _, symbol in ipairs(_symbols) do + if symbol.location then -- SymbolInformation type + local range = symbol.location.range + local kind = M._get_symbol_kind_name(symbol.kind) + table.insert(_items, { + filename = vim.uri_to_fname(symbol.location.uri), + lnum = range.start.line + 1, + col = range.start.character + 1, + kind = kind, + text = '['..kind..'] '..symbol.name, + }) + elseif symbol.range then -- DocumentSymbole type + local kind = M._get_symbol_kind_name(symbol.kind) + table.insert(_items, { + -- bufnr = _bufnr, + filename = vim.api.nvim_buf_get_name(_bufnr), + lnum = symbol.range.start.line + 1, + col = symbol.range.start.character + 1, + kind = kind, + text = '['..kind..'] '..symbol.name + }) + if symbol.children then + for _, v in ipairs(_symbols_to_items(symbol.children, _items, _bufnr)) do + vim.list_extend(_items, v) + end + end + end + end + return _items + end + return _symbols_to_items(symbols, {}, bufnr) +end + +-- Remove empty lines from the beginning and end. +function M.trim_empty_lines(lines) + 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 + return vim.list_extend({}, lines, start, finish) +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 + +local str_utfindex = vim.str_utfindex +local function make_position_param() + 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 = str_utfindex(line, col) + return { line = row; character = col; } +end + +function M.make_position_params() + return { + textDocument = M.make_text_document_params(); + position = make_position_param() + } +end + +function M.make_range_params() + local position = make_position_param() + return { + textDocument = { uri = vim.uri_from_bufnr(0) }, + range = { start = position; ["end"] = position; } + } +end + +function M.make_text_document_params() + return { uri = vim.uri_from_bufnr(0) } +end + +--- Get visual width of tabstop. +--- +--@see |softtabstop| +--@param bufnr (optional, number): Buffer handle, defaults to current +--@returns (number) tabstop visual width +function M.get_effective_tabstop(bufnr) + validate { bufnr = {bufnr, 'n', true} } + local bo = bufnr and vim.bo[bufnr] or vim.bo + local sts = bo.softtabstop + return (sts > 0 and sts) or (sts < 0 and bo.shiftwidth) or bo.tabstop +end + +function M.make_formatting_params(options) + validate { options = {options, 't', true} } + options = vim.tbl_extend('keep', options or {}, { + tabSize = M.get_effective_tabstop(); + insertSpaces = vim.bo.expandtab; + }) + return { + textDocument = { uri = vim.uri_from_bufnr(0) }; + options = options; + } +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] + -- If the col is past the EOL, use the line length. + if col > #line then + return str_utfindex(line) + end + return str_utfindex(line, col) +end + +M.buf_versions = {} + +return M +-- vim:sw=2 ts=2 et diff --git a/runtime/lua/vim/shared.lua b/runtime/lua/vim/shared.lua index cd6f8a04d8..6e427665f2 100644 --- a/runtime/lua/vim/shared.lua +++ b/runtime/lua/vim/shared.lua @@ -4,34 +4,51 @@ -- test-suite. If, in the future, Nvim itself is used to run the test-suite -- instead of "vanilla Lua", these functions could move to src/nvim/lua/vim.lua +local vim = vim or {} --- Returns a deep copy of the given object. Non-table objects are copied as --- in a typical Lua assignment, whereas table objects are copied recursively. +--- Functions are naively copied, so functions in the copied table point to the +--- same functions as those in the input table. Userdata and threads are not +--- copied and will throw an error. --- --@param orig Table to copy --@returns New table of copied keys and (nested) values. -local function deepcopy(orig) - error(orig) -end -local function _id(v) - return v -end -local deepcopy_funcs = { - table = function(orig) - local copy = {} - for k, v in pairs(orig) do - copy[deepcopy(k)] = deepcopy(v) +function vim.deepcopy(orig) end -- luacheck: no unused +vim.deepcopy = (function() + local function _id(v) + return v + end + + local deepcopy_funcs = { + table = function(orig) + local copy = {} + + if vim._empty_dict_mt ~= nil and getmetatable(orig) == vim._empty_dict_mt then + copy = vim.empty_dict() + end + + for k, v in pairs(orig) do + copy[vim.deepcopy(k)] = vim.deepcopy(v) + end + return copy + end, + number = _id, + string = _id, + ['nil'] = _id, + boolean = _id, + ['function'] = _id, + } + + return function(orig) + local f = deepcopy_funcs[type(orig)] + if f then + return f(orig) + else + error("Cannot deepcopy object of type "..type(orig)) end - return copy - end, - number = _id, - string = _id, - ['nil'] = _id, - boolean = _id, -} -deepcopy = function(orig) - return deepcopy_funcs[type(orig)](orig) -end + end +end)() --- Splits a string at each instance of a separator. --- @@ -43,10 +60,8 @@ end --@param sep Separator string or pattern --@param plain If `true` use `sep` literally (passed to String.find) --@returns Iterator over the split components -local function gsplit(s, sep, plain) - assert(type(s) == "string") - assert(type(sep) == "string") - assert(type(plain) == "boolean" or type(plain) == "nil") +function vim.gsplit(s, sep, plain) + vim.validate{s={s,'s'},sep={sep,'s'},plain={plain,'b',true}} local start = 1 local done = false @@ -64,7 +79,7 @@ local function gsplit(s, sep, plain) end return function() - if done then + if done or (s == '' and sep == '') then return end if sep == '' then @@ -92,20 +107,81 @@ end --@param sep Separator string or pattern --@param plain If `true` use `sep` literally (passed to String.find) --@returns List-like table of the split components. -local function split(s,sep,plain) - local t={} for c in gsplit(s, sep, plain) do table.insert(t,c) end +function vim.split(s,sep,plain) + local t={} for c in vim.gsplit(s, sep, plain) do table.insert(t,c) end return t end +--- Return a list of all keys used in a table. +--- However, the order of the return table of keys is not guaranteed. +--- +--@see From https://github.com/premake/premake-core/blob/master/src/base/table.lua +--- +--@param t Table +--@returns list of keys +function vim.tbl_keys(t) + assert(type(t) == 'table', string.format("Expected table, got %s", type(t))) + + local keys = {} + for k, _ in pairs(t) do + table.insert(keys, k) + end + return keys +end + +--- Return a list of all values used in a table. +--- However, the order of the return table of values is not guaranteed. +--- +--@param t Table +--@returns list of values +function vim.tbl_values(t) + assert(type(t) == 'table', string.format("Expected table, got %s", type(t))) + + local values = {} + for _, v in pairs(t) do + table.insert(values, v) + end + return values +end + +--- Apply a function to all values of a table. +--- +--@param func function or callable table +--@param t table +function vim.tbl_map(func, t) + vim.validate{func={func,'c'},t={t,'t'}} + + local rettab = {} + for k, v in pairs(t) do + rettab[k] = func(v) + end + return rettab +end + +--- Filter a table using a predicate function +--- +--@param func function or callable table +--@param t table +function vim.tbl_filter(func, t) + vim.validate{func={func,'c'},t={t,'t'}} + + local rettab = {} + for _, entry in pairs(t) do + if func(entry) then + table.insert(rettab, entry) + end + end + return rettab +end + --- Checks if a list-like (vector) table contains `value`. --- --@param t Table to check --@param value Value to compare --@returns true if `t` contains `value` -local function tbl_contains(t, value) - if type(t) ~= 'table' then - error('t must be a table') - end +function vim.tbl_contains(t, value) + vim.validate{t={t,'t'}} + for _,v in ipairs(t) do if v == value then return true @@ -114,25 +190,38 @@ local function tbl_contains(t, value) return false end ---- Merges two or more map-like tables. ---- ---@see |extend()| ---- ---@param behavior Decides what to do if a key is found in more than one map: ---- - "error": raise an error ---- - "keep": use value from the leftmost map ---- - "force": use value from the rightmost map ---@param ... Two or more map-like tables. -local function tbl_extend(behavior, ...) +-- Returns true if the table is empty, and contains no indexed or keyed values. +-- +--@see From https://github.com/premake/premake-core/blob/master/src/base/table.lua +-- +--@param t Table to check +function vim.tbl_isempty(t) + assert(type(t) == 'table', string.format("Expected table, got %s", type(t))) + return next(t) == nil +end + +local function tbl_extend(behavior, deep_extend, ...) if (behavior ~= 'error' and behavior ~= 'keep' and behavior ~= 'force') then error('invalid "behavior": '..tostring(behavior)) end + + if select('#', ...) < 2 then + error('wrong number of arguments (given '..tostring(1 + select('#', ...))..', expected at least 3)') + end + local ret = {} + if vim._empty_dict_mt ~= nil and getmetatable(select(1, ...)) == vim._empty_dict_mt then + ret = vim.empty_dict() + end + for i = 1, select('#', ...) do local tbl = select(i, ...) + vim.validate{["after the second argument"] = {tbl,'t'}} if tbl then for k, v in pairs(tbl) do - if behavior ~= 'force' and ret[k] ~= nil then + if type(v) == 'table' and deep_extend and not vim.tbl_islist(v) then + ret[k] = tbl_extend(behavior, true, ret[k] or vim.empty_dict(), v) + elseif behavior ~= 'force' and ret[k] ~= nil then if behavior == 'error' then error('key found in more than one map: '..k) end -- Else behavior is "keep". @@ -145,13 +234,103 @@ local function tbl_extend(behavior, ...) return ret end +--- Merges two or more map-like tables. +--- +--@see |extend()| +--- +--@param behavior Decides what to do if a key is found in more than one map: +--- - "error": raise an error +--- - "keep": use value from the leftmost map +--- - "force": use value from the rightmost map +--@param ... Two or more map-like tables. +function vim.tbl_extend(behavior, ...) + return tbl_extend(behavior, false, ...) +end + +--- Merges recursively two or more map-like tables. +--- +--@see |tbl_extend()| +--- +--@param behavior Decides what to do if a key is found in more than one map: +--- - "error": raise an error +--- - "keep": use value from the leftmost map +--- - "force": use value from the rightmost map +--@param ... Two or more map-like tables. +function vim.tbl_deep_extend(behavior, ...) + return tbl_extend(behavior, true, ...) +end + +--- Deep compare values for equality +function vim.deep_equal(a, b) + if a == b then return true end + if type(a) ~= type(b) then return false end + if type(a) == 'table' then + -- TODO improve this algorithm's performance. + for k, v in pairs(a) do + if not vim.deep_equal(v, b[k]) then + return false + end + end + for k, v in pairs(b) do + if not vim.deep_equal(v, a[k]) then + return false + end + end + return true + end + return false +end + +--- Add the reverse lookup values to an existing table. +--- For example: +--- `tbl_add_reverse_lookup { A = 1 } == { [1] = 'A', A = 1 }` +-- +--Do note that it *modifies* the input. +--@param o table The table to add the reverse to. +function vim.tbl_add_reverse_lookup(o) + local keys = vim.tbl_keys(o) + for _, k in ipairs(keys) do + local v = o[k] + if o[v] then + error(string.format("The reverse lookup found an existing value for %q while processing key %q", tostring(v), tostring(k))) + end + o[v] = k + end + return o +end + +--- Extends a list-like table with the values of another list-like table. +--- +--- NOTE: This mutates dst! +--- +--@see |vim.tbl_extend()| +--- +--@param dst list which will be modified and appended to. +--@param src list from which values will be inserted. +--@param start Start index on src. defaults to 1 +--@param finish Final index on src. defaults to #src +--@returns dst +function vim.list_extend(dst, src, start, finish) + vim.validate { + dst = {dst, 't'}; + src = {src, 't'}; + start = {start, 'n', true}; + finish = {finish, 'n', true}; + } + for i = start or 1, finish or #src do + table.insert(dst, src[i]) + end + return dst +end + --- Creates a copy of a list-like table such that any nested tables are --- "unrolled" and appended to the result. --- +--@see From https://github.com/premake/premake-core/blob/master/src/base/table.lua +--- --@param t List-like table --@returns Flattened copy of the given list-like table. -local function tbl_flatten(t) - -- From https://github.com/premake/premake-core/blob/master/src/base/table.lua +function vim.tbl_flatten(t) local result = {} local function _tbl_flatten(_t) local n = #_t @@ -168,34 +347,190 @@ local function tbl_flatten(t) return result end +--- Determine whether a Lua table can be treated as an array. +--- +--- An empty table `{}` will default to being treated as an array. +--- Use `vim.emtpy_dict()` to create a table treated as an +--- empty dict. Empty tables returned by `rpcrequest()` and +--- `vim.fn` functions can be checked using this function +--- whether they represent empty API arrays and vimL lists. +--- +--@param t Table +--@returns `true` if array-like table, else `false`. +function vim.tbl_islist(t) + if type(t) ~= 'table' then + return false + end + + local count = 0 + + for k, _ in pairs(t) do + if type(k) == "number" then + count = count + 1 + else + return false + end + end + + if count > 0 then + return true + else + -- TODO(bfredl): in the future, we will always be inside nvim + -- then this check can be deleted. + if vim._empty_dict_mt == nil then + return nil + end + return getmetatable(t) ~= vim._empty_dict_mt + end +end + +--- Counts the number of non-nil values in table `t`. +--- +--- <pre> +--- vim.tbl_count({ a=1, b=2 }) => 2 +--- vim.tbl_count({ 1, 2 }) => 2 +--- </pre> +--- +--@see https://github.com/Tieske/Penlight/blob/master/lua/pl/tablex.lua +--@param t Table +--@returns Number that is the number of the value in table +function vim.tbl_count(t) + vim.validate{t={t,'t'}} + + local count = 0 + for _ in pairs(t) do count = count + 1 end + return count +end + --- Trim whitespace (Lua pattern "%s") from both sides of a string. --- --@see https://www.lua.org/pil/20.2.html --@param s String to trim --@returns String with whitespace removed from its beginning and end -local function trim(s) - assert(type(s) == 'string', 'Only strings can be trimmed') +function vim.trim(s) + vim.validate{s={s,'s'}} return s:match('^%s*(.*%S)') or '' end ---- Escapes magic chars in a Lua pattern string. +--- Escapes magic chars in a Lua pattern. --- --@see https://github.com/rxi/lume --@param s String to escape --@returns %-escaped pattern string -local function pesc(s) - assert(type(s) == 'string') +function vim.pesc(s) + vim.validate{s={s,'s'}} return s:gsub('[%(%)%.%%%+%-%*%?%[%]%^%$]', '%%%1') end -local module = { - deepcopy = deepcopy, - gsplit = gsplit, - pesc = pesc, - split = split, - tbl_contains = tbl_contains, - tbl_extend = tbl_extend, - tbl_flatten = tbl_flatten, - trim = trim, -} -return module +--- Tests if `s` starts with `prefix`. +--- +--@param s (string) a string +--@param prefix (string) a prefix +--@return (boolean) true if `prefix` is a prefix of s +function vim.startswith(s, prefix) + vim.validate { s = {s, 's'}; prefix = {prefix, 's'}; } + return s:sub(1, #prefix) == prefix +end + +--- Tests if `s` ends with `suffix`. +--- +--@param s (string) a string +--@param suffix (string) a suffix +--@return (boolean) true if `suffix` is a suffix of s +function vim.endswith(s, suffix) + vim.validate { s = {s, 's'}; suffix = {suffix, 's'}; } + return #suffix == 0 or s:sub(-#suffix) == suffix +end + +--- Validates a parameter specification (types and values). +--- +--- Usage example: +--- <pre> +--- function user.new(name, age, hobbies) +--- vim.validate{ +--- name={name, 'string'}, +--- age={age, 'number'}, +--- hobbies={hobbies, 'table'}, +--- } +--- ... +--- end +--- </pre> +--- +--- Examples with explicit argument values (can be run directly): +--- <pre> +--- vim.validate{arg1={{'foo'}, 'table'}, arg2={'foo', 'string'}} +--- => NOP (success) +--- +--- vim.validate{arg1={1, 'table'}} +--- => error('arg1: expected table, got number') +--- +--- vim.validate{arg1={3, function(a) return (a % 2) == 0 end, 'even number'}} +--- => error('arg1: expected even number, got 3') +--- </pre> +--- +--@param opt Map of parameter names to validations. Each key is a parameter +--- name; each value is a tuple in one of these forms: +--- 1. (arg_value, type_name, optional) +--- - arg_value: argument value +--- - type_name: string type name, one of: ("table", "t", "string", +--- "s", "number", "n", "boolean", "b", "function", "f", "nil", +--- "thread", "userdata") +--- - optional: (optional) boolean, if true, `nil` is valid +--- 2. (arg_value, fn, msg) +--- - arg_value: argument value +--- - fn: any function accepting one argument, returns true if and +--- only if the argument is valid +--- - msg: (optional) error string if validation fails +function vim.validate(opt) end -- luacheck: no unused +vim.validate = (function() + local type_names = { + t='table', s='string', n='number', b='boolean', f='function', c='callable', + ['table']='table', ['string']='string', ['number']='number', + ['boolean']='boolean', ['function']='function', ['callable']='callable', + ['nil']='nil', ['thread']='thread', ['userdata']='userdata', + } + local function _type_name(t) + local tname = type_names[t] + if tname == nil then + error(string.format('invalid type name: %s', tostring(t))) + end + return tname + end + local function _is_type(val, t) + return t == 'callable' and vim.is_callable(val) or type(val) == t + end + + return function(opt) + assert(type(opt) == 'table', string.format('opt: expected table, got %s', type(opt))) + for param_name, spec in pairs(opt) do + assert(type(spec) == 'table', string.format('%s: expected table, got %s', param_name, type(spec))) + + local val = spec[1] -- Argument value. + local t = spec[2] -- Type name, or callable. + local optional = (true == spec[3]) + + if not vim.is_callable(t) then -- Check type name. + if (not optional or val ~= nil) and not _is_type(val, _type_name(t)) then + error(string.format("%s: expected %s, got %s", param_name, _type_name(t), type(val))) + end + elseif not t(val) then -- Check user-provided validation function. + error(string.format("%s: expected %s, got %s", param_name, (spec[3] or '?'), val)) + end + end + return true + end +end)() + +--- Returns true if object `f` can be called as a function. +--- +--@param f Any object +--@return true if `f` is callable, else false +function vim.is_callable(f) + if type(f) == 'function' then return true end + local m = getmetatable(f) + if m == nil then return false end + return type(m.__call) == 'function' +end + +return vim +-- vim:sw=2 ts=2 et diff --git a/runtime/lua/vim/treesitter.lua b/runtime/lua/vim/treesitter.lua new file mode 100644 index 0000000000..927456708c --- /dev/null +++ b/runtime/lua/vim/treesitter.lua @@ -0,0 +1,260 @@ +local a = vim.api + +-- TODO(bfredl): currently we retain parsers for the lifetime of the buffer. +-- Consider use weak references to release parser if all plugins are done with +-- it. +local parsers = {} + +local Parser = {} +Parser.__index = Parser + +function Parser:parse() + if self.valid then + return self.tree + end + local changes + self.tree, changes = self._parser:parse_buf(self.bufnr) + self.valid = true + + if not vim.tbl_isempty(changes) then + for _, cb in ipairs(self.changedtree_cbs) do + cb(changes) + end + end + + return self.tree, changes +end + +function Parser:_on_lines(bufnr, changed_tick, start_row, old_stop_row, stop_row, old_byte_size) + local start_byte = a.nvim_buf_get_offset(bufnr,start_row) + local stop_byte = a.nvim_buf_get_offset(bufnr,stop_row) + local old_stop_byte = start_byte + old_byte_size + self._parser:edit(start_byte,old_stop_byte,stop_byte, + start_row,0,old_stop_row,0,stop_row,0) + self.valid = false + + for _, cb in ipairs(self.lines_cbs) do + cb(bufnr, changed_tick, start_row, old_stop_row, stop_row, old_byte_size) + end +end + +function Parser:set_included_ranges(ranges) + self._parser:set_included_ranges(ranges) + -- The buffer will need to be parsed again later + self.valid = false +end + +local M = { + parse_query = vim._ts_parse_query, +} + +setmetatable(M, { + __index = function (t, k) + if k == "TSHighlighter" then + t[k] = require'vim.tshighlighter' + return t[k] + end + end + }) + +function M.require_language(lang, path) + if vim._ts_has_language(lang) then + return true + end + if path == nil then + local fname = 'parser/' .. lang .. '.*' + local paths = a.nvim_get_runtime_file(fname, false) + if #paths == 0 then + -- TODO(bfredl): help tag? + error("no parser for '"..lang.."' language") + end + path = paths[1] + end + vim._ts_add_language(path, lang) +end + +function M.inspect_language(lang) + M.require_language(lang) + return vim._ts_inspect_language(lang) +end + +function M.create_parser(bufnr, lang, id) + M.require_language(lang) + if bufnr == 0 then + bufnr = a.nvim_get_current_buf() + end + + vim.fn.bufload(bufnr) + + local self = setmetatable({bufnr=bufnr, lang=lang, valid=false}, Parser) + self._parser = vim._create_ts_parser(lang) + self.changedtree_cbs = {} + self.lines_cbs = {} + self:parse() + -- TODO(bfredl): use weakref to self, so that the parser is free'd is no plugin is + -- using it. + local function lines_cb(_, ...) + return self:_on_lines(...) + end + local detach_cb = nil + if id ~= nil then + detach_cb = function() + if parsers[id] == self then + parsers[id] = nil + end + end + end + a.nvim_buf_attach(self.bufnr, false, {on_lines=lines_cb, on_detach=detach_cb}) + return self +end + +function M.get_parser(bufnr, ft, buf_attach_cbs) + if bufnr == nil or bufnr == 0 then + bufnr = a.nvim_get_current_buf() + end + if ft == nil then + ft = a.nvim_buf_get_option(bufnr, "filetype") + end + local id = tostring(bufnr)..'_'..ft + + if parsers[id] == nil then + parsers[id] = M.create_parser(bufnr, ft, id) + end + + if buf_attach_cbs and buf_attach_cbs.on_changedtree then + table.insert(parsers[id].changedtree_cbs, buf_attach_cbs.on_changedtree) + end + + if buf_attach_cbs and buf_attach_cbs.on_lines then + table.insert(parsers[id].lines_cbs, buf_attach_cbs.on_lines) + end + + return parsers[id] +end + +-- query: pattern matching on trees +-- predicate matching is implemented in lua +local Query = {} +Query.__index = Query + +local magic_prefixes = {['\\v']=true, ['\\m']=true, ['\\M']=true, ['\\V']=true} +local function check_magic(str) + if string.len(str) < 2 or magic_prefixes[string.sub(str,1,2)] then + return str + end + return '\\v'..str +end + +function M.parse_query(lang, query) + M.require_language(lang) + local self = setmetatable({}, Query) + self.query = vim._ts_parse_query(lang, vim.fn.escape(query,'\\')) + self.info = self.query:inspect() + self.captures = self.info.captures + self.regexes = {} + for id,preds in pairs(self.info.patterns) do + local regexes = {} + for i, pred in ipairs(preds) do + if (pred[1] == "match?" and type(pred[2]) == "number" + and type(pred[3]) == "string") then + regexes[i] = vim.regex(check_magic(pred[3])) + end + end + if next(regexes) then + self.regexes[id] = regexes + end + end + return self +end + +local function get_node_text(node, bufnr) + local start_row, start_col, end_row, end_col = node:range() + if start_row ~= end_row then + return nil + end + local line = a.nvim_buf_get_lines(bufnr, start_row, start_row+1, true)[1] + return string.sub(line, start_col+1, end_col) +end + +function Query:match_preds(match, pattern, bufnr) + local preds = self.info.patterns[pattern] + if not preds then + return true + end + local regexes = self.regexes[pattern] + for i, pred in pairs(preds) do + -- Here we only want to return if a predicate DOES NOT match, and + -- continue on the other case. This way unknown predicates will not be considered, + -- which allows some testing and easier user extensibility (#12173). + -- Also, tree-sitter strips the leading # from predicates for us. + if pred[1] == "eq?" then + local node = match[pred[2]] + local node_text = get_node_text(node, bufnr) + + local str + if type(pred[3]) == "string" then + -- (#eq? @aa "foo") + str = pred[3] + else + -- (#eq? @aa @bb) + str = get_node_text(match[pred[3]], bufnr) + end + + if node_text ~= str or str == nil then + return false + end + elseif pred[1] == "match?" then + if not regexes or not regexes[i] then + return false + end + local node = match[pred[2]] + local start_row, start_col, end_row, end_col = node:range() + if start_row ~= end_row then + return false + end + if not regexes[i]:match_line(bufnr, start_row, start_col, end_col) then + return false + end + end + end + return true +end + +function Query:iter_captures(node, bufnr, start, stop) + if bufnr == 0 then + bufnr = vim.api.nvim_get_current_buf() + end + local raw_iter = node:_rawquery(self.query,true,start,stop) + local function iter() + local capture, captured_node, match = raw_iter() + if match ~= nil then + local active = self:match_preds(match, match.pattern, bufnr) + match.active = active + if not active then + return iter() -- tail call: try next match + end + end + return capture, captured_node + end + return iter +end + +function Query:iter_matches(node, bufnr, start, stop) + if bufnr == 0 then + bufnr = vim.api.nvim_get_current_buf() + end + local raw_iter = node:_rawquery(self.query,false,start,stop) + local function iter() + local pattern, match = raw_iter() + if match ~= nil then + local active = self:match_preds(match, pattern, bufnr) + if not active then + return iter() -- tail call: try next match + end + end + return pattern, match + end + return iter +end + +return M diff --git a/runtime/lua/vim/tshighlighter.lua b/runtime/lua/vim/tshighlighter.lua new file mode 100644 index 0000000000..6465751ae8 --- /dev/null +++ b/runtime/lua/vim/tshighlighter.lua @@ -0,0 +1,116 @@ +local a = vim.api + +-- support reload for quick experimentation +local TSHighlighter = rawget(vim.treesitter, 'TSHighlighter') or {} +TSHighlighter.__index = TSHighlighter +local ts_hs_ns = a.nvim_create_namespace("treesitter_hl") + +-- These are conventions defined by tree-sitter, though it +-- needs to be user extensible also. +-- TODO(bfredl): this is very much incomplete, we will need to +-- go through a few tree-sitter provided queries and decide +-- on translations that makes the most sense. +TSHighlighter.hl_map = { + keyword="Keyword", + string="String", + type="Type", + comment="Comment", + constant="Constant", + operator="Operator", + number="Number", + label="Label", + ["function"]="Function", + ["function.special"]="Function", +} + +function TSHighlighter.new(query, bufnr, ft) + local self = setmetatable({}, TSHighlighter) + self.parser = vim.treesitter.get_parser( + bufnr, + ft, + { + on_changedtree = function(...) self:on_changedtree(...) end, + on_lines = function() self.root = self.parser:parse():root() end + } + ) + + self.buf = self.parser.bufnr + + local tree = self.parser:parse() + self.root = tree:root() + self:set_query(query) + self.edit_count = 0 + self.redraw_count = 0 + self.line_count = {} + a.nvim_buf_set_option(self.buf, "syntax", "") + + -- Tricky: if syntax hasn't been enabled, we need to reload color scheme + -- but use synload.vim rather than syntax.vim to not enable + -- syntax FileType autocmds. Later on we should integrate with the + -- `:syntax` and `set syntax=...` machinery properly. + if vim.g.syntax_on ~= 1 then + vim.api.nvim_command("runtime! syntax/synload.vim") + end + return self +end + +local function is_highlight_name(capture_name) + local firstc = string.sub(capture_name, 1, 1) + return firstc ~= string.lower(firstc) +end + +function TSHighlighter:get_hl_from_capture(capture) + + local name = self.query.captures[capture] + + if is_highlight_name(name) then + -- From "Normal.left" only keep "Normal" + return vim.split(name, '.', true)[1] + else + -- Default to false to avoid recomputing + return TSHighlighter.hl_map[name] + end +end + +function TSHighlighter:set_query(query) + if type(query) == "string" then + query = vim.treesitter.parse_query(self.parser.lang, query) + end + self.query = query + + self.hl_cache = setmetatable({}, { + __index = function(table, capture) + local hl = self:get_hl_from_capture(capture) + rawset(table, capture, hl) + + return hl + end + }) + + self:on_changedtree({{self.root:range()}}) +end + +function TSHighlighter:on_changedtree(changes) + -- Get a fresh root + self.root = self.parser.tree:root() + + for _, ch in ipairs(changes or {}) do + -- Try to be as exact as possible + local changed_node = self.root:descendant_for_range(ch[1], ch[2], ch[3], ch[4]) + + a.nvim_buf_clear_namespace(self.buf, ts_hs_ns, ch[1], ch[3]) + + for capture, node in self.query:iter_captures(changed_node, self.buf, ch[1], ch[3] + 1) do + local start_row, start_col, end_row, end_col = node:range() + local hl = self.hl_cache[capture] + if hl then + a.nvim__buf_add_decoration(self.buf, ts_hs_ns, hl, + start_row, start_col, + end_row, end_col, + {}) + end + end + end +end + +return TSHighlighter diff --git a/runtime/lua/vim/uri.lua b/runtime/lua/vim/uri.lua new file mode 100644 index 0000000000..9c3535c676 --- /dev/null +++ b/runtime/lua/vim/uri.lua @@ -0,0 +1,112 @@ +--- TODO: This is implemented only for files now. +-- https://tools.ietf.org/html/rfc3986 +-- https://tools.ietf.org/html/rfc2732 +-- https://tools.ietf.org/html/rfc2396 + + +local uri_decode +do + local schar = string.char + local function hex_to_char(hex) + return schar(tonumber(hex, 16)) + end + uri_decode = function(str) + return str:gsub("%%([a-fA-F0-9][a-fA-F0-9])", hex_to_char) + end +end + +local uri_encode +do + local PATTERNS = { + --- RFC 2396 + -- https://tools.ietf.org/html/rfc2396#section-2.2 + rfc2396 = "^A-Za-z0-9%-_.!~*'()"; + --- RFC 2732 + -- https://tools.ietf.org/html/rfc2732 + rfc2732 = "^A-Za-z0-9%-_.!~*'()[]"; + --- RFC 3986 + -- https://tools.ietf.org/html/rfc3986#section-2.2 + rfc3986 = "^A-Za-z0-9%-._~!$&'()*+,;=:@/"; + } + local sbyte, tohex = string.byte + if jit then + tohex = require'bit'.tohex + else + tohex = function(b) return string.format("%02x", b) end + end + local function percent_encode_char(char) + return "%"..tohex(sbyte(char), 2) + end + uri_encode = function(text, rfc) + if not text then return end + local pattern = PATTERNS[rfc] or PATTERNS.rfc3986 + return text:gsub("(["..pattern.."])", percent_encode_char) + end +end + + +local function is_windows_file_uri(uri) + return uri:match('^file:///[a-zA-Z]:') ~= nil +end + +local function uri_from_fname(path) + local volume_path, fname = path:match("^([a-zA-Z]:)(.*)") + local is_windows = volume_path ~= nil + if is_windows then + path = volume_path..uri_encode(fname:gsub("\\", "/")) + else + path = uri_encode(path) + end + local uri_parts = {"file://"} + if is_windows then + table.insert(uri_parts, "/") + end + table.insert(uri_parts, path) + return table.concat(uri_parts) +end + +local URI_SCHEME_PATTERN = '^([a-zA-Z]+[a-zA-Z0-9+-.]*)://.*' + +local function uri_from_bufnr(bufnr) + local fname = vim.api.nvim_buf_get_name(bufnr) + local scheme = fname:match(URI_SCHEME_PATTERN) + if scheme then + return fname + else + return uri_from_fname(fname) + end +end + +local function uri_to_fname(uri) + local scheme = assert(uri:match(URI_SCHEME_PATTERN), 'URI must contain a scheme: ' .. uri) + if scheme ~= 'file' then + return uri + end + uri = uri_decode(uri) + -- TODO improve this. + if is_windows_file_uri(uri) then + uri = uri:gsub('^file:///', '') + uri = uri:gsub('/', '\\') + else + uri = uri:gsub('^file://', '') + end + return uri +end + +-- Return or create a buffer for a uri. +local function uri_to_bufnr(uri) + local scheme = assert(uri:match(URI_SCHEME_PATTERN), 'URI must contain a scheme: ' .. uri) + if scheme == 'file' then + return vim.fn.bufadd(uri_to_fname(uri)) + else + return vim.fn.bufadd(uri) + end +end + +return { + uri_from_fname = uri_from_fname, + uri_from_bufnr = uri_from_bufnr, + uri_to_fname = uri_to_fname, + uri_to_bufnr = uri_to_bufnr, +} +-- vim:sw=2 ts=2 et |