lua LSP client: initial implementation (#11336)

Mainly configuration and RPC infrastructure can be considered "done". Specific requests and their callbacks will be improved later (and also served by plugins). There are also some TODO:s for the client itself, like incremental updates.

Co-authored by at-tjdevries and at-h-michael, with many review/suggestion contributions.
This commit is contained in:
Ashkan Kiani 2019-11-13 12:55:26 -08:00 committed by Björn Linse
parent db436d5277
commit 00dc12c5d8
15 changed files with 5556 additions and 1 deletions

45
runtime/autoload/lsp.vim Normal file
View File

@ -0,0 +1,45 @@
function! lsp#add_filetype_config(config) abort
call luaeval('vim.lsp.add_filetype_config(_A)', a:config)
endfunction
function! lsp#set_log_level(level) abort
call luaeval('vim.lsp.set_log_level(_A)', a:level)
endfunction
function! lsp#get_log_path() abort
return luaeval('vim.lsp.get_log_path()')
endfunction
function! lsp#omnifunc(findstart, base) abort
return luaeval("vim.lsp.omnifunc(_A[1], _A[2])", [a:findstart, a:base])
endfunction
function! lsp#text_document_hover() abort
lua vim.lsp.buf_request(nil, 'textDocument/hover', vim.lsp.protocol.make_text_document_position_params())
return ''
endfunction
function! lsp#text_document_declaration() abort
lua vim.lsp.buf_request(nil, 'textDocument/declaration', vim.lsp.protocol.make_text_document_position_params())
return ''
endfunction
function! lsp#text_document_definition() abort
lua vim.lsp.buf_request(nil, 'textDocument/definition', vim.lsp.protocol.make_text_document_position_params())
return ''
endfunction
function! lsp#text_document_signature_help() abort
lua vim.lsp.buf_request(nil, 'textDocument/signatureHelp', vim.lsp.protocol.make_text_document_position_params())
return ''
endfunction
function! lsp#text_document_type_definition() abort
lua vim.lsp.buf_request(nil, 'textDocument/typeDefinition', vim.lsp.protocol.make_text_document_position_params())
return ''
endfunction
function! lsp#text_document_implementation() abort
lua vim.lsp.buf_request(nil, 'textDocument/implementation', vim.lsp.protocol.make_text_document_position_params())
return ''
endfunction

662
runtime/doc/lsp.txt Normal file
View File

@ -0,0 +1,662 @@
*lsp.txt* The Language Server Protocol
NVIM REFERENCE MANUAL
Neovim Language Server Protocol (LSP) API
Neovim exposes a powerful API that conforms to Microsoft's published Language
Server Protocol specification. The documentation can be found here:
https://microsoft.github.io/language-server-protocol/
================================================================================
*lsp-api*
Neovim exposes a API for the language server protocol. To get the real benefits
of this API, a language server must be installed.
Many examples can be found here:
https://microsoft.github.io/language-server-protocol/implementors/servers/
After installing a language server to your machine, you must let Neovim know
how to start and interact with that language server.
To do so, you can either:
- Use the |vim.lsp.add_filetype_config()|, which solves the common use-case of
a single server for one or more filetypes. This can also be used from vim
via |lsp#add_filetype_config()|.
- Or |vim.lsp.start_client()| and |vim.lsp.buf_attach_client()|. These are the
backbone of the LSP API. These are easy to use enough for basic or more
complex configurations such as in |lsp-advanced-js-example|.
================================================================================
*lsp-filetype-config*
These are utilities specific to filetype based configurations.
*lsp#add_filetype_config()*
*vim.lsp.add_filetype_config()*
lsp#add_filetype_config({config}) for Vim.
vim.lsp.add_filetype_config({config}) for Lua
These are functions which can be used to create a simple configuration which
will start a language server for a list of filetypes based on the |FileType|
event.
It will lazily start start the server, meaning that it will only start once
a matching filetype is encountered.
The {config} options are the same as |vim.lsp.start_client()|, but
with a few additions and distinctions:
Additional parameters:~
`filetype`
{string} or {list} of filetypes to attach to.
`name`
A unique identifying string among all other servers configured with
|vim.lsp.add_filetype_config|.
Differences:~
`root_dir`
Will default to |getcwd()| instead of being required.
NOTE: the function options in {config} like {config.on_init} are for Lua
callbacks, not Vim callbacks.
>
" Go example
call lsp#add_filetype_config({
\ 'filetype': 'go',
\ 'name': 'gopls',
\ 'cmd': 'gopls'
\ })
" Python example
call lsp#add_filetype_config({
\ 'filetype': 'python',
\ 'name': 'pyls',
\ 'cmd': 'pyls'
\ })
" Rust example
call lsp#add_filetype_config({
\ 'filetype': 'rust',
\ 'name': 'rls',
\ 'cmd': 'rls',
\ 'capabilities': {
\ 'clippy_preference': 'on',
\ 'all_targets': v:false,
\ 'build_on_save': v:true,
\ 'wait_to_build': 0
\ }})
<
>
-- From Lua
vim.lsp.add_filetype_config {
name = "clangd";
filetype = {"c", "cpp"};
cmd = "clangd -background-index";
capabilities = {
offsetEncoding = {"utf-8", "utf-16"};
};
on_init = vim.schedule_wrap(function(client, result)
if result.offsetEncoding then
client.offset_encoding = result.offsetEncoding
end
end)
}
<
*vim.lsp.copy_filetype_config()*
vim.lsp.copy_filetype_config({existing_name}, [{override_config}])
You can use this to copy an existing filetype configuration and change it by
specifying {override_config} which will override any properties in the
existing configuration. If you don't specify a new unique name with
{override_config.name} then it will try to create one and return it.
Returns:~
`name` the new configuration name.
*vim.lsp.get_filetype_client_by_name()*
vim.lsp.get_filetype_client_by_name({name})
Use this to look up a client by its name created from
|vim.lsp.add_filetype_config()|.
Returns nil if the client is not active or the name is not valid.
================================================================================
*lsp-core-api*
These are the core api functions for working with clients. You will mainly be
using |vim.lsp.start_client()| and |vim.lsp.buf_attach_client()| for operations
and |vim.lsp.get_client_by_id()| to retrieve a client by its id after it has
initialized (or {config.on_init}. see below)
*vim.lsp.start_client()*
vim.lsp.start_client({config})
The main function used for starting clients.
Start a client and initialize it.
Its arguments are passed via a configuration object {config}.
Mandatory parameters:~
`root_dir`
{string} specifying the directory where the LSP server will base
as its rootUri on initialization.
`cmd`
{string} or {list} which is the base command to execute for the LSP. A
string will be run using |'shell'| and a list will be interpreted as a
bare command with arguments passed. This is the same as |jobstart()|.
Optional parameters:~
`cmd_cwd`
{string} specifying the directory to launch the `cmd` process. This is not
related to `root_dir`.
By default, |getcwd()| is used.
`cmd_env`
{table} specifying the environment flags to pass to the LSP on spawn.
This can be specified using keys like a map or as a list with `k=v` pairs
or both. Non-string values are coerced to a string.
For example:
`{ "PRODUCTION=true"; "TEST=123"; PORT = 8080; HOST = "0.0.0.0"; }`
`capabilities`
A {table} which will be used instead of
`vim.lsp.protocol.make_client_capabilities()` which contains neovim's
default capabilities and passed to the language server on initialization.
You'll probably want to use make_client_capabilities() and modify the
result.
NOTE:
To send an empty dictionary, you should use
`{[vim.type_idx]=vim.types.dictionary}` Otherwise, it will be encoded as
an array.
`callbacks`
A {table} of whose keys are language server method names and the values
are `function(err, method, params, client_id)` See |lsp-callbacks| for
more. This will be combined with |lsp-builtin-callbacks| to provide
defaults.
`init_options`
A {table} of values to pass in the initialization request as
`initializationOptions`. See the `initialize` in the LSP spec.
`name`
A {string} used in log messages. Defaults to {client_id}
`offset_encoding`
One of "utf-8", "utf-16", or "utf-32" which is the encoding that the LSP
server expects.
The default encoding for Language Server Protocol is UTF-16, but there are
language servers which may use other encodings.
By default, it is "utf-16" as specified in the LSP specification. The
client does not verify this is correct.
`on_error(code, ...)`
A function for handling errors thrown by client operation. {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.
`vim.lsp.client_errors[code]` can be used to retrieve a human
understandable string.
`on_init(client, initialize_result)`
A function which is called after the request `initialize` is completed.
`initialize_result` contains `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.
`on_attach(client, bufnr)`
A function which is called after the client is attached to a buffer.
`on_exit(code, signal, client_id)`
A function which is called after the client has exited. code is the exit
code of the process, and signal is a number describing the signal used to
terminate (if any).
`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}
You can use |vim.lsp.get_client_by_id()| to get the actual client object.
See |lsp-client| for what the client structure will be.
NOTE: The client is only available *after* it has been initialized, which
may happen after a small delay (or never if there is an error). For this
reason, you may want to use `on_init` to do any actions once the client has
been initialized.
*lsp-client*
The client object has some methods and members related to using the client.
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)
A copy of the table that was passed by the user to
|vim.lsp.start_client()|.
`server_capabilities` (table)
The response from the server sent on `initialize` describing the
server's capabilities.
`resolved_capabilities` (table)
A normalized table of capabilities that we have detected based on the
initialize response from the server in `server_capabilities`.
*vim.lsp.buf_attach_client()*
vim.lsp.buf_attach_client({bufnr}, {client_id})
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.
*vim.lsp.get_client_by_id()*
vim.lsp.get_client_by_id({client_id})
Look up an active client by its id, returns nil if it is not yet initialized
or is not a valid id. Returns |lsp-client|
*vim.lsp.stop_client()*
vim.lsp.stop_client({client_id}, [{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.
You can also use `client.stop()` if you have access to the client.
*vim.lsp.stop_all_clients()*
vim.lsp.stop_all_clients([{force}])
|vim.lsp.stop_client()|, but for all active clients.
*vim.lsp.get_active_clients()*
vim.lsp.get_active_clients()
Return a list of all of the active clients. See |lsp-client| for a
description of what a client looks like.
*vim.lsp.rpc_response_error()*
vim.lsp.rpc_response_error({code}, [{message}], [{data}])
Helper function to create an RPC response object/table. This is an alias for
|vim.lsp.rpc.rpc_response_error|. Code must be an RPC error code as
described in `vim.lsp.protocol.ErrorCodes`.
You can describe an optional {message} string or arbitrary {data} to send to
the server.
================================================================================
*vim.lsp.builtin_callbacks*
The |vim.lsp.builtin_callbacks| table contains the default |lsp-callbacks|
that are used when creating a new client. The keys are the LSP method names.
The following requests and notifications have built-in callbacks defined to
handle the response in an idiomatic way.
textDocument/completion
textDocument/declaration
textDocument/definition
textDocument/hover
textDocument/implementation
textDocument/rename
textDocument/signatureHelp
textDocument/typeDefinition
window/logMessage
window/showMessage
You can check these via `vim.tbl_keys(vim.lsp.builtin_callbacks)`.
These will be automatically used and can be overridden by users (either by
modifying the |vim.lsp.builtin_callbacks| object or on a per-client basis
by passing in a table via the {callbacks} parameter on |vim.lsp.start_client|
or |vim.lsp.add_filetype_config|.
More information about callbacks can be found in |lsp-callbacks|.
================================================================================
*lsp-callbacks*
Callbacks are functions which are called in a variety of situations by the
client. Their signature is `function(err, method, params, client_id)` They can
be set by the {callbacks} parameter for |vim.lsp.start_client| and
|vim.lsp.add_filetype_config| or via the |vim.lsp.builtin_callbacks|.
This will be called for:
- notifications from the server, where `err` will always be `nil`
- requests initiated by the server. The parameter `err` will be `nil` here as
well.
For these, you can respond by returning two values: `result, err` The
err must be in the format of an RPC error, which is
`{ code, message, data? }`
You can use |vim.lsp.rpc_response_error()| to help with creating this object.
- as a callback for requests initiated by the client if the request doesn't
explicitly specify a callback (such as in |vim.lsp.buf_request|).
================================================================================
*vim.lsp.protocol*
vim.lsp.protocol
Contains constants as described in the Language Server Protocol
specification and helper functions for creating protocol related objects.
https://github.com/microsoft/language-server-protocol/raw/gh-pages/_specifications/specification-3-14.md
Useful examples are `vim.lsp.protocol.ErrorCodes`. These objects allow
reverse lookup by either the number or string name.
e.g. vim.lsp.protocol.TextDocumentSyncKind.Full == 1
vim.lsp.protocol.TextDocumentSyncKind[1] == "Full"
Utility functions used internally are:
`vim.lsp.make_client_capabilities()`
Make a ClientCapabilities object. These are the builtin
capabilities.
`vim.lsp.make_text_document_position_params()`
Make a TextDocumentPositionParams object.
`vim.lsp.resolve_capabilities(server_capabilites)`
Creates a normalized object describing capabilities from the server
capabilities.
================================================================================
*vim.lsp.util*
TODO: Describe the utils here for handling/applying things from LSP.
================================================================================
*lsp-buf-methods*
There are methods which operate on the buffer level for all of the active
clients attached to the buffer.
*vim.lsp.buf_request()*
vim.lsp.buf_request({bufnr}, {method}, {params}, [{callback}])
Send a async request for all the clients active and attached to the buffer.
Parameters: ~
{bufnr}: The buffer handle or 0 for the current buffer.
{method}: The LSP method name.
{params}: The parameters to send.
{callback}: An optional `function(err, method, params, client_id)` which
will be called for this request. If you do not specify it, then it will
use the client's callback in {client.callbacks}. See |lsp-callbacks| for
more information.
Returns:~
A table from client id to the request id for all of the successful
requests.
The second result is a function which can be used to cancel all the
requests. You can do this individually with `client.cancel_request()`
*vim.lsp.buf_request_sync()*
vim.lsp.buf_request_sync({bufnr}, {method}, {params}, [{timeout_ms}])
Calls |vim.lsp.buf_request()|, but it will wait for the result and block Vim
in the process.
The parameters are the same as |vim.lsp.buf_request()|, but the return
result is different.
It will wait maximum of {timeout_ms} which defaults to 100ms.
Returns:~
If the timeout is exceeded or a cancel is sent or an error, it will cancel
the request and return `nil, err` where `err` is a string that describes
the reason why it failed.
If it is successful, it will return a table from client id to result id.
*vim.lsp.buf_notify()*
vim.lsp.buf_notify({bufnr}, {method}, {params})
Send a notification to all servers on the buffer.
Parameters: ~
{bufnr}: The buffer handle or 0 for the current buffer.
{method}: The LSP method name.
{params}: The parameters to send.
================================================================================
*lsp-logging*
*lsp#set_log_level()*
lsp#set_log_level({level})
You can set the log level for language server client logging.
Possible values: "trace", "debug", "info", "warn", "error"
Default: "warn"
Example: `call lsp#set_log_level("debug")`
*lsp#get_log_path()*
*vim.lsp.get_log_path()*
lsp#get_log_path()
vim.lsp.get_log_path()
Returns the path that LSP logs are written.
*vim.lsp.log_levels*
vim.lsp.log_levels
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-omnifunc*
*vim.lsp.omnifunc()*
*lsp#omnifunc*
lsp#omnifunc({findstart}, {base})
vim.lsp.omnifunc({findstart}, {base})
To configure omnifunc, add the following in your init.vim:
>
set omnifunc=lsp#omnifunc
" This is optional, but you may find it useful
autocmd CompleteDone * pclose
<
================================================================================
*lsp-vim-functions*
These methods can be used in mappings and are the equivalent of using the
request from lua as follows:
>
lua vim.lsp.buf_request(0, "textDocument/hover", vim.lsp.protocol.make_text_document_position_params())
<
lsp#text_document_declaration()
lsp#text_document_definition()
lsp#text_document_hover()
lsp#text_document_implementation()
lsp#text_document_signature_help()
lsp#text_document_type_definition()
>
" Example config
autocmd Filetype rust,python,go,c,cpp setl omnifunc=lsp#omnifunc
nnoremap <silent> ;dc :call lsp#text_document_declaration()<CR>
nnoremap <silent> ;df :call lsp#text_document_definition()<CR>
nnoremap <silent> ;h :call lsp#text_document_hover()<CR>
nnoremap <silent> ;i :call lsp#text_document_implementation()<CR>
nnoremap <silent> ;s :call lsp#text_document_signature_help()<CR>
nnoremap <silent> ;td :call lsp#text_document_type_definition()<CR>
<
================================================================================
*lsp-advanced-js-example*
For more advanced configurations where just filtering by filetype isn't
sufficient, you can use the `vim.lsp.start_client()` and
`vim.lsp.buf_attach_client()` commands to easily customize the configuration
however you please. For example, if you want to do your own filtering, or
start a new LSP client based on the root directory for if you plan to work
with multiple projects in a single session. Below is a fully working Lua
example which can do exactly that.
The example will:
1. Check for each new buffer whether or not we want to start an LSP client.
2. Try to find a root directory by ascending from the buffer's path.
3. Create a new LSP for that root directory if one doesn't exist.
4. Attach the buffer to the client for that root directory.
>
-- Some path manipulation utilities
local function is_dir(filename)
local stat = vim.loop.fs_stat(filename)
return stat and stat.type == 'directory' or false
end
local path_sep = vim.loop.os_uname().sysname == "Windows" and "\\" or "/"
-- Asumes filepath is a file.
local function dirname(filepath)
local is_changed = false
local result = filepath:gsub(path_sep.."([^"..path_sep.."]+)$", function()
is_changed = true
return ""
end)
return result, is_changed
end
local function path_join(...)
return table.concat(vim.tbl_flatten {...}, path_sep)
end
-- Ascend the buffer's path until we find the rootdir.
-- is_root_path is a function which returns bool
local function buffer_find_root_dir(bufnr, is_root_path)
local bufname = vim.api.nvim_buf_get_name(bufnr)
if vim.fn.filereadable(bufname) == 0 then
return nil
end
local dir = bufname
-- Just in case our algo is buggy, don't infinite loop.
for _ = 1, 100 do
local did_change
dir, did_change = dirname(dir)
if is_root_path(dir, bufname) then
return dir, bufname
end
-- If we can't ascend further, then stop looking.
if not did_change then
return nil
end
end
end
-- A table to store our root_dir to client_id lookup. We want one LSP per
-- root directory, and this is how we assert that.
local javascript_lsps = {}
-- Which filetypes we want to consider.
local javascript_filetypes = {
["javascript.jsx"] = true;
["javascript"] = true;
["typescript"] = true;
["typescript.jsx"] = true;
}
-- Create a template configuration for a server to start, minus the root_dir
-- which we will specify later.
local javascript_lsp_config = {
name = "javascript";
cmd = { path_join(os.getenv("JAVASCRIPT_LANGUAGE_SERVER_DIRECTORY"), "lib", "language-server-stdio.js") };
}
-- This needs to be global so that we can call it from the autocmd.
function check_start_javascript_lsp()
local bufnr = vim.api.nvim_get_current_buf()
-- Filter which files we are considering.
if not javascript_filetypes[vim.api.nvim_buf_get_option(bufnr, 'filetype')] then
return
end
-- Try to find our root directory. We will define this as a directory which contains
-- node_modules. Another choice would be to check for `package.json`, or for `.git`.
local root_dir = buffer_find_root_dir(bufnr, function(dir)
return is_dir(path_join(dir, 'node_modules'))
-- return vim.fn.filereadable(path_join(dir, 'package.json')) == 1
-- return is_dir(path_join(dir, '.git'))
end)
-- We couldn't find a root directory, so ignore this file.
if not root_dir then return end
-- Check if we have a client alredy or start and store it.
local client_id = javascript_lsps[root_dir]
if not client_id then
local new_config = vim.tbl_extend("error", javascript_lsp_config, {
root_dir = root_dir;
})
client_id = vim.lsp.start_client(new_config)
javascript_lsps[root_dir] = client_id
end
-- Finally, attach to the buffer to track changes. This will do nothing if we
-- are already attached.
vim.lsp.buf_attach_client(bufnr, client_id)
end
vim.api.nvim_command [[autocmd BufReadPost * lua check_start_javascript_lsp()]]
<
vim:tw=78:ts=8:ft=help:norl:

1055
runtime/lua/vim/lsp.lua Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,296 @@
--- Implements the following default callbacks:
--
-- vim.api.nvim_buf_set_lines(0, 0, 0, false, vim.tbl_keys(vim.lsp.builtin_callbacks))
--
-- textDocument/completion
-- textDocument/declaration
-- textDocument/definition
-- textDocument/hover
-- textDocument/implementation
-- textDocument/publishDiagnostics
-- textDocument/rename
-- textDocument/signatureHelp
-- textDocument/typeDefinition
-- TODO codeLens/resolve
-- TODO completionItem/resolve
-- TODO documentLink/resolve
-- TODO textDocument/codeAction
-- TODO textDocument/codeLens
-- TODO textDocument/documentHighlight
-- TODO textDocument/documentLink
-- TODO textDocument/documentSymbol
-- TODO textDocument/formatting
-- TODO textDocument/onTypeFormatting
-- TODO textDocument/rangeFormatting
-- TODO textDocument/references
-- window/logMessage
-- window/showMessage
local log = require 'vim.lsp.log'
local protocol = require 'vim.lsp.protocol'
local util = require 'vim.lsp.util'
local api = vim.api
local function split_lines(value)
return vim.split(value, '\n', true)
end
local builtin_callbacks = {}
-- textDocument/completion
-- https://microsoft.github.io/language-server-protocol/specification#textDocument_completion
builtin_callbacks['textDocument/completion'] = function(_, _, result)
if not result or vim.tbl_isempty(result) then
return
end
local pos = api.nvim_win_get_cursor(0)
local row, col = pos[1], pos[2]
local line = assert(api.nvim_buf_get_lines(0, row-1, row, false)[1])
local line_to_cursor = line:sub(col+1)
local matches = util.text_document_completion_list_to_complete_items(result, line_to_cursor)
local match_result = vim.fn.matchstrpos(line_to_cursor, '\\k\\+$')
local match_start, match_finish = match_result[2], match_result[3]
vim.fn.complete(col + 1 - (match_finish - match_start), matches)
end
-- textDocument/rename
builtin_callbacks['textDocument/rename'] = function(_, _, result)
if not result then return end
util.workspace_apply_workspace_edit(result)
end
local function uri_to_bufnr(uri)
return vim.fn.bufadd((vim.uri_to_fname(uri)))
end
builtin_callbacks['textDocument/publishDiagnostics'] = function(_, _, result)
if not result then return end
local uri = result.uri
local bufnr = uri_to_bufnr(uri)
if not bufnr then
api.nvim_err_writeln(string.format("LSP.publishDiagnostics: Couldn't find buffer for %s", uri))
return
end
util.buf_clear_diagnostics(bufnr)
util.buf_diagnostics_save_positions(bufnr, result.diagnostics)
util.buf_diagnostics_underline(bufnr, result.diagnostics)
util.buf_diagnostics_virtual_text(bufnr, result.diagnostics)
-- util.buf_loclist(bufnr, result.diagnostics)
end
-- textDocument/hover
-- https://microsoft.github.io/language-server-protocol/specification#textDocument_hover
-- @params MarkedString | MarkedString[] | MarkupContent
builtin_callbacks['textDocument/hover'] = function(_, _, result)
if result == nil or vim.tbl_isempty(result) then
return
end
if result.contents ~= nil then
local markdown_lines = util.convert_input_to_markdown_lines(result.contents)
if vim.tbl_isempty(markdown_lines) then
markdown_lines = { 'No information available' }
end
util.open_floating_preview(markdown_lines, 'markdown')
end
end
builtin_callbacks['textDocument/peekDefinition'] = function(_, _, result)
if result == nil or vim.tbl_isempty(result) then return end
-- TODO(ashkan) what to do with multiple locations?
result = result[1]
local bufnr = uri_to_bufnr(result.uri)
assert(bufnr)
local start = result.range.start
local finish = result.range["end"]
util.open_floating_peek_preview(bufnr, start, finish, { offset_x = 1 })
util.open_floating_preview({"*Peek:*", string.rep(" ", finish.character - start.character + 1) }, 'markdown', { offset_y = -(finish.line - start.line) })
end
--- Convert SignatureHelp response to preview contents.
-- https://microsoft.github.io/language-server-protocol/specifications/specification-3-14/#textDocument_signatureHelp
local function signature_help_to_preview_contents(input)
if not input.signatures then
return
end
--The active signature. If omitted or the value lies outside the range of
--`signatures` the value defaults to zero or is ignored if `signatures.length
--=== 0`. Whenever possible implementors should make an active decision about
--the active signature and shouldn't rely on a default value.
local contents = {}
local active_signature = input.activeSignature or 0
-- If the activeSignature is not inside the valid range, then clip it.
if active_signature >= #input.signatures then
active_signature = 0
end
local signature = input.signatures[active_signature + 1]
if not signature then
return
end
vim.list_extend(contents, split_lines(signature.label))
if signature.documentation then
util.convert_input_to_markdown_lines(signature.documentation, contents)
end
if input.parameters then
local active_parameter = input.activeParameter or 0
-- If the activeParameter is not inside the valid range, then clip it.
if active_parameter >= #input.parameters then
active_parameter = 0
end
local parameter = signature.parameters and signature.parameters[active_parameter]
if parameter then
--[=[
--Represents a parameter of a callable-signature. A parameter can
--have a label and a doc-comment.
interface ParameterInformation {
--The label of this parameter information.
--
--Either a string or an inclusive start and exclusive end offsets within its containing
--signature label. (see SignatureInformation.label). The offsets are based on a UTF-16
--string representation as `Position` and `Range` does.
--
--*Note*: a label of type string should be a substring of its containing signature label.
--Its intended use case is to highlight the parameter label part in the `SignatureInformation.label`.
label: string | [number, number];
--The human-readable doc-comment of this parameter. Will be shown
--in the UI but can be omitted.
documentation?: string | MarkupContent;
}
--]=]
-- TODO highlight parameter
if parameter.documentation then
util.convert_input_to_markdown_lines(parameter.documentation, contents)
end
end
end
return contents
end
-- textDocument/signatureHelp
-- https://microsoft.github.io/language-server-protocol/specification#textDocument_signatureHelp
builtin_callbacks['textDocument/signatureHelp'] = function(_, _, result)
if result == nil or vim.tbl_isempty(result) then
return
end
-- TODO show empty popup when signatures is empty?
if #result.signatures > 0 then
local markdown_lines = signature_help_to_preview_contents(result)
if vim.tbl_isempty(markdown_lines) then
markdown_lines = { 'No signature available' }
end
util.open_floating_preview(markdown_lines, 'markdown')
end
end
local function update_tagstack()
local bufnr = api.nvim_get_current_buf()
local line = vim.fn.line('.')
local col = vim.fn.col('.')
local tagname = vim.fn.expand('<cWORD>')
local item = { bufnr = bufnr, from = { bufnr, line, col, 0 }, tagname = tagname }
local winid = vim.fn.win_getid()
local tagstack = vim.fn.gettagstack(winid)
local action
if tagstack.length == tagstack.curidx then
action = 'r'
tagstack.items[tagstack.curidx] = item
elseif tagstack.length > tagstack.curidx then
action = 'r'
if tagstack.curidx > 1 then
tagstack.items = table.insert(tagstack.items[tagstack.curidx - 1], item)
else
tagstack.items = { item }
end
else
action = 'a'
tagstack.items = { item }
end
tagstack.curidx = tagstack.curidx + 1
vim.fn.settagstack(winid, tagstack, action)
end
local function handle_location(result)
-- We can sometimes get a list of locations, so set the first value as the
-- only value we want to handle
-- TODO(ashkan) was this correct^? We could use location lists.
if result[1] ~= nil then
result = result[1]
end
if result.uri == nil then
api.nvim_err_writeln('[LSP] Could not find a valid location')
return
end
local result_file = vim.uri_to_fname(result.uri)
local bufnr = vim.fn.bufadd(result_file)
update_tagstack()
api.nvim_set_current_buf(bufnr)
local start = result.range.start
api.nvim_win_set_cursor(0, {start.line + 1, start.character})
end
local function location_callback(_, method, result)
if result == nil or vim.tbl_isempty(result) then
local _ = log.info() and log.info(method, 'No location found')
return nil
end
handle_location(result)
return true
end
local location_callbacks = {
-- https://microsoft.github.io/language-server-protocol/specification#textDocument_declaration
'textDocument/declaration';
-- https://microsoft.github.io/language-server-protocol/specification#textDocument_definition
'textDocument/definition';
-- https://microsoft.github.io/language-server-protocol/specification#textDocument_implementation
'textDocument/implementation';
-- https://microsoft.github.io/language-server-protocol/specification#textDocument_typeDefinition
'textDocument/typeDefinition';
}
for _, location_method in ipairs(location_callbacks) do
builtin_callbacks[location_method] = location_callback
end
local function log_message(_, _, result, client_id)
local message_type = result.type
local message = result.message
local client = vim.lsp.get_client_by_id(client_id)
local client_name = client and client.name or string.format("id=%d", client_id)
if not client then
api.nvim_err_writeln(string.format("LSP[%s] client has shut down after sending the message", client_name))
end
if message_type == protocol.MessageType.Error then
-- Might want to not use err_writeln,
-- but displaying a message with red highlights or something
api.nvim_err_writeln(string.format("LSP[%s] %s", client_name, message))
else
local message_type_name = protocol.MessageType[message_type]
api.nvim_out_write(string.format("LSP[%s][%s] %s\n", client_name, message_type_name, message))
end
return result
end
builtin_callbacks['window/showMessage'] = log_message
builtin_callbacks['window/logMessage'] = log_message
-- Add boilerplate error validation and logging for all of these.
for k, fn in pairs(builtin_callbacks) do
builtin_callbacks[k] = function(err, method, params, client_id)
local _ = log.debug() and log.debug('builtin_callback', method, { params = params, client_id = client_id, err = err })
if err then
error(tostring(err))
end
return fn(err, method, params, client_id)
end
end
return builtin_callbacks
-- vim:sw=2 ts=2 et

View File

@ -0,0 +1,95 @@
-- Logger for language client plugin.
local log = {}
-- Log level dictionary with reverse lookup as well.
--
-- Can be used to lookup the number from the name or the name from the number.
-- Levels by name: 'trace', 'debug', 'info', 'warn', 'error'
-- Level numbers begin with 'trace' at 0
log.levels = {
TRACE = 0;
DEBUG = 1;
INFO = 2;
WARN = 3;
ERROR = 4;
-- FATAL = 4;
}
-- Default log level is warn.
local current_log_level = log.levels.WARN
local log_date_format = "%FT%H:%M:%SZ%z"
do
local path_sep = vim.loop.os_uname().sysname == "Windows" and "\\" or "/"
local function path_join(...)
return table.concat(vim.tbl_flatten{...}, path_sep)
end
local logfilename = path_join(vim.fn.stdpath('data'), 'vim-lsp.log')
--- Return the log filename.
function log.get_filename()
return logfilename
end
vim.fn.mkdir(vim.fn.stdpath('data'), "p")
local logfile = assert(io.open(logfilename, "a+"))
for level, levelnr in pairs(log.levels) do
-- Also export the log level on the root object.
log[level] = levelnr
-- Set the lowercase name as the main use function.
-- If called without arguments, it will check whether the log level is
-- greater than or equal to this one. When called with arguments, it will
-- log at that level (if applicable, it is checked either way).
--
-- Recommended usage:
-- ```
-- local _ = log.warn() and log.warn("123")
-- ```
--
-- This way you can avoid string allocations if the log level isn't high enough.
log[level:lower()] = function(...)
local argc = select("#", ...)
if levelnr < current_log_level then return false end
if argc == 0 then return true end
local info = debug.getinfo(2, "Sl")
local fileinfo = string.format("%s:%s", info.short_src, info.currentline)
local parts = { table.concat({"[", level, "]", os.date(log_date_format), "]", fileinfo, "]"}, " ") }
for i = 1, argc do
local arg = select(i, ...)
if arg == nil then
table.insert(parts, "nil")
else
table.insert(parts, vim.inspect(arg, {newline=''}))
end
end
logfile:write(table.concat(parts, '\t'), "\n")
logfile:flush()
end
end
-- Add some space to make it easier to distinguish different neovim runs.
logfile:write("\n")
end
-- This is put here on purpose after the loop above so that it doesn't
-- interfere with iterating the levels
vim.tbl_add_reverse_lookup(log.levels)
function log.set_level(level)
if type(level) == 'string' then
current_log_level = assert(log.levels[level:upper()], string.format("Invalid log level: %q", level))
else
assert(type(level) == 'number', "level must be a number or string")
assert(log.levels[level], string.format("Invalid log level: %d", level))
current_log_level = level
end
end
-- Return whether the level is sufficient for logging.
-- @param level number log level
function log.should_log(level)
return level >= current_log_level
end
return log
-- vim:sw=2 ts=2 et

View File

@ -0,0 +1,936 @@
-- Protocol for the Microsoft Language Server Protocol (mslsp)
local protocol = {}
local function ifnil(a, b)
if a == nil then return b end
return a
end
--[=[
-- Useful for interfacing with:
-- https://github.com/microsoft/language-server-protocol/blob/gh-pages/_specifications/specification-3-14.md
-- https://github.com/microsoft/language-server-protocol/raw/gh-pages/_specifications/specification-3-14.md
function transform_schema_comments()
nvim.command [[silent! '<,'>g/\/\*\*\|\*\/\|^$/d]]
nvim.command [[silent! '<,'>s/^\(\s*\) \* \=\(.*\)/\1--\2/]]
end
function transform_schema_to_table()
transform_schema_comments()
nvim.command [[silent! '<,'>s/: \S\+//]]
nvim.command [[silent! '<,'>s/export const //]]
nvim.command [[silent! '<,'>s/export namespace \(\S*\)\s*{/protocol.\1 = {/]]
nvim.command [[silent! '<,'>s/namespace \(\S*\)\s*{/protocol.\1 = {/]]
end
--]=]
local constants = {
DiagnosticSeverity = {
-- Reports an error.
Error = 1;
-- Reports a warning.
Warning = 2;
-- Reports an information.
Information = 3;
-- Reports a hint.
Hint = 4;
};
MessageType = {
-- An error message.
Error = 1;
-- A warning message.
Warning = 2;
-- An information message.
Info = 3;
-- A log message.
Log = 4;
};
-- The file event type.
FileChangeType = {
-- The file got created.
Created = 1;
-- The file got changed.
Changed = 2;
-- The file got deleted.
Deleted = 3;
};
-- The kind of a completion entry.
CompletionItemKind = {
Text = 1;
Method = 2;
Function = 3;
Constructor = 4;
Field = 5;
Variable = 6;
Class = 7;
Interface = 8;
Module = 9;
Property = 10;
Unit = 11;
Value = 12;
Enum = 13;
Keyword = 14;
Snippet = 15;
Color = 16;
File = 17;
Reference = 18;
Folder = 19;
EnumMember = 20;
Constant = 21;
Struct = 22;
Event = 23;
Operator = 24;
TypeParameter = 25;
};
-- How a completion was triggered
CompletionTriggerKind = {
-- Completion was triggered by typing an identifier (24x7 code
-- complete), manual invocation (e.g Ctrl+Space) or via API.
Invoked = 1;
-- Completion was triggered by a trigger character specified by
-- the `triggerCharacters` properties of the `CompletionRegistrationOptions`.
TriggerCharacter = 2;
-- Completion was re-triggered as the current completion list is incomplete.
TriggerForIncompleteCompletions = 3;
};
-- A document highlight kind.
DocumentHighlightKind = {
-- A textual occurrence.
Text = 1;
-- Read-access of a symbol, like reading a variable.
Read = 2;
-- Write-access of a symbol, like writing to a variable.
Write = 3;
};
-- A symbol kind.
SymbolKind = {
File = 1;
Module = 2;
Namespace = 3;
Package = 4;
Class = 5;
Method = 6;
Property = 7;
Field = 8;
Constructor = 9;
Enum = 10;
Interface = 11;
Function = 12;
Variable = 13;
Constant = 14;
String = 15;
Number = 16;
Boolean = 17;
Array = 18;
Object = 19;
Key = 20;
Null = 21;
EnumMember = 22;
Struct = 23;
Event = 24;
Operator = 25;
TypeParameter = 26;
};
-- Represents reasons why a text document is saved.
TextDocumentSaveReason = {
-- Manually triggered, e.g. by the user pressing save, by starting debugging,
-- or by an API call.
Manual = 1;
-- Automatic after a delay.
AfterDelay = 2;
-- When the editor lost focus.
FocusOut = 3;
};
ErrorCodes = {
-- Defined by JSON RPC
ParseError = -32700;
InvalidRequest = -32600;
MethodNotFound = -32601;
InvalidParams = -32602;
InternalError = -32603;
serverErrorStart = -32099;
serverErrorEnd = -32000;
ServerNotInitialized = -32002;
UnknownErrorCode = -32001;
-- Defined by the protocol.
RequestCancelled = -32800;
ContentModified = -32801;
};
-- Describes the content type that a client supports in various
-- result literals like `Hover`, `ParameterInfo` or `CompletionItem`.
--
-- Please note that `MarkupKinds` must not start with a `$`. This kinds
-- are reserved for internal usage.
MarkupKind = {
-- Plain text is supported as a content format
PlainText = 'plaintext';
-- Markdown is supported as a content format
Markdown = 'markdown';
};
ResourceOperationKind = {
-- Supports creating new files and folders.
Create = 'create';
-- Supports renaming existing files and folders.
Rename = 'rename';
-- Supports deleting existing files and folders.
Delete = 'delete';
};
FailureHandlingKind = {
-- Applying the workspace change is simply aborted if one of the changes provided
-- fails. All operations executed before the failing operation stay executed.
Abort = 'abort';
-- All operations are executed transactionally. That means they either all
-- succeed or no changes at all are applied to the workspace.
Transactional = 'transactional';
-- If the workspace edit contains only textual file changes they are executed transactionally.
-- If resource changes (create, rename or delete file) are part of the change the failure
-- handling strategy is abort.
TextOnlyTransactional = 'textOnlyTransactional';
-- The client tries to undo the operations already executed. But there is no
-- guarantee that this succeeds.
Undo = 'undo';
};
-- Known error codes for an `InitializeError`;
InitializeError = {
-- If the protocol version provided by the client can't be handled by the server.
-- @deprecated This initialize error got replaced by client capabilities. There is
-- no version handshake in version 3.0x
unknownProtocolVersion = 1;
};
-- Defines how the host (editor) should sync document changes to the language server.
TextDocumentSyncKind = {
-- Documents should not be synced at all.
None = 0;
-- Documents are synced by always sending the full content
-- of the document.
Full = 1;
-- Documents are synced by sending the full content on open.
-- After that only incremental updates to the document are
-- send.
Incremental = 2;
};
WatchKind = {
-- Interested in create events.
Create = 1;
-- Interested in change events
Change = 2;
-- Interested in delete events
Delete = 4;
};
-- Defines whether the insert text in a completion item should be interpreted as
-- plain text or a snippet.
InsertTextFormat = {
-- The primary text to be inserted is treated as a plain string.
PlainText = 1;
-- The primary text to be inserted is treated as a snippet.
--
-- A snippet can define tab stops and placeholders with `$1`, `$2`
-- and `${3:foo};`. `$0` defines the final tab stop, it defaults to
-- the end of the snippet. Placeholders with equal identifiers are linked,
-- that is typing in one will update others too.
Snippet = 2;
};
-- A set of predefined code action kinds
CodeActionKind = {
-- Empty kind.
Empty = '';
-- Base kind for quickfix actions
QuickFix = 'quickfix';
-- Base kind for refactoring actions
Refactor = 'refactor';
-- Base kind for refactoring extraction actions
--
-- Example extract actions:
--
-- - Extract method
-- - Extract function
-- - Extract variable
-- - Extract interface from class
-- - ...
RefactorExtract = 'refactor.extract';
-- Base kind for refactoring inline actions
--
-- Example inline actions:
--
-- - Inline function
-- - Inline variable
-- - Inline constant
-- - ...
RefactorInline = 'refactor.inline';
-- Base kind for refactoring rewrite actions
--
-- Example rewrite actions:
--
-- - Convert JavaScript function to class
-- - Add or remove parameter
-- - Encapsulate field
-- - Make method static
-- - Move method to base class
-- - ...
RefactorRewrite = 'refactor.rewrite';
-- Base kind for source actions
--
-- Source code actions apply to the entire file.
Source = 'source';
-- Base kind for an organize imports source action
SourceOrganizeImports = 'source.organizeImports';
};
}
for k, v in pairs(constants) do
vim.tbl_add_reverse_lookup(v)
protocol[k] = v
end
--[=[
--Text document specific client capabilities.
export interface TextDocumentClientCapabilities {
synchronization?: {
--Whether text document synchronization supports dynamic registration.
dynamicRegistration?: boolean;
--The client supports sending will save notifications.
willSave?: boolean;
--The client supports sending a will save request and
--waits for a response providing text edits which will
--be applied to the document before it is saved.
willSaveWaitUntil?: boolean;
--The client supports did save notifications.
didSave?: boolean;
}
--Capabilities specific to the `textDocument/completion`
completion?: {
--Whether completion supports dynamic registration.
dynamicRegistration?: boolean;
--The client supports the following `CompletionItem` specific
--capabilities.
completionItem?: {
--The client supports snippets as insert text.
--
--A snippet can define tab stops and placeholders with `$1`, `$2`
--and `${3:foo}`. `$0` defines the final tab stop, it defaults to
--the end of the snippet. Placeholders with equal identifiers are linked,
--that is typing in one will update others too.
snippetSupport?: boolean;
--The client supports commit characters on a completion item.
commitCharactersSupport?: boolean
--The client supports the following content formats for the documentation
--property. The order describes the preferred format of the client.
documentationFormat?: MarkupKind[];
--The client supports the deprecated property on a completion item.
deprecatedSupport?: boolean;
--The client supports the preselect property on a completion item.
preselectSupport?: boolean;
}
completionItemKind?: {
--The completion item kind values the client supports. When this
--property exists the client also guarantees that it will
--handle values outside its set gracefully and falls back
--to a default value when unknown.
--
--If this property is not present the client only supports
--the completion items kinds from `Text` to `Reference` as defined in
--the initial version of the protocol.
valueSet?: CompletionItemKind[];
},
--The client supports to send additional context information for a
--`textDocument/completion` request.
contextSupport?: boolean;
};
--Capabilities specific to the `textDocument/hover`
hover?: {
--Whether hover supports dynamic registration.
dynamicRegistration?: boolean;
--The client supports the follow content formats for the content
--property. The order describes the preferred format of the client.
contentFormat?: MarkupKind[];
};
--Capabilities specific to the `textDocument/signatureHelp`
signatureHelp?: {
--Whether signature help supports dynamic registration.
dynamicRegistration?: boolean;
--The client supports the following `SignatureInformation`
--specific properties.
signatureInformation?: {
--The client supports the follow content formats for the documentation
--property. The order describes the preferred format of the client.
documentationFormat?: MarkupKind[];
--Client capabilities specific to parameter information.
parameterInformation?: {
--The client supports processing label offsets instead of a
--simple label string.
--
--Since 3.14.0
labelOffsetSupport?: boolean;
}
};
};
--Capabilities specific to the `textDocument/references`
references?: {
--Whether references supports dynamic registration.
dynamicRegistration?: boolean;
};
--Capabilities specific to the `textDocument/documentHighlight`
documentHighlight?: {
--Whether document highlight supports dynamic registration.
dynamicRegistration?: boolean;
};
--Capabilities specific to the `textDocument/documentSymbol`
documentSymbol?: {
--Whether document symbol supports dynamic registration.
dynamicRegistration?: boolean;
--Specific capabilities for the `SymbolKind`.
symbolKind?: {
--The symbol kind values the client supports. When this
--property exists the client also guarantees that it will
--handle values outside its set gracefully and falls back
--to a default value when unknown.
--
--If this property is not present the client only supports
--the symbol kinds from `File` to `Array` as defined in
--the initial version of the protocol.
valueSet?: SymbolKind[];
}
--The client supports hierarchical document symbols.
hierarchicalDocumentSymbolSupport?: boolean;
};
--Capabilities specific to the `textDocument/formatting`
formatting?: {
--Whether formatting supports dynamic registration.
dynamicRegistration?: boolean;
};
--Capabilities specific to the `textDocument/rangeFormatting`
rangeFormatting?: {
--Whether range formatting supports dynamic registration.
dynamicRegistration?: boolean;
};
--Capabilities specific to the `textDocument/onTypeFormatting`
onTypeFormatting?: {
--Whether on type formatting supports dynamic registration.
dynamicRegistration?: boolean;
};
--Capabilities specific to the `textDocument/declaration`
declaration?: {
--Whether declaration supports dynamic registration. If this is set to `true`
--the client supports the new `(TextDocumentRegistrationOptions & StaticRegistrationOptions)`
--return value for the corresponding server capability as well.
dynamicRegistration?: boolean;
--The client supports additional metadata in the form of declaration links.
--
--Since 3.14.0
linkSupport?: boolean;
};
--Capabilities specific to the `textDocument/definition`.
--
--Since 3.14.0
definition?: {
--Whether definition supports dynamic registration.
dynamicRegistration?: boolean;
--The client supports additional metadata in the form of definition links.
linkSupport?: boolean;
};
--Capabilities specific to the `textDocument/typeDefinition`
--
--Since 3.6.0
typeDefinition?: {
--Whether typeDefinition supports dynamic registration. If this is set to `true`
--the client supports the new `(TextDocumentRegistrationOptions & StaticRegistrationOptions)`
--return value for the corresponding server capability as well.
dynamicRegistration?: boolean;
--The client supports additional metadata in the form of definition links.
--
--Since 3.14.0
linkSupport?: boolean;
};
--Capabilities specific to the `textDocument/implementation`.
--
--Since 3.6.0
implementation?: {
--Whether implementation supports dynamic registration. If this is set to `true`
--the client supports the new `(TextDocumentRegistrationOptions & StaticRegistrationOptions)`
--return value for the corresponding server capability as well.
dynamicRegistration?: boolean;
--The client supports additional metadata in the form of definition links.
--
--Since 3.14.0
linkSupport?: boolean;
};
--Capabilities specific to the `textDocument/codeAction`
codeAction?: {
--Whether code action supports dynamic registration.
dynamicRegistration?: boolean;
--The client support code action literals as a valid
--response of the `textDocument/codeAction` request.
--
--Since 3.8.0
codeActionLiteralSupport?: {
--The code action kind is support with the following value
--set.
codeActionKind: {
--The code action kind values the client supports. When this
--property exists the client also guarantees that it will
--handle values outside its set gracefully and falls back
--to a default value when unknown.
valueSet: CodeActionKind[];
};
};
};
--Capabilities specific to the `textDocument/codeLens`
codeLens?: {
--Whether code lens supports dynamic registration.
dynamicRegistration?: boolean;
};
--Capabilities specific to the `textDocument/documentLink`
documentLink?: {
--Whether document link supports dynamic registration.
dynamicRegistration?: boolean;
};
--Capabilities specific to the `textDocument/documentColor` and the
--`textDocument/colorPresentation` request.
--
--Since 3.6.0
colorProvider?: {
--Whether colorProvider supports dynamic registration. If this is set to `true`
--the client supports the new `(ColorProviderOptions & TextDocumentRegistrationOptions & StaticRegistrationOptions)`
--return value for the corresponding server capability as well.
dynamicRegistration?: boolean;
}
--Capabilities specific to the `textDocument/rename`
rename?: {
--Whether rename supports dynamic registration.
dynamicRegistration?: boolean;
--The client supports testing for validity of rename operations
--before execution.
prepareSupport?: boolean;
};
--Capabilities specific to `textDocument/publishDiagnostics`.
publishDiagnostics?: {
--Whether the clients accepts diagnostics with related information.
relatedInformation?: boolean;
};
--Capabilities specific to `textDocument/foldingRange` requests.
--
--Since 3.10.0
foldingRange?: {
--Whether implementation supports dynamic registration for folding range providers. If this is set to `true`
--the client supports the new `(FoldingRangeProviderOptions & TextDocumentRegistrationOptions & StaticRegistrationOptions)`
--return value for the corresponding server capability as well.
dynamicRegistration?: boolean;
--The maximum number of folding ranges that the client prefers to receive per document. The value serves as a
--hint, servers are free to follow the limit.
rangeLimit?: number;
--If set, the client signals that it only supports folding complete lines. If set, client will
--ignore specified `startCharacter` and `endCharacter` properties in a FoldingRange.
lineFoldingOnly?: boolean;
};
}
--]=]
--[=[
--Workspace specific client capabilities.
export interface WorkspaceClientCapabilities {
--The client supports applying batch edits to the workspace by supporting
--the request 'workspace/applyEdit'
applyEdit?: boolean;
--Capabilities specific to `WorkspaceEdit`s
workspaceEdit?: {
--The client supports versioned document changes in `WorkspaceEdit`s
documentChanges?: boolean;
--The resource operations the client supports. Clients should at least
--support 'create', 'rename' and 'delete' files and folders.
resourceOperations?: ResourceOperationKind[];
--The failure handling strategy of a client if applying the workspace edit
--fails.
failureHandling?: FailureHandlingKind;
};
--Capabilities specific to the `workspace/didChangeConfiguration` notification.
didChangeConfiguration?: {
--Did change configuration notification supports dynamic registration.
dynamicRegistration?: boolean;
};
--Capabilities specific to the `workspace/didChangeWatchedFiles` notification.
didChangeWatchedFiles?: {
--Did change watched files notification supports dynamic registration. Please note
--that the current protocol doesn't support static configuration for file changes
--from the server side.
dynamicRegistration?: boolean;
};
--Capabilities specific to the `workspace/symbol` request.
symbol?: {
--Symbol request supports dynamic registration.
dynamicRegistration?: boolean;
--Specific capabilities for the `SymbolKind` in the `workspace/symbol` request.
symbolKind?: {
--The symbol kind values the client supports. When this
--property exists the client also guarantees that it will
--handle values outside its set gracefully and falls back
--to a default value when unknown.
--
--If this property is not present the client only supports
--the symbol kinds from `File` to `Array` as defined in
--the initial version of the protocol.
valueSet?: SymbolKind[];
}
};
--Capabilities specific to the `workspace/executeCommand` request.
executeCommand?: {
--Execute command supports dynamic registration.
dynamicRegistration?: boolean;
};
--The client has support for workspace folders.
--
--Since 3.6.0
workspaceFolders?: boolean;
--The client supports `workspace/configuration` requests.
--
--Since 3.6.0
configuration?: boolean;
}
--]=]
function protocol.make_client_capabilities()
return {
textDocument = {
synchronization = {
dynamicRegistration = false;
-- TODO(ashkan) Send textDocument/willSave before saving (BufWritePre)
willSave = false;
-- TODO(ashkan) Implement textDocument/willSaveWaitUntil
willSaveWaitUntil = false;
-- Send textDocument/didSave after saving (BufWritePost)
didSave = true;
};
completion = {
dynamicRegistration = false;
completionItem = {
-- TODO(tjdevries): Is it possible to implement this in plain lua?
snippetSupport = false;
commitCharactersSupport = false;
preselectSupport = false;
deprecatedSupport = false;
documentationFormat = { protocol.MarkupKind.Markdown; protocol.MarkupKind.PlainText };
};
completionItemKind = {
valueSet = (function()
local res = {}
for k in pairs(protocol.CompletionItemKind) do
if type(k) == 'number' then table.insert(res, k) end
end
return res
end)();
};
-- TODO(tjdevries): Implement this
contextSupport = false;
};
hover = {
dynamicRegistration = false;
contentFormat = { protocol.MarkupKind.Markdown; protocol.MarkupKind.PlainText };
};
signatureHelp = {
dynamicRegistration = false;
signatureInformation = {
documentationFormat = { protocol.MarkupKind.Markdown; protocol.MarkupKind.PlainText };
-- parameterInformation = {
-- labelOffsetSupport = false;
-- };
};
};
references = {
dynamicRegistration = false;
};
documentHighlight = {
dynamicRegistration = false
};
-- documentSymbol = {
-- dynamicRegistration = false;
-- symbolKind = {
-- valueSet = (function()
-- local res = {}
-- for k in pairs(protocol.SymbolKind) do
-- if type(k) == 'string' then table.insert(res, k) end
-- end
-- return res
-- end)();
-- };
-- hierarchicalDocumentSymbolSupport = false;
-- };
};
workspace = nil;
experimental = nil;
}
end
function protocol.make_text_document_position_params()
local position = vim.api.nvim_win_get_cursor(0)
return {
textDocument = {
uri = vim.uri_from_bufnr()
};
position = {
line = position[1] - 1;
character = position[2];
}
}
end
--[=[
export interface DocumentFilter {
--A language id, like `typescript`.
language?: string;
--A Uri [scheme](#Uri.scheme), like `file` or `untitled`.
scheme?: string;
--A glob pattern, like `*.{ts,js}`.
--
--Glob patterns can have the following syntax:
--- `*` to match one or more characters in a path segment
--- `?` to match on one character in a path segment
--- `**` to match any number of path segments, including none
--- `{}` to group conditions (e.g. `**/*.{ts,js}` matches all TypeScript and JavaScript files)
--- `[]` to declare a range of characters to match in a path segment (e.g., `example.[0-9]` to match on `example.0`, `example.1`, …)
--- `[!...]` to negate a range of characters to match in a path segment (e.g., `example.[!0-9]` to match on `example.a`, `example.b`, but not `example.0`)
pattern?: string;
}
--]=]
--[[
--Static registration options to be returned in the initialize request.
interface StaticRegistrationOptions {
--The id used to register the request. The id can be used to deregister
--the request again. See also Registration#id.
id?: string;
}
export interface DocumentFilter {
--A language id, like `typescript`.
language?: string;
--A Uri [scheme](#Uri.scheme), like `file` or `untitled`.
scheme?: string;
--A glob pattern, like `*.{ts,js}`.
--
--Glob patterns can have the following syntax:
--- `*` to match one or more characters in a path segment
--- `?` to match on one character in a path segment
--- `**` to match any number of path segments, including none
--- `{}` to group conditions (e.g. `**/*.{ts,js}` matches all TypeScript and JavaScript files)
--- `[]` to declare a range of characters to match in a path segment (e.g., `example.[0-9]` to match on `example.0`, `example.1`, …)
--- `[!...]` to negate a range of characters to match in a path segment (e.g., `example.[!0-9]` to match on `example.a`, `example.b`, but not `example.0`)
pattern?: string;
}
export type DocumentSelector = DocumentFilter[];
export interface TextDocumentRegistrationOptions {
--A document selector to identify the scope of the registration. If set to null
--the document selector provided on the client side will be used.
documentSelector: DocumentSelector | null;
}
--Code Action options.
export interface CodeActionOptions {
--CodeActionKinds that this server may return.
--
--The list of kinds may be generic, such as `CodeActionKind.Refactor`, or the server
--may list out every specific kind they provide.
codeActionKinds?: CodeActionKind[];
}
interface ServerCapabilities {
--Defines how text documents are synced. Is either a detailed structure defining each notification or
--for backwards compatibility the TextDocumentSyncKind number. If omitted it defaults to `TextDocumentSyncKind.None`.
textDocumentSync?: TextDocumentSyncOptions | number;
--The server provides hover support.
hoverProvider?: boolean;
--The server provides completion support.
completionProvider?: CompletionOptions;
--The server provides signature help support.
signatureHelpProvider?: SignatureHelpOptions;
--The server provides goto definition support.
definitionProvider?: boolean;
--The server provides Goto Type Definition support.
--
--Since 3.6.0
typeDefinitionProvider?: boolean | (TextDocumentRegistrationOptions & StaticRegistrationOptions);
--The server provides Goto Implementation support.
--
--Since 3.6.0
implementationProvider?: boolean | (TextDocumentRegistrationOptions & StaticRegistrationOptions);
--The server provides find references support.
referencesProvider?: boolean;
--The server provides document highlight support.
documentHighlightProvider?: boolean;
--The server provides document symbol support.
documentSymbolProvider?: boolean;
--The server provides workspace symbol support.
workspaceSymbolProvider?: boolean;
--The server provides code actions. The `CodeActionOptions` return type is only
--valid if the client signals code action literal support via the property
--`textDocument.codeAction.codeActionLiteralSupport`.
codeActionProvider?: boolean | CodeActionOptions;
--The server provides code lens.
codeLensProvider?: CodeLensOptions;
--The server provides document formatting.
documentFormattingProvider?: boolean;
--The server provides document range formatting.
documentRangeFormattingProvider?: boolean;
--The server provides document formatting on typing.
documentOnTypeFormattingProvider?: DocumentOnTypeFormattingOptions;
--The server provides rename support. RenameOptions may only be
--specified if the client states that it supports
--`prepareSupport` in its initial `initialize` request.
renameProvider?: boolean | RenameOptions;
--The server provides document link support.
documentLinkProvider?: DocumentLinkOptions;
--The server provides color provider support.
--
--Since 3.6.0
colorProvider?: boolean | ColorProviderOptions | (ColorProviderOptions & TextDocumentRegistrationOptions & StaticRegistrationOptions);
--The server provides folding provider support.
--
--Since 3.10.0
foldingRangeProvider?: boolean | FoldingRangeProviderOptions | (FoldingRangeProviderOptions & TextDocumentRegistrationOptions & StaticRegistrationOptions);
--The server provides go to declaration support.
--
--Since 3.14.0
declarationProvider?: boolean | (TextDocumentRegistrationOptions & StaticRegistrationOptions);
--The server provides execute command support.
executeCommandProvider?: ExecuteCommandOptions;
--Workspace specific server capabilities
workspace?: {
--The server supports workspace folder.
--
--Since 3.6.0
workspaceFolders?: {
* The server has support for workspace folders
supported?: boolean;
* Whether the server wants to receive workspace folder
* change notifications.
*
* If a strings is provided the string is treated as a ID
* under which the notification is registered on the client
* side. The ID can be used to unregister for these events
* using the `client/unregisterCapability` request.
changeNotifications?: string | boolean;
}
}
--Experimental server capabilities.
experimental?: any;
}
--]]
function protocol.resolve_capabilities(server_capabilities)
local general_properties = {}
local text_document_sync_properties
do
local TextDocumentSyncKind = protocol.TextDocumentSyncKind
local textDocumentSync = server_capabilities.textDocumentSync
if textDocumentSync == nil then
-- Defaults if omitted.
text_document_sync_properties = {
text_document_open_close = false;
text_document_did_change = TextDocumentSyncKind.None;
-- text_document_did_change = false;
text_document_will_save = false;
text_document_will_save_wait_until = false;
text_document_save = false;
text_document_save_include_text = false;
}
elseif type(textDocumentSync) == 'number' then
-- Backwards compatibility
if not TextDocumentSyncKind[textDocumentSync] then
return nil, "Invalid server TextDocumentSyncKind for textDocumentSync"
end
text_document_sync_properties = {
text_document_open_close = true;
text_document_did_change = textDocumentSync;
text_document_will_save = false;
text_document_will_save_wait_until = false;
text_document_save = false;
text_document_save_include_text = false;
}
elseif type(textDocumentSync) == 'table' then
text_document_sync_properties = {
text_document_open_close = ifnil(textDocumentSync.openClose, false);
text_document_did_change = ifnil(textDocumentSync.change, TextDocumentSyncKind.None);
text_document_will_save = ifnil(textDocumentSync.willSave, false);
text_document_will_save_wait_until = ifnil(textDocumentSync.willSaveWaitUntil, false);
text_document_save = ifnil(textDocumentSync.save, false);
text_document_save_include_text = ifnil(textDocumentSync.save and textDocumentSync.save.includeText, false);
}
else
return nil, string.format("Invalid type for textDocumentSync: %q", type(textDocumentSync))
end
end
general_properties.hover = server_capabilities.hoverProvider or false
general_properties.goto_definition = server_capabilities.definitionProvider or false
general_properties.find_references = server_capabilities.referencesProvider or false
general_properties.document_highlight = server_capabilities.documentHighlightProvider or false
general_properties.document_symbol = server_capabilities.documentSymbolProvider or false
general_properties.workspace_symbol = server_capabilities.workspaceSymbolProvider or false
general_properties.document_formatting = server_capabilities.documentFormattingProvider or false
general_properties.document_range_formatting = server_capabilities.documentRangeFormattingProvider or false
if server_capabilities.codeActionProvider == nil then
general_properties.code_action = false
elseif type(server_capabilities.codeActionProvider) == 'boolean' then
general_properties.code_action = server_capabilities.codeActionProvider
elseif type(server_capabilities.codeActionProvider) == 'table' then
-- TODO(ashkan) support CodeActionKind
general_properties.code_action = false
else
error("The server sent invalid codeActionProvider")
end
if server_capabilities.implementationProvider == nil then
general_properties.implementation = false
elseif type(server_capabilities.implementationProvider) == 'boolean' then
general_properties.implementation = server_capabilities.implementationProvider
elseif type(server_capabilities.implementationProvider) == 'table' then
-- TODO(ashkan) support more detailed implementation options.
general_properties.implementation = false
else
error("The server sent invalid implementationProvider")
end
local signature_help_properties
if server_capabilities.signatureHelpProvider == nil then
signature_help_properties = {
signature_help = false;
signature_help_trigger_characters = {};
}
elseif type(server_capabilities.signatureHelpProvider) == 'table' then
signature_help_properties = {
signature_help = true;
-- The characters that trigger signature help automatically.
signature_help_trigger_characters = server_capabilities.signatureHelpProvider.triggerCharacters or {};
}
else
error("The server sent invalid signatureHelpProvider")
end
return vim.tbl_extend("error"
, text_document_sync_properties
, signature_help_properties
, general_properties
)
end
return protocol
-- vim:sw=2 ts=2 et

451
runtime/lua/vim/lsp/rpc.lua Normal file
View File

@ -0,0 +1,451 @@
local uv = vim.loop
local log = require('vim.lsp.log')
local protocol = require('vim.lsp.protocol')
local validate, schedule, schedule_wrap = vim.validate, vim.schedule, vim.schedule_wrap
-- TODO replace with a better implementation.
local function json_encode(data)
local status, result = pcall(vim.fn.json_encode, data)
if status then
return result
else
return nil, result
end
end
local function json_decode(data)
local status, result = pcall(vim.fn.json_decode, data)
if status then
return result
else
return nil, result
end
end
local function is_dir(filename)
local stat = vim.loop.fs_stat(filename)
return stat and stat.type == 'directory' or false
end
local NIL = vim.NIL
local function convert_NIL(v)
if v == NIL then return nil end
return v
end
-- If a dictionary is passed in, turn it into a list of string of "k=v"
-- Accepts a table which can be composed of k=v strings or map-like
-- specification, such as:
--
-- ```
-- {
-- "PRODUCTION=false";
-- "PATH=/usr/bin/";
-- PORT = 123;
-- HOST = "0.0.0.0";
-- }
-- ```
--
-- Non-string values will be cast with `tostring`
local function force_env_list(final_env)
if final_env then
local env = final_env
final_env = {}
for k,v in pairs(env) do
-- If it's passed in as a dict, then convert to list of "k=v"
if type(k) == "string" then
table.insert(final_env, k..'='..tostring(v))
elseif type(v) == 'string' then
table.insert(final_env, v)
else
-- TODO is this right or should I exception here?
-- Try to coerce other values to string.
table.insert(final_env, tostring(v))
end
end
return final_env
end
end
local function format_message_with_content_length(encoded_message)
return table.concat {
'Content-Length: '; tostring(#encoded_message); '\r\n\r\n';
encoded_message;
}
end
--- Parse an LSP Message's header
-- @param header: The header to parse.
local function parse_headers(header)
if type(header) ~= 'string' then
return nil
end
local headers = {}
for line in vim.gsplit(header, '\r\n', true) do
if line == '' then
break
end
local key, value = line:match("^%s*(%S+)%s*:%s*(.+)%s*$")
if key then
key = key:lower():gsub('%-', '_')
headers[key] = value
else
local _ = log.error() and log.error("invalid header line %q", line)
error(string.format("invalid header line %q", line))
end
end
headers.content_length = tonumber(headers.content_length)
or error(string.format("Content-Length not found in headers. %q", header))
return headers
end
-- This is the start of any possible header patterns. The gsub converts it to a
-- case insensitive pattern.
local header_start_pattern = ("content"):gsub("%w", function(c) return "["..c..c:upper().."]" end)
local function request_parser_loop()
local buffer = ''
while true do
-- A message can only be complete if it has a double CRLF and also the full
-- payload, so first let's check for the CRLFs
local start, finish = buffer:find('\r\n\r\n', 1, true)
-- Start parsing the headers
if start then
-- This is a workaround for servers sending initial garbage before
-- sending headers, such as if a bash script sends stdout. It assumes
-- that we know all of the headers ahead of time. At this moment, the
-- only valid headers start with "Content-*", so that's the thing we will
-- be searching for.
-- TODO(ashkan) I'd like to remove this, but it seems permanent :(
local buffer_start = buffer:find(header_start_pattern)
local headers = parse_headers(buffer:sub(buffer_start, start-1))
buffer = buffer:sub(finish+1)
local content_length = headers.content_length
-- Keep waiting for data until we have enough.
while #buffer < content_length do
buffer = buffer..(coroutine.yield()
or error("Expected more data for the body. The server may have died.")) -- TODO hmm.
end
local body = buffer:sub(1, content_length)
buffer = buffer:sub(content_length + 1)
-- Yield our data.
buffer = buffer..(coroutine.yield(headers, body)
or error("Expected more data for the body. The server may have died.")) -- TODO hmm.
else
-- Get more data since we don't have enough.
buffer = buffer..(coroutine.yield()
or error("Expected more data for the header. The server may have died.")) -- TODO hmm.
end
end
end
local client_errors = vim.tbl_add_reverse_lookup {
INVALID_SERVER_MESSAGE = 1;
INVALID_SERVER_JSON = 2;
NO_RESULT_CALLBACK_FOUND = 3;
READ_ERROR = 4;
NOTIFICATION_HANDLER_ERROR = 5;
SERVER_REQUEST_HANDLER_ERROR = 6;
SERVER_RESULT_CALLBACK_ERROR = 7;
}
local function format_rpc_error(err)
validate {
err = { err, 't' };
}
local code_name = assert(protocol.ErrorCodes[err.code], "err.code is invalid")
local message_parts = {"RPC", code_name}
if err.message then
table.insert(message_parts, "message = ")
table.insert(message_parts, string.format("%q", err.message))
end
if err.data then
table.insert(message_parts, "data = ")
table.insert(message_parts, vim.inspect(err.data))
end
return table.concat(message_parts, ' ')
end
local function rpc_response_error(code, message, data)
-- TODO should this error or just pick a sane error (like InternalError)?
local code_name = assert(protocol.ErrorCodes[code], 'Invalid rpc error code')
return setmetatable({
code = code;
message = message or code_name;
data = data;
}, {
__tostring = format_rpc_error;
})
end
local default_handlers = {}
function default_handlers.notification(method, params)
local _ = log.debug() and log.debug('notification', method, params)
end
function default_handlers.server_request(method, params)
local _ = log.debug() and log.debug('server_request', method, params)
return nil, rpc_response_error(protocol.ErrorCodes.MethodNotFound)
end
function default_handlers.on_exit(code, signal)
local _ = log.info() and log.info("client exit", { code = code, signal = signal })
end
function default_handlers.on_error(code, err)
local _ = log.error() and log.error('client_error:', client_errors[code], err)
end
--- Create and start an RPC client.
-- @param cmd [
local function create_and_start_client(cmd, cmd_args, handlers, extra_spawn_params)
local _ = log.info() and log.info("Starting RPC client", {cmd = cmd, args = cmd_args, extra = extra_spawn_params})
validate {
cmd = { cmd, 's' };
cmd_args = { cmd_args, 't' };
handlers = { handlers, 't', true };
}
if not (vim.fn.executable(cmd) == 1) then
error(string.format("The given command %q is not executable.", cmd))
end
if handlers then
local user_handlers = handlers
handlers = {}
for handle_name, default_handler in pairs(default_handlers) do
local user_handler = user_handlers[handle_name]
if user_handler then
if type(user_handler) ~= 'function' then
error(string.format("handler.%s must be a function", handle_name))
end
-- server_request is wrapped elsewhere.
if not (handle_name == 'server_request'
or handle_name == 'on_exit') -- TODO this blocks the loop exiting for some reason.
then
user_handler = schedule_wrap(user_handler)
end
handlers[handle_name] = user_handler
else
handlers[handle_name] = default_handler
end
end
else
handlers = default_handlers
end
local stdin = uv.new_pipe(false)
local stdout = uv.new_pipe(false)
local stderr = uv.new_pipe(false)
local message_index = 0
local message_callbacks = {}
local handle, pid
do
local function onexit(code, signal)
stdin:close()
stdout:close()
stderr:close()
handle:close()
-- Make sure that message_callbacks can be gc'd.
message_callbacks = nil
handlers.on_exit(code, signal)
end
local spawn_params = {
args = cmd_args;
stdio = {stdin, stdout, stderr};
}
if extra_spawn_params then
spawn_params.cwd = extra_spawn_params.cwd
if spawn_params.cwd then
assert(is_dir(spawn_params.cwd), "cwd must be a directory")
end
spawn_params.env = force_env_list(extra_spawn_params.env)
end
handle, pid = uv.spawn(cmd, spawn_params, onexit)
end
local function encode_and_send(payload)
local _ = log.debug() and log.debug("rpc.send.payload", payload)
if handle:is_closing() then return false end
-- TODO(ashkan) remove this once we have a Lua json_encode
schedule(function()
local encoded = assert(json_encode(payload))
stdin:write(format_message_with_content_length(encoded))
end)
return true
end
local function send_notification(method, params)
local _ = log.debug() and log.debug("rpc.notify", method, params)
return encode_and_send {
jsonrpc = "2.0";
method = method;
params = params;
}
end
local function send_response(request_id, err, result)
return encode_and_send {
id = request_id;
jsonrpc = "2.0";
error = err;
result = result;
}
end
local function send_request(method, params, callback)
validate {
callback = { callback, 'f' };
}
message_index = message_index + 1
local message_id = message_index
local result = encode_and_send {
id = message_id;
jsonrpc = "2.0";
method = method;
params = params;
}
if result then
message_callbacks[message_id] = schedule_wrap(callback)
return result, message_id
else
return false
end
end
stderr:read_start(function(_err, chunk)
if chunk then
local _ = log.error() and log.error("rpc", cmd, "stderr", chunk)
end
end)
local function on_error(errkind, ...)
assert(client_errors[errkind])
-- TODO what to do if this fails?
pcall(handlers.on_error, errkind, ...)
end
local function pcall_handler(errkind, status, head, ...)
if not status then
on_error(errkind, head, ...)
return status, head
end
return status, head, ...
end
local function try_call(errkind, fn, ...)
return pcall_handler(errkind, pcall(fn, ...))
end
-- TODO periodically check message_callbacks for old requests past a certain
-- time and log them. This would require storing the timestamp. I could call
-- them with an error then, perhaps.
local function handle_body(body)
local decoded, err = json_decode(body)
if not decoded then
on_error(client_errors.INVALID_SERVER_JSON, err)
end
local _ = log.debug() and log.debug("decoded", decoded)
if type(decoded.method) == 'string' and decoded.id then
-- Server Request
decoded.params = convert_NIL(decoded.params)
-- Schedule here so that the users functions don't trigger an error and
-- we can still use the result.
schedule(function()
local status, result
status, result, err = try_call(client_errors.SERVER_REQUEST_HANDLER_ERROR,
handlers.server_request, decoded.method, decoded.params)
local _ = log.debug() and log.debug("server_request: callback result", { status = status, result = result, err = err })
if status then
if not (result or err) then
-- TODO this can be a problem if `null` is sent for result. needs vim.NIL
error(string.format("method %q: either a result or an error must be sent to the server in response", decoded.method))
end
if err then
assert(type(err) == 'table', "err must be a table. Use rpc_response_error to help format errors.")
local code_name = assert(protocol.ErrorCodes[err.code], "Errors must use protocol.ErrorCodes. Use rpc_response_error to help format errors.")
err.message = err.message or code_name
end
else
-- On an exception, result will contain the error message.
err = rpc_response_error(protocol.ErrorCodes.InternalError, result)
result = nil
end
send_response(decoded.id, err, result)
end)
-- This works because we are expecting vim.NIL here
elseif decoded.id and (decoded.result or decoded.error) then
-- Server Result
decoded.error = convert_NIL(decoded.error)
decoded.result = convert_NIL(decoded.result)
-- We sent a number, so we expect a number.
local result_id = tonumber(decoded.id)
local callback = message_callbacks[result_id]
if callback then
message_callbacks[result_id] = nil
validate {
callback = { callback, 'f' };
}
if decoded.error then
decoded.error = setmetatable(decoded.error, {
__tostring = format_rpc_error;
})
end
try_call(client_errors.SERVER_RESULT_CALLBACK_ERROR,
callback, decoded.error, decoded.result)
else
on_error(client_errors.NO_RESULT_CALLBACK_FOUND, decoded)
local _ = log.error() and log.error("No callback found for server response id "..result_id)
end
elseif type(decoded.method) == 'string' then
-- Notification
decoded.params = convert_NIL(decoded.params)
try_call(client_errors.NOTIFICATION_HANDLER_ERROR,
handlers.notification, decoded.method, decoded.params)
else
-- Invalid server message
on_error(client_errors.INVALID_SERVER_MESSAGE, decoded)
end
end
-- TODO(ashkan) remove this once we have a Lua json_decode
handle_body = schedule_wrap(handle_body)
local request_parser = coroutine.wrap(request_parser_loop)
request_parser()
stdout:read_start(function(err, chunk)
if err then
-- TODO better handling. Can these be intermittent errors?
on_error(client_errors.READ_ERROR, err)
return
end
-- This should signal that we are done reading from the client.
if not chunk then return end
-- Flush anything in the parser by looping until we don't get a result
-- anymore.
while true do
local headers, body = request_parser(chunk)
-- If we successfully parsed, then handle the response.
if headers then
handle_body(body)
-- Set chunk to empty so that we can call request_parser to get
-- anything existing in the parser to flush.
chunk = ''
else
break
end
end
end)
return {
pid = pid;
handle = handle;
request = send_request;
notify = send_notification;
}
end
return {
start = create_and_start_client;
rpc_response_error = rpc_response_error;
format_rpc_error = format_rpc_error;
client_errors = client_errors;
}
-- vim:sw=2 ts=2 et

View File

@ -0,0 +1,557 @@
local protocol = require 'vim.lsp.protocol'
local validate = vim.validate
local api = vim.api
local M = {}
local split = vim.split
local function split_lines(value)
return split(value, '\n', true)
end
local list_extend = vim.list_extend
--- Find the longest shared prefix between prefix and word.
-- e.g. remove_prefix("123tes", "testing") == "ting"
local function remove_prefix(prefix, word)
local max_prefix_length = math.min(#prefix, #word)
local prefix_length = 0
for i = 1, max_prefix_length do
local current_line_suffix = prefix:sub(-i)
local word_prefix = word:sub(1, i)
if current_line_suffix == word_prefix then
prefix_length = i
end
end
return word:sub(prefix_length + 1)
end
local function resolve_bufnr(bufnr)
if bufnr == nil or bufnr == 0 then
return api.nvim_get_current_buf()
end
return bufnr
end
-- local valid_windows_path_characters = "[^<>:\"/\\|?*]"
-- local valid_unix_path_characters = "[^/]"
-- https://github.com/davidm/lua-glob-pattern
-- https://stackoverflow.com/questions/1976007/what-characters-are-forbidden-in-windows-and-linux-directory-names
-- function M.glob_to_regex(glob)
-- end
--- Apply the TextEdit response.
-- @params TextEdit [table] see https://microsoft.github.io/language-server-protocol/specification
function M.text_document_apply_text_edit(text_edit, bufnr)
bufnr = resolve_bufnr(bufnr)
local range = text_edit.range
local start = range.start
local finish = range['end']
local new_lines = split_lines(text_edit.newText)
if start.character == 0 and finish.character == 0 then
api.nvim_buf_set_lines(bufnr, start.line, finish.line, false, new_lines)
return
end
api.nvim_err_writeln('apply_text_edit currently only supports character ranges starting at 0')
error('apply_text_edit currently only supports character ranges starting at 0')
return
-- TODO test and finish this support for character ranges.
-- local lines = api.nvim_buf_get_lines(0, start.line, finish.line + 1, false)
-- local suffix = lines[#lines]:sub(finish.character+2)
-- local prefix = lines[1]:sub(start.character+2)
-- new_lines[#new_lines] = new_lines[#new_lines]..suffix
-- new_lines[1] = prefix..new_lines[1]
-- api.nvim_buf_set_lines(0, start.line, finish.line, false, new_lines)
end
-- textDocument/completion response returns one of CompletionItem[], CompletionList or null.
-- https://microsoft.github.io/language-server-protocol/specification#textDocument_completion
function M.extract_completion_items(result)
if type(result) == 'table' and result.items then
return result.items
elseif result ~= nil then
return result
else
return {}
end
end
--- Apply the TextDocumentEdit response.
-- @params TextDocumentEdit [table] see https://microsoft.github.io/language-server-protocol/specification
function M.text_document_apply_text_document_edit(text_document_edit, bufnr)
-- local text_document = text_document_edit.textDocument
-- TODO use text_document_version?
-- local text_document_version = text_document.version
-- TODO technically, you could do this without doing multiple buf_get/set
-- by getting the full region (smallest line and largest line) and doing
-- the edits on the buffer, and then applying the buffer at the end.
-- I'm not sure if that's better.
for _, text_edit in ipairs(text_document_edit.edits) do
M.text_document_apply_text_edit(text_edit, bufnr)
end
end
function M.get_current_line_to_cursor()
local pos = api.nvim_win_get_cursor(0)
local line = assert(api.nvim_buf_get_lines(0, pos[1]-1, pos[1], false)[1])
return line:sub(pos[2]+1)
end
--- Getting vim complete-items with incomplete flag.
-- @params CompletionItem[], CompletionList or nil (https://microsoft.github.io/language-server-protocol/specification#textDocument_completion)
-- @return { matches = complete-items table, incomplete = boolean }
function M.text_document_completion_list_to_complete_items(result, line_prefix)
local items = M.extract_completion_items(result)
if vim.tbl_isempty(items) then
return {}
end
-- Only initialize if we have some items.
if not line_prefix then
line_prefix = M.get_current_line_to_cursor()
end
local matches = {}
for _, completion_item in ipairs(items) do
local info = ' '
local documentation = completion_item.documentation
if documentation then
if type(documentation) == 'string' and documentation ~= '' then
info = documentation
elseif type(documentation) == 'table' and type(documentation.value) == 'string' then
info = documentation.value
-- else
-- TODO(ashkan) Validation handling here?
end
end
local word = completion_item.insertText or completion_item.label
-- Ref: `:h complete-items`
table.insert(matches, {
word = remove_prefix(line_prefix, word),
abbr = completion_item.label,
kind = protocol.CompletionItemKind[completion_item.kind] or '',
menu = completion_item.detail or '',
info = info,
icase = 1,
dup = 0,
empty = 1,
})
end
return matches
end
-- @params WorkspaceEdit [table] see https://microsoft.github.io/language-server-protocol/specification
function M.workspace_apply_workspace_edit(workspace_edit)
if workspace_edit.documentChanges then
for _, change in ipairs(workspace_edit.documentChanges) do
if change.kind then
-- TODO(ashkan) handle CreateFile/RenameFile/DeleteFile
error(string.format("Unsupported change: %q", vim.inspect(change)))
else
M.text_document_apply_text_document_edit(change)
end
end
return
end
if workspace_edit.changes == nil or #workspace_edit.changes == 0 then
return
end
for uri, changes in pairs(workspace_edit.changes) do
local fname = vim.uri_to_fname(uri)
-- TODO improve this approach. Try to edit open buffers without switching.
-- Not sure how to handle files which aren't open. This is deprecated
-- anyway, so I guess it could be left as is.
api.nvim_command('edit '..fname)
for _, change in ipairs(changes) do
M.text_document_apply_text_edit(change)
end
end
end
--- Convert any of MarkedString | MarkedString[] | MarkupContent into markdown text lines
-- see https://microsoft.github.io/language-server-protocol/specifications/specification-3-14/#textDocument_hover
-- Useful for textDocument/hover, textDocument/signatureHelp, and potentially others.
function M.convert_input_to_markdown_lines(input, contents)
contents = contents or {}
-- MarkedString variation 1
if type(input) == 'string' then
list_extend(contents, split_lines(input))
else
assert(type(input) == 'table', "Expected a table for Hover.contents")
-- MarkupContent
if input.kind then
-- The kind can be either plaintext or markdown. However, either way we
-- will just be rendering markdown, so we handle them both the same way.
-- TODO these can have escaped/sanitized html codes in markdown. We
-- should make sure we handle this correctly.
-- Some servers send input.value as empty, so let's ignore this :(
-- assert(type(input.value) == 'string')
list_extend(contents, split_lines(input.value or ''))
-- MarkupString variation 2
elseif input.language then
-- Some servers send input.value as empty, so let's ignore this :(
-- assert(type(input.value) == 'string')
table.insert(contents, "```"..input.language)
list_extend(contents, split_lines(input.value or ''))
table.insert(contents, "```")
-- By deduction, this must be MarkedString[]
else
-- Use our existing logic to handle MarkedString
for _, marked_string in ipairs(input) do
M.convert_input_to_markdown_lines(marked_string, contents)
end
end
end
if contents[1] == '' or contents[1] == nil then
return {}
end
return contents
end
function M.make_floating_popup_options(width, height, opts)
validate {
opts = { opts, 't', true };
}
opts = opts or {}
validate {
["opts.offset_x"] = { opts.offset_x, 'n', true };
["opts.offset_y"] = { opts.offset_y, 'n', true };
}
local anchor = ''
local row, col
if vim.fn.winline() <= height then
anchor = anchor..'N'
row = 1
else
anchor = anchor..'S'
row = 0
end
if vim.fn.wincol() + width <= api.nvim_get_option('columns') then
anchor = anchor..'W'
col = 0
else
anchor = anchor..'E'
col = 1
end
return {
anchor = anchor,
col = col + (opts.offset_x or 0),
height = height,
relative = 'cursor',
row = row + (opts.offset_y or 0),
style = 'minimal',
width = width,
}
end
function M.open_floating_preview(contents, filetype, opts)
validate {
contents = { contents, 't' };
filetype = { filetype, 's', true };
opts = { opts, 't', true };
}
-- Trim empty lines from the end.
for i = #contents, 1, -1 do
if #contents[i] == 0 then
table.remove(contents)
else
break
end
end
local width = 0
local height = #contents
for i, line in ipairs(contents) do
-- Clean up the input and add left pad.
line = " "..line:gsub("\r", "")
-- TODO(ashkan) use nvim_strdisplaywidth if/when that is introduced.
local line_width = vim.fn.strdisplaywidth(line)
width = math.max(line_width, width)
contents[i] = line
end
-- Add right padding of 1 each.
width = width + 1
local floating_bufnr = api.nvim_create_buf(false, true)
if filetype then
api.nvim_buf_set_option(floating_bufnr, 'filetype', filetype)
end
local float_option = M.make_floating_popup_options(width, height, opts)
local floating_winnr = api.nvim_open_win(floating_bufnr, false, float_option)
if filetype == 'markdown' then
api.nvim_win_set_option(floating_winnr, 'conceallevel', 2)
end
api.nvim_buf_set_lines(floating_bufnr, 0, -1, true, contents)
api.nvim_buf_set_option(floating_bufnr, 'modifiable', false)
api.nvim_command("autocmd CursorMoved <buffer> ++once lua pcall(vim.api.nvim_win_close, "..floating_winnr..", true)")
return floating_bufnr, floating_winnr
end
local function validate_lsp_position(pos)
validate { pos = {pos, 't'} }
validate {
line = {pos.line, 'n'};
character = {pos.character, 'n'};
}
return true
end
function M.open_floating_peek_preview(bufnr, start, finish, opts)
validate {
bufnr = {bufnr, 'n'};
start = {start, validate_lsp_position, 'valid start Position'};
finish = {finish, validate_lsp_position, 'valid finish Position'};
opts = { opts, 't', true };
}
local width = math.max(finish.character - start.character + 1, 1)
local height = math.max(finish.line - start.line + 1, 1)
local floating_winnr = api.nvim_open_win(bufnr, false, M.make_floating_popup_options(width, height, opts))
api.nvim_win_set_cursor(floating_winnr, {start.line+1, start.character})
api.nvim_command("autocmd CursorMoved * ++once lua pcall(vim.api.nvim_win_close, "..floating_winnr..", true)")
return floating_winnr
end
local function highlight_range(bufnr, ns, hiname, start, finish)
if start[1] == finish[1] then
-- TODO care about encoding here since this is in byte index?
api.nvim_buf_add_highlight(bufnr, ns, hiname, start[1], start[2], finish[2])
else
api.nvim_buf_add_highlight(bufnr, ns, hiname, start[1], start[2], -1)
for line = start[1] + 1, finish[1] - 1 do
api.nvim_buf_add_highlight(bufnr, ns, hiname, line, 0, -1)
end
api.nvim_buf_add_highlight(bufnr, ns, hiname, finish[1], 0, finish[2])
end
end
do
local all_buffer_diagnostics = {}
local diagnostic_ns = api.nvim_create_namespace("vim_lsp_diagnostics")
local default_severity_highlight = {
[protocol.DiagnosticSeverity.Error] = { guifg = "Red" };
[protocol.DiagnosticSeverity.Warning] = { guifg = "Orange" };
[protocol.DiagnosticSeverity.Information] = { guifg = "LightBlue" };
[protocol.DiagnosticSeverity.Hint] = { guifg = "LightGrey" };
}
local underline_highlight_name = "LspDiagnosticsUnderline"
api.nvim_command(string.format("highlight %s gui=underline cterm=underline", underline_highlight_name))
local function find_color_rgb(color)
local rgb_hex = api.nvim_get_color_by_name(color)
validate { color = {color, function() return rgb_hex ~= -1 end, "valid color name"} }
return rgb_hex
end
--- Determine whether to use black or white text
-- Ref: https://stackoverflow.com/a/1855903/837964
-- https://stackoverflow.com/questions/596216/formula-to-determine-brightness-of-rgb-color
local function color_is_bright(r, g, b)
-- Counting the perceptive luminance - human eye favors green color
local luminance = (0.299*r + 0.587*g + 0.114*b)/255
if luminance > 0.5 then
return true -- Bright colors, black font
else
return false -- Dark colors, white font
end
end
local severity_highlights = {}
function M.set_severity_highlights(highlights)
validate {highlights = {highlights, 't'}}
for severity, default_color in pairs(default_severity_highlight) do
local severity_name = protocol.DiagnosticSeverity[severity]
local highlight_name = "LspDiagnostics"..severity_name
local hi_info = highlights[severity] or default_color
-- Try to fill in the foreground color with a sane default.
if not hi_info.guifg and hi_info.guibg then
-- TODO(ashkan) move this out when bitop is guaranteed to be included.
local bit = require 'bit'
local band, rshift = bit.band, bit.rshift
local rgb = find_color_rgb(hi_info.guibg)
local is_bright = color_is_bright(rshift(rgb, 16), band(rshift(rgb, 8), 0xFF), band(rgb, 0xFF))
hi_info.guifg = is_bright and "Black" or "White"
end
if not hi_info.ctermfg and hi_info.ctermbg then
-- TODO(ashkan) move this out when bitop is guaranteed to be included.
local bit = require 'bit'
local band, rshift = bit.band, bit.rshift
local rgb = find_color_rgb(hi_info.ctermbg)
local is_bright = color_is_bright(rshift(rgb, 16), band(rshift(rgb, 8), 0xFF), band(rgb, 0xFF))
hi_info.ctermfg = is_bright and "Black" or "White"
end
local cmd_parts = {"highlight", highlight_name}
for k, v in pairs(hi_info) do
table.insert(cmd_parts, k.."="..v)
end
api.nvim_command(table.concat(cmd_parts, ' '))
severity_highlights[severity] = highlight_name
end
end
function M.buf_clear_diagnostics(bufnr)
validate { bufnr = {bufnr, 'n', true} }
bufnr = bufnr == 0 and api.nvim_get_current_buf() or bufnr
api.nvim_buf_clear_namespace(bufnr, diagnostic_ns, 0, -1)
end
-- Initialize with the defaults.
M.set_severity_highlights(default_severity_highlight)
function M.get_severity_highlight_name(severity)
return severity_highlights[severity]
end
function M.show_line_diagnostics()
local bufnr = api.nvim_get_current_buf()
local line = api.nvim_win_get_cursor(0)[1] - 1
-- local marks = api.nvim_buf_get_extmarks(bufnr, diagnostic_ns, {line, 0}, {line, -1}, {})
-- if #marks == 0 then
-- return
-- end
-- local buffer_diagnostics = all_buffer_diagnostics[bufnr]
local lines = {"Diagnostics:"}
local highlights = {{0, "Bold"}}
local buffer_diagnostics = all_buffer_diagnostics[bufnr]
if not buffer_diagnostics then return end
local line_diagnostics = buffer_diagnostics[line]
if not line_diagnostics then return end
for i, diagnostic in ipairs(line_diagnostics) do
-- for i, mark in ipairs(marks) do
-- local mark_id = mark[1]
-- local diagnostic = buffer_diagnostics[mark_id]
-- TODO(ashkan) make format configurable?
local prefix = string.format("%d. ", i)
local hiname = severity_highlights[diagnostic.severity]
local message_lines = split_lines(diagnostic.message)
table.insert(lines, prefix..message_lines[1])
table.insert(highlights, {#prefix + 1, hiname})
for j = 2, #message_lines do
table.insert(lines, message_lines[j])
table.insert(highlights, {0, hiname})
end
end
local popup_bufnr, winnr = M.open_floating_preview(lines, 'plaintext')
for i, hi in ipairs(highlights) do
local prefixlen, hiname = unpack(hi)
-- Start highlight after the prefix
api.nvim_buf_add_highlight(popup_bufnr, -1, hiname, i-1, prefixlen, -1)
end
return popup_bufnr, winnr
end
function M.buf_diagnostics_save_positions(bufnr, diagnostics)
validate {
bufnr = {bufnr, 'n', true};
diagnostics = {diagnostics, 't', true};
}
if not diagnostics then return end
bufnr = bufnr == 0 and api.nvim_get_current_buf() or bufnr
if not all_buffer_diagnostics[bufnr] then
-- Clean up our data when the buffer unloads.
api.nvim_buf_attach(bufnr, false, {
on_detach = function(b)
all_buffer_diagnostics[b] = nil
end
})
end
all_buffer_diagnostics[bufnr] = {}
local buffer_diagnostics = all_buffer_diagnostics[bufnr]
for _, diagnostic in ipairs(diagnostics) do
local start = diagnostic.range.start
-- local mark_id = api.nvim_buf_set_extmark(bufnr, diagnostic_ns, 0, start.line, 0, {})
-- buffer_diagnostics[mark_id] = diagnostic
local line_diagnostics = buffer_diagnostics[start.line]
if not line_diagnostics then
line_diagnostics = {}
buffer_diagnostics[start.line] = line_diagnostics
end
table.insert(line_diagnostics, diagnostic)
end
end
function M.buf_diagnostics_underline(bufnr, diagnostics)
for _, diagnostic in ipairs(diagnostics) do
local start = diagnostic.range.start
local finish = diagnostic.range["end"]
-- TODO care about encoding here since this is in byte index?
highlight_range(bufnr, diagnostic_ns, underline_highlight_name,
{start.line, start.character},
{finish.line, finish.character}
)
end
end
function M.buf_diagnostics_virtual_text(bufnr, diagnostics)
local buffer_line_diagnostics = all_buffer_diagnostics[bufnr]
if not buffer_line_diagnostics then
M.buf_diagnostics_save_positions(bufnr, diagnostics)
end
buffer_line_diagnostics = all_buffer_diagnostics[bufnr]
if not buffer_line_diagnostics then
return
end
for line, line_diags in pairs(buffer_line_diagnostics) do
local virt_texts = {}
for i = 1, #line_diags - 1 do
table.insert(virt_texts, {"", severity_highlights[line_diags[i].severity]})
end
local last = line_diags[#line_diags]
-- TODO(ashkan) use first line instead of subbing 2 spaces?
table.insert(virt_texts, {""..last.message:gsub("\r", ""):gsub("\n", " "), severity_highlights[last.severity]})
api.nvim_buf_set_virtual_text(bufnr, diagnostic_ns, line, virt_texts, {})
end
end
end
function M.buf_loclist(bufnr, locations)
local targetwin
for _, winnr in ipairs(api.nvim_list_wins()) do
local winbuf = api.nvim_win_get_buf(winnr)
if winbuf == bufnr then
targetwin = winnr
break
end
end
if not targetwin then return end
local items = {}
local path = api.nvim_buf_get_name(bufnr)
for _, d in ipairs(locations) do
-- TODO: URL parsing here?
local start = d.range.start
table.insert(items, {
filename = path,
lnum = start.line + 1,
col = start.character + 1,
text = d.message,
})
end
vim.fn.setloclist(targetwin, items, ' ', 'Language Server')
end
return M
-- vim:sw=2 ts=2 et

View File

@ -98,6 +98,38 @@ function vim.split(s,sep,plain)
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
--- Checks if a list-like (vector) table contains `value`.
---
--@param t Table to check
@ -114,6 +146,16 @@ function vim.tbl_contains(t, value)
return false
end
-- 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
--- Merges two or more map-like tables.
---
--@see |extend()|
@ -145,13 +187,69 @@ function vim.tbl_extend(behavior, ...)
return ret
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 |extend()|
---
--@param dst The list which will be modified and appended to.
--@param src The list from which values will be inserted.
function vim.list_extend(dst, src)
assert(type(dst) == 'table', "dst must be a table")
assert(type(src) == 'table', "src must be a table")
for _, v in ipairs(src) do
table.insert(dst, v)
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.
function vim.tbl_flatten(t)
-- From https://github.com/premake/premake-core/blob/master/src/base/table.lua
local result = {}
local function _tbl_flatten(_t)
local n = #_t
@ -168,6 +266,32 @@ function vim.tbl_flatten(t)
return result
end
-- Determine whether a Lua table can be treated as an array.
---
--@params Table
--@returns true: A non-empty array, false: A non-empty table, nil: An empty table
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
return nil
end
end
--- Trim whitespace (Lua pattern "%s") from both sides of a string.
---
--@see https://www.lua.org/pil/20.2.html
@ -279,3 +403,4 @@ function vim.is_callable(f)
end
return vim
-- vim:sw=2 ts=2 et

89
runtime/lua/vim/uri.lua Normal file
View File

@ -0,0 +1,89 @@
--- 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 function uri_from_bufnr(bufnr)
return uri_from_fname(vim.api.nvim_buf_get_name(bufnr))
end
local function uri_to_fname(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_decode(uri)
end
return {
uri_from_fname = uri_from_fname,
uri_from_bufnr = uri_from_bufnr,
uri_to_fname = uri_to_fname,
}
-- vim:sw=2 ts=2 et

View File

@ -256,6 +256,13 @@ local function __index(t, key)
-- Expose all `vim.shared` functions on the `vim` module.
t[key] = require('vim.shared')[key]
return t[key]
elseif require('vim.uri')[key] ~= nil then
-- Expose all `vim.uri` functions on the `vim` module.
t[key] = require('vim.uri')[key]
return t[key]
elseif key == 'lsp' then
t.lsp = require('vim.lsp')
return t.lsp
end
end

View File

@ -0,0 +1,424 @@
local protocol = require 'vim.lsp.protocol'
-- Internal utility methods.
-- 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 message_parts(sep, ...)
local parts = {}
for i = 1, select("#", ...) do
local arg = select(i, ...)
if arg ~= nil then
table.insert(parts, arg)
end
end
return table.concat(parts, sep)
end
-- Assert utility methods
local function assert_eq(a, b, ...)
if not vim.deep_equal(a, b) then
error(message_parts(": ",
..., "assert_eq failed",
string.format("left == %q, right == %q", vim.inspect(a), vim.inspect(b))
))
end
end
local function format_message_with_content_length(encoded_message)
return table.concat {
'Content-Length: '; tostring(#encoded_message); '\r\n\r\n';
encoded_message;
}
end
-- Server utility methods.
local function read_message()
local line = io.read("*l")
local length = line:lower():match("content%-length:%s*(%d+)")
return assert(json_decode(io.read(2 + length):sub(2)), "read_message.json_decode")
end
local function send(payload)
io.stdout:write(format_message_with_content_length(json_encode(payload)))
end
local function respond(id, err, result)
assert(type(id) == 'number', "id must be a number")
send { jsonrpc = "2.0"; id = id, error = err, result = result }
end
local function notify(method, params)
assert(type(method) == 'string', "method must be a string")
send { method = method, params = params or {} }
end
local function expect_notification(method, params, ...)
local message = read_message()
assert_eq(method, message.method,
..., "expect_notification", "method")
assert_eq(params, message.params,
..., "expect_notification", method, "params")
assert_eq({jsonrpc = "2.0"; method=method, params=params}, message,
..., "expect_notification", "message")
end
local function expect_request(method, callback, ...)
local req = read_message()
assert_eq(method, req.method,
..., "expect_request", "method")
local err, result = callback(req.params)
respond(req.id, err, result)
end
io.stderr:setvbuf("no")
local function skeleton(config)
local on_init = assert(config.on_init)
local body = assert(config.body)
expect_request("initialize", function(params)
return nil, on_init(params)
end)
expect_notification("initialized", {})
body()
expect_request("shutdown", function()
return nil, {}
end)
expect_notification("exit", nil)
end
-- The actual tests.
local tests = {}
function tests.basic_init()
skeleton {
on_init = function(_params)
return { capabilities = {} }
end;
body = function()
notify('test')
end;
}
end
function tests.basic_check_capabilities()
skeleton {
on_init = function(params)
local expected_capabilities = protocol.make_client_capabilities()
assert_eq(params.capabilities, expected_capabilities)
return {
capabilities = {
textDocumentSync = protocol.TextDocumentSyncKind.Full;
}
}
end;
body = function()
end;
}
end
function tests.basic_finish()
skeleton {
on_init = function(params)
local expected_capabilities = protocol.make_client_capabilities()
assert_eq(params.capabilities, expected_capabilities)
return {
capabilities = {
textDocumentSync = protocol.TextDocumentSyncKind.Full;
}
}
end;
body = function()
expect_notification("finish")
notify('finish')
end;
}
end
function tests.basic_check_buffer_open()
skeleton {
on_init = function(params)
local expected_capabilities = protocol.make_client_capabilities()
assert_eq(params.capabilities, expected_capabilities)
return {
capabilities = {
textDocumentSync = protocol.TextDocumentSyncKind.Full;
}
}
end;
body = function()
notify('start')
expect_notification('textDocument/didOpen', {
textDocument = {
languageId = "";
text = table.concat({"testing"; "123"}, "\n");
uri = "file://";
version = 0;
};
})
expect_notification("finish")
notify('finish')
end;
}
end
function tests.basic_check_buffer_open_and_change()
skeleton {
on_init = function(params)
local expected_capabilities = protocol.make_client_capabilities()
assert_eq(params.capabilities, expected_capabilities)
return {
capabilities = {
textDocumentSync = protocol.TextDocumentSyncKind.Full;
}
}
end;
body = function()
notify('start')
expect_notification('textDocument/didOpen', {
textDocument = {
languageId = "";
text = table.concat({"testing"; "123"}, "\n");
uri = "file://";
version = 0;
};
})
expect_notification('textDocument/didChange', {
textDocument = {
uri = "file://";
version = 3;
};
contentChanges = {
{ text = table.concat({"testing"; "boop"}, "\n"); };
}
})
expect_notification("finish")
notify('finish')
end;
}
end
function tests.basic_check_buffer_open_and_change_multi()
skeleton {
on_init = function(params)
local expected_capabilities = protocol.make_client_capabilities()
assert_eq(params.capabilities, expected_capabilities)
return {
capabilities = {
textDocumentSync = protocol.TextDocumentSyncKind.Full;
}
}
end;
body = function()
notify('start')
expect_notification('textDocument/didOpen', {
textDocument = {
languageId = "";
text = table.concat({"testing"; "123"}, "\n");
uri = "file://";
version = 0;
};
})
expect_notification('textDocument/didChange', {
textDocument = {
uri = "file://";
version = 3;
};
contentChanges = {
{ text = table.concat({"testing"; "321"}, "\n"); };
}
})
expect_notification('textDocument/didChange', {
textDocument = {
uri = "file://";
version = 4;
};
contentChanges = {
{ text = table.concat({"testing"; "boop"}, "\n"); };
}
})
expect_notification("finish")
notify('finish')
end;
}
end
function tests.basic_check_buffer_open_and_change_multi_and_close()
skeleton {
on_init = function(params)
local expected_capabilities = protocol.make_client_capabilities()
assert_eq(params.capabilities, expected_capabilities)
return {
capabilities = {
textDocumentSync = protocol.TextDocumentSyncKind.Full;
}
}
end;
body = function()
notify('start')
expect_notification('textDocument/didOpen', {
textDocument = {
languageId = "";
text = table.concat({"testing"; "123"}, "\n");
uri = "file://";
version = 0;
};
})
expect_notification('textDocument/didChange', {
textDocument = {
uri = "file://";
version = 3;
};
contentChanges = {
{ text = table.concat({"testing"; "321"}, "\n"); };
}
})
expect_notification('textDocument/didChange', {
textDocument = {
uri = "file://";
version = 4;
};
contentChanges = {
{ text = table.concat({"testing"; "boop"}, "\n"); };
}
})
expect_notification('textDocument/didClose', {
textDocument = {
uri = "file://";
};
})
expect_notification("finish")
notify('finish')
end;
}
end
function tests.basic_check_buffer_open_and_change_incremental()
skeleton {
on_init = function(params)
local expected_capabilities = protocol.make_client_capabilities()
assert_eq(params.capabilities, expected_capabilities)
return {
capabilities = {
textDocumentSync = protocol.TextDocumentSyncKind.Incremental;
}
}
end;
body = function()
notify('start')
expect_notification('textDocument/didOpen', {
textDocument = {
languageId = "";
text = table.concat({"testing"; "123"}, "\n");
uri = "file://";
version = 0;
};
})
expect_notification('textDocument/didChange', {
textDocument = {
uri = "file://";
version = 3;
};
contentChanges = {
{
range = {
start = { line = 1; character = 0; };
["end"] = { line = 2; character = 0; };
};
rangeLength = 4;
text = "boop\n";
};
}
})
expect_notification("finish")
notify('finish')
end;
}
end
function tests.basic_check_buffer_open_and_change_incremental_editting()
skeleton {
on_init = function(params)
local expected_capabilities = protocol.make_client_capabilities()
assert_eq(params.capabilities, expected_capabilities)
return {
capabilities = {
textDocumentSync = protocol.TextDocumentSyncKind.Incremental;
}
}
end;
body = function()
notify('start')
expect_notification('textDocument/didOpen', {
textDocument = {
languageId = "";
text = table.concat({"testing"; "123"}, "\n");
uri = "file://";
version = 0;
};
})
expect_notification('textDocument/didChange', {
textDocument = {
uri = "file://";
version = 3;
};
contentChanges = {
{
range = {
start = { line = 0; character = 0; };
["end"] = { line = 1; character = 0; };
};
rangeLength = 4;
text = "testing\n\n";
};
}
})
expect_notification("finish")
notify('finish')
end;
}
end
function tests.invalid_header()
io.stdout:write("Content-length: \r\n")
end
-- Tests will be indexed by TEST_NAME
local kill_timer = vim.loop.new_timer()
kill_timer:start(_G.TIMEOUT or 1e3, 0, function()
kill_timer:stop()
kill_timer:close()
io.stderr:write("TIMEOUT")
os.exit(100)
end)
local test_name = _G.TEST_NAME -- lualint workaround
assert(type(test_name) == 'string', 'TEST_NAME must be specified.')
local status, err = pcall(assert(tests[test_name], "Test not found"))
kill_timer:stop()
kill_timer:close()
if not status then
io.stderr:write(err)
os.exit(1)
end
os.exit(0)

View File

@ -0,0 +1,107 @@
local helpers = require('test.functional.helpers')(after_each)
local clear = helpers.clear
local exec_lua = helpers.exec_lua
local eq = helpers.eq
describe('URI methods', function()
before_each(function()
clear()
end)
describe('file path to uri', function()
describe('encode Unix file path', function()
it('file path includes only ascii charactors', function()
exec_lua("filepath = '/Foo/Bar/Baz.txt'")
eq('file:///Foo/Bar/Baz.txt', exec_lua("return vim.uri_from_fname(filepath)"))
end)
it('file path including white space', function()
exec_lua("filepath = '/Foo /Bar/Baz.txt'")
eq('file:///Foo%20/Bar/Baz.txt', exec_lua("return vim.uri_from_fname(filepath)"))
end)
it('file path including Unicode charactors', function()
exec_lua("filepath = '/xy/åäö/ɧ/汉语/↥/🤦/🦄/å/بِيَّ.txt'")
-- The URI encoding should be case-insensitive
eq('file:///xy/%c3%a5%c3%a4%c3%b6/%c9%a7/%e6%b1%89%e8%af%ad/%e2%86%a5/%f0%9f%a4%a6/%f0%9f%a6%84/a%cc%8a/%d8%a8%d9%90%d9%8a%d9%8e%d9%91.txt', exec_lua("return vim.uri_from_fname(filepath)"))
end)
end)
describe('encode Windows filepath', function()
it('file path includes only ascii charactors', function()
exec_lua([[filepath = 'C:\\Foo\\Bar\\Baz.txt']])
eq('file:///C:/Foo/Bar/Baz.txt', exec_lua("return vim.uri_from_fname(filepath)"))
end)
it('file path including white space', function()
exec_lua([[filepath = 'C:\\Foo \\Bar\\Baz.txt']])
eq('file:///C:/Foo%20/Bar/Baz.txt', exec_lua("return vim.uri_from_fname(filepath)"))
end)
it('file path including Unicode charactors', function()
exec_lua([[filepath = 'C:\\xy\\åäö\\ɧ\\汉语\\↥\\🤦\\🦄\\å\\بِيَّ.txt']])
eq('file:///C:/xy/%c3%a5%c3%a4%c3%b6/%c9%a7/%e6%b1%89%e8%af%ad/%e2%86%a5/%f0%9f%a4%a6/%f0%9f%a6%84/a%cc%8a/%d8%a8%d9%90%d9%8a%d9%8e%d9%91.txt', exec_lua("return vim.uri_from_fname(filepath)"))
end)
end)
end)
describe('uri to filepath', function()
describe('decode Unix file path', function()
it('file path includes only ascii charactors', function()
exec_lua("uri = 'file:///Foo/Bar/Baz.txt'")
eq('/Foo/Bar/Baz.txt', exec_lua("return vim.uri_to_fname(uri)"))
end)
it('file path including white space', function()
exec_lua("uri = 'file:///Foo%20/Bar/Baz.txt'")
eq('/Foo /Bar/Baz.txt', exec_lua("return vim.uri_to_fname(uri)"))
end)
it('file path including Unicode charactors', function()
local test_case = [[
local uri = 'file:///xy/%C3%A5%C3%A4%C3%B6/%C9%A7/%E6%B1%89%E8%AF%AD/%E2%86%A5/%F0%9F%A4%A6/%F0%9F%A6%84/a%CC%8A/%D8%A8%D9%90%D9%8A%D9%8E%D9%91.txt'
return vim.uri_to_fname(uri)
]]
eq('/xy/åäö/ɧ/汉语/↥/🤦/🦄/å/بِيَّ.txt', exec_lua(test_case))
end)
end)
describe('decode Windows filepath', function()
it('file path includes only ascii charactors', function()
local test_case = [[
local uri = 'file:///C:/Foo/Bar/Baz.txt'
return vim.uri_to_fname(uri)
]]
eq('C:\\Foo\\Bar\\Baz.txt', exec_lua(test_case))
end)
it('file path including white space', function()
local test_case = [[
local uri = 'file:///C:/Foo%20/Bar/Baz.txt'
return vim.uri_to_fname(uri)
]]
eq('C:\\Foo \\Bar\\Baz.txt', exec_lua(test_case))
end)
it('file path including Unicode charactors', function()
local test_case = [[
local uri = 'file:///C:/xy/%C3%A5%C3%A4%C3%B6/%C9%A7/%E6%B1%89%E8%AF%AD/%E2%86%A5/%F0%9F%A4%A6/%F0%9F%A6%84/a%CC%8A/%D8%A8%D9%90%D9%8A%D9%8E%D9%91.txt'
return vim.uri_to_fname(uri)
]]
eq('C:\\xy\\åäö\\ɧ\\汉语\\\\🤦\\🦄\\\\بِيَّ.txt', exec_lua(test_case))
end)
end)
end)
end)

View File

@ -305,6 +305,78 @@ describe('lua stdlib', function()
pcall_err(exec_lua, [[return vim.pesc(2)]]))
end)
it('vim.tbl_keys', function()
eq({}, exec_lua("return vim.tbl_keys({})"))
for _, v in pairs(exec_lua("return vim.tbl_keys({'a', 'b', 'c'})")) do
eq(true, exec_lua("return vim.tbl_contains({ 1, 2, 3 }, ...)", v))
end
for _, v in pairs(exec_lua("return vim.tbl_keys({a=1, b=2, c=3})")) do
eq(true, exec_lua("return vim.tbl_contains({ 'a', 'b', 'c' }, ...)", v))
end
end)
it('vim.tbl_values', function()
eq({}, exec_lua("return vim.tbl_values({})"))
for _, v in pairs(exec_lua("return vim.tbl_values({'a', 'b', 'c'})")) do
eq(true, exec_lua("return vim.tbl_contains({ 'a', 'b', 'c' }, ...)", v))
end
for _, v in pairs(exec_lua("return vim.tbl_values({a=1, b=2, c=3})")) do
eq(true, exec_lua("return vim.tbl_contains({ 1, 2, 3 }, ...)", v))
end
end)
it('vim.tbl_islist', function()
eq(NIL, exec_lua("return vim.tbl_islist({})"))
eq(true, exec_lua("return vim.tbl_islist({'a', 'b', 'c'})"))
eq(false, exec_lua("return vim.tbl_islist({'a', '32', a='hello', b='baz'})"))
eq(false, exec_lua("return vim.tbl_islist({1, a='hello', b='baz'})"))
eq(false, exec_lua("return vim.tbl_islist({a='hello', b='baz', 1})"))
eq(false, exec_lua("return vim.tbl_islist({1, 2, nil, a='hello'})"))
end)
it('vim.tbl_isempty', function()
eq(true, exec_lua("return vim.tbl_isempty({})"))
eq(false, exec_lua("return vim.tbl_isempty({ 1, 2, 3 })"))
eq(false, exec_lua("return vim.tbl_isempty({a=1, b=2, c=3})"))
end)
it('vim.deep_equal', function()
eq(true, exec_lua [[ return vim.deep_equal({a=1}, {a=1}) ]])
eq(true, exec_lua [[ return vim.deep_equal({a={b=1}}, {a={b=1}}) ]])
eq(true, exec_lua [[ return vim.deep_equal({a={b={nil}}}, {a={b={}}}) ]])
eq(true, exec_lua [[ return vim.deep_equal({a=1, [5]=5}, {nil,nil,nil,nil,5,a=1}) ]])
eq(false, exec_lua [[ return vim.deep_equal(1, {nil,nil,nil,nil,5,a=1}) ]])
eq(false, exec_lua [[ return vim.deep_equal(1, 3) ]])
eq(false, exec_lua [[ return vim.deep_equal(nil, 3) ]])
eq(false, exec_lua [[ return vim.deep_equal({a=1}, {a=2}) ]])
end)
it('vim.list_extend', function()
eq({1,2,3}, exec_lua [[ return vim.list_extend({1}, {2,3}) ]])
eq('Error executing lua: .../shared.lua: src must be a table',
pcall_err(exec_lua, [[ return vim.list_extend({1}, nil) ]]))
eq({1,2}, exec_lua [[ return vim.list_extend({1}, {2;a=1}) ]])
eq(true, exec_lua [[ local a = {1} return vim.list_extend(a, {2;a=1}) == a ]])
end)
it('vim.tbl_add_reverse_lookup', function()
eq(true, exec_lua [[
local a = { A = 1 }
vim.tbl_add_reverse_lookup(a)
return vim.deep_equal(a, { A = 1; [1] = 'A'; })
]])
-- Throw an error for trying to do it twice (run into an existing key)
local code = [[
local res = {}
local a = { A = 1 }
vim.tbl_add_reverse_lookup(a)
assert(vim.deep_equal(a, { A = 1; [1] = 'A'; }))
vim.tbl_add_reverse_lookup(a)
]]
matches('Error executing lua: .../shared.lua: The reverse lookup found an existing value for "[1A]" while processing key "[1A]"',
pcall_err(exec_lua, code))
end)
it('vim.call, vim.fn', function()
eq(true, exec_lua([[return vim.call('sin', 0.0) == 0.0 ]]))
eq(true, exec_lua([[return vim.fn.sin(0.0) == 0.0 ]]))

View File

@ -0,0 +1,634 @@
local helpers = require('test.functional.helpers')(after_each)
local clear = helpers.clear
local exec_lua = helpers.exec_lua
local eq = helpers.eq
local NIL = helpers.NIL
-- Use these to get access to a coroutine so that I can run async tests and use
-- yield.
local run, stop = helpers.run, helpers.stop
if helpers.pending_win32(pending) then return end
local is_windows = require'luv'.os_uname().sysname == "Windows"
local lsp_test_rpc_server_file = "test/functional/fixtures/lsp-test-rpc-server.lua"
if is_windows then
lsp_test_rpc_server_file = lsp_test_rpc_server_file:gsub("/", "\\")
end
local function test_rpc_server_setup(test_name, timeout_ms)
exec_lua([=[
lsp = require('vim.lsp')
local test_name, fixture_filename, timeout = ...
TEST_RPC_CLIENT_ID = lsp.start_client {
cmd = {
vim.api.nvim_get_vvar("progpath"), '-Es', '-u', 'NONE', '--headless',
"-c", string.format("lua TEST_NAME = %q", test_name),
"-c", string.format("lua TIMEOUT = %d", timeout),
"-c", "luafile "..fixture_filename,
};
callbacks = setmetatable({}, {
__index = function(t, method)
return function(...)
return vim.rpcrequest(1, 'callback', ...)
end
end;
});
root_dir = vim.loop.cwd();
on_init = function(client, result)
TEST_RPC_CLIENT = client
vim.rpcrequest(1, "init", result)
end;
on_exit = function(...)
vim.rpcnotify(1, "exit", ...)
end;
}
]=], test_name, lsp_test_rpc_server_file, timeout_ms or 1e3)
end
local function test_rpc_server(config)
if config.test_name then
clear()
test_rpc_server_setup(config.test_name, config.timeout_ms or 1e3)
end
local client = setmetatable({}, {
__index = function(_, name)
-- Workaround for not being able to yield() inside __index for Lua 5.1 :(
-- Otherwise I would just return the value here.
return function(...)
return exec_lua([=[
local name = ...
if type(TEST_RPC_CLIENT[name]) == 'function' then
return TEST_RPC_CLIENT[name](select(2, ...))
else
return TEST_RPC_CLIENT[name]
end
]=], name, ...)
end
end;
})
local code, signal
local function on_request(method, args)
if method == "init" then
if config.on_init then
config.on_init(client, unpack(args))
end
return NIL
end
if method == 'callback' then
if config.on_callback then
config.on_callback(unpack(args))
end
end
return NIL
end
local function on_notify(method, args)
if method == 'exit' then
code, signal = unpack(args)
return stop()
end
end
-- TODO specify timeout?
-- run(on_request, on_notify, config.on_setup, 1000)
run(on_request, on_notify, config.on_setup)
if config.on_exit then
config.on_exit(code, signal)
end
stop()
if config.test_name then
exec_lua("lsp._vim_exit_handler()")
end
end
describe('Language Client API', function()
describe('server_name is specified', function()
before_each(function()
clear()
-- Run an instance of nvim on the file which contains our "scripts".
-- Pass TEST_NAME to pick the script.
local test_name = "basic_init"
exec_lua([=[
lsp = require('vim.lsp')
local test_name, fixture_filename = ...
TEST_RPC_CLIENT_ID = lsp.start_client {
cmd = {
vim.api.nvim_get_vvar("progpath"), '-Es', '-u', 'NONE', '--headless',
"-c", string.format("lua TEST_NAME = %q", test_name),
"-c", "luafile "..fixture_filename;
};
root_dir = vim.loop.cwd();
}
]=], test_name, lsp_test_rpc_server_file)
end)
after_each(function()
exec_lua("lsp._vim_exit_handler()")
-- exec_lua("lsp.stop_all_clients(true)")
end)
describe('start_client and stop_client', function()
it('should return true', function()
for _ = 1, 20 do
helpers.sleep(10)
if exec_lua("return #lsp.get_active_clients()") > 0 then
break
end
end
eq(1, exec_lua("return #lsp.get_active_clients()"))
eq(false, exec_lua("return lsp.get_client_by_id(TEST_RPC_CLIENT_ID) == nil"))
eq(false, exec_lua("return lsp.get_client_by_id(TEST_RPC_CLIENT_ID).is_stopped()"))
exec_lua("return lsp.get_client_by_id(TEST_RPC_CLIENT_ID).stop()")
eq(false, exec_lua("return lsp.get_client_by_id(TEST_RPC_CLIENT_ID).is_stopped()"))
for _ = 1, 20 do
helpers.sleep(10)
if exec_lua("return #lsp.get_active_clients()") == 0 then
break
end
end
eq(true, exec_lua("return lsp.get_client_by_id(TEST_RPC_CLIENT_ID) == nil"))
end)
end)
end)
describe('basic_init test', function()
it('should run correctly', function()
local expected_callbacks = {
{NIL, "test", {}, 1};
}
test_rpc_server {
test_name = "basic_init";
on_init = function(client, _init_result)
-- client is a dummy object which will queue up commands to be run
-- once the server initializes. It can't accept lua callbacks or
-- other types that may be unserializable for now.
client.stop()
end;
-- If the program timed out, then code will be nil.
on_exit = function(code, signal)
eq(0, code, "exit code") eq(0, signal, "exit signal")
end;
-- Note that NIL must be used here.
-- on_callback(err, method, result, client_id)
on_callback = function(...)
eq(table.remove(expected_callbacks), {...})
end;
}
end)
it('should fail', function()
local expected_callbacks = {
{NIL, "test", {}, 1};
}
test_rpc_server {
test_name = "basic_init";
on_init = function(client)
client.notify('test')
client.stop()
end;
on_exit = function(code, signal)
eq(1, code, "exit code") eq(0, signal, "exit signal")
end;
on_callback = function(...)
eq(table.remove(expected_callbacks), {...}, "expected callback")
end;
}
end)
it('should succeed with manual shutdown', function()
local expected_callbacks = {
{NIL, "shutdown", {}, 1};
{NIL, "test", {}, 1};
}
test_rpc_server {
test_name = "basic_init";
on_init = function(client)
eq(0, client.resolved_capabilities().text_document_did_change)
client.request('shutdown')
client.notify('exit')
end;
on_exit = function(code, signal)
eq(0, code, "exit code") eq(0, signal, "exit signal")
end;
on_callback = function(...)
eq(table.remove(expected_callbacks), {...}, "expected callback")
end;
}
end)
it('should verify capabilities sent', function()
local expected_callbacks = {
{NIL, "shutdown", {}, 1};
}
test_rpc_server {
test_name = "basic_check_capabilities";
on_init = function(client)
client.stop()
end;
on_exit = function(code, signal)
eq(0, code, "exit code") eq(0, signal, "exit signal")
end;
on_callback = function(...)
eq(table.remove(expected_callbacks), {...}, "expected callback")
end;
}
end)
it('should not send didOpen if the buffer closes before init', function()
local expected_callbacks = {
{NIL, "shutdown", {}, 1};
{NIL, "finish", {}, 1};
}
local client
test_rpc_server {
test_name = "basic_finish";
on_setup = function()
exec_lua [[
BUFFER = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(BUFFER, 0, -1, false, {
"testing";
"123";
})
]]
eq(1, exec_lua("return TEST_RPC_CLIENT_ID"))
eq(true, exec_lua("return lsp.buf_attach_client(BUFFER, TEST_RPC_CLIENT_ID)"))
eq(true, exec_lua("return lsp.buf_is_attached(BUFFER, TEST_RPC_CLIENT_ID)"))
exec_lua [[
vim.api.nvim_command(BUFFER.."bwipeout")
]]
end;
on_init = function(_client)
client = _client
local full_kind = exec_lua("return require'vim.lsp.protocol'.TextDocumentSyncKind.Full")
eq(full_kind, client.resolved_capabilities().text_document_did_change)
eq(true, client.resolved_capabilities().text_document_open_close)
client.notify('finish')
end;
on_exit = function(code, signal)
eq(0, code, "exit code") eq(0, signal, "exit signal")
end;
on_callback = function(err, method, params, client_id)
eq(table.remove(expected_callbacks), {err, method, params, client_id}, "expected callback")
if method == 'finish' then
client.stop()
end
end;
}
end)
it('should check the body sent attaching before init', function()
local expected_callbacks = {
{NIL, "shutdown", {}, 1};
{NIL, "finish", {}, 1};
{NIL, "start", {}, 1};
}
local client
test_rpc_server {
test_name = "basic_check_buffer_open";
on_setup = function()
exec_lua [[
BUFFER = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(BUFFER, 0, -1, false, {
"testing";
"123";
})
]]
exec_lua [[
assert(lsp.buf_attach_client(BUFFER, TEST_RPC_CLIENT_ID))
]]
end;
on_init = function(_client)
client = _client
local full_kind = exec_lua("return require'vim.lsp.protocol'.TextDocumentSyncKind.Full")
eq(full_kind, client.resolved_capabilities().text_document_did_change)
eq(true, client.resolved_capabilities().text_document_open_close)
exec_lua [[
assert(not lsp.buf_attach_client(BUFFER, TEST_RPC_CLIENT_ID), "Shouldn't attach twice")
]]
end;
on_exit = function(code, signal)
eq(0, code, "exit code") eq(0, signal, "exit signal")
end;
on_callback = function(err, method, params, client_id)
if method == 'start' then
client.notify('finish')
end
eq(table.remove(expected_callbacks), {err, method, params, client_id}, "expected callback")
if method == 'finish' then
client.stop()
end
end;
}
end)
it('should check the body sent attaching after init', function()
local expected_callbacks = {
{NIL, "shutdown", {}, 1};
{NIL, "finish", {}, 1};
{NIL, "start", {}, 1};
}
local client
test_rpc_server {
test_name = "basic_check_buffer_open";
on_setup = function()
exec_lua [[
BUFFER = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(BUFFER, 0, -1, false, {
"testing";
"123";
})
]]
end;
on_init = function(_client)
client = _client
local full_kind = exec_lua("return require'vim.lsp.protocol'.TextDocumentSyncKind.Full")
eq(full_kind, client.resolved_capabilities().text_document_did_change)
eq(true, client.resolved_capabilities().text_document_open_close)
exec_lua [[
assert(lsp.buf_attach_client(BUFFER, TEST_RPC_CLIENT_ID))
]]
end;
on_exit = function(code, signal)
eq(0, code, "exit code") eq(0, signal, "exit signal")
end;
on_callback = function(err, method, params, client_id)
if method == 'start' then
client.notify('finish')
end
eq(table.remove(expected_callbacks), {err, method, params, client_id}, "expected callback")
if method == 'finish' then
client.stop()
end
end;
}
end)
it('should check the body and didChange full', function()
local expected_callbacks = {
{NIL, "shutdown", {}, 1};
{NIL, "finish", {}, 1};
{NIL, "start", {}, 1};
}
local client
test_rpc_server {
test_name = "basic_check_buffer_open_and_change";
on_setup = function()
exec_lua [[
BUFFER = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(BUFFER, 0, -1, false, {
"testing";
"123";
})
]]
end;
on_init = function(_client)
client = _client
local full_kind = exec_lua("return require'vim.lsp.protocol'.TextDocumentSyncKind.Full")
eq(full_kind, client.resolved_capabilities().text_document_did_change)
eq(true, client.resolved_capabilities().text_document_open_close)
exec_lua [[
assert(lsp.buf_attach_client(BUFFER, TEST_RPC_CLIENT_ID))
]]
end;
on_exit = function(code, signal)
eq(0, code, "exit code") eq(0, signal, "exit signal")
end;
on_callback = function(err, method, params, client_id)
if method == 'start' then
exec_lua [[
vim.api.nvim_buf_set_lines(BUFFER, 1, 2, false, {
"boop";
})
]]
client.notify('finish')
end
eq(table.remove(expected_callbacks), {err, method, params, client_id}, "expected callback")
if method == 'finish' then
client.stop()
end
end;
}
end)
-- TODO(askhan) we don't support full for now, so we can disable these tests.
pending('should check the body and didChange incremental', function()
local expected_callbacks = {
{NIL, "shutdown", {}, 1};
{NIL, "finish", {}, 1};
{NIL, "start", {}, 1};
}
local client
test_rpc_server {
test_name = "basic_check_buffer_open_and_change_incremental";
on_setup = function()
exec_lua [[
BUFFER = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(BUFFER, 0, -1, false, {
"testing";
"123";
})
]]
end;
on_init = function(_client)
client = _client
local sync_kind = exec_lua("return require'vim.lsp.protocol'.TextDocumentSyncKind.Incremental")
eq(sync_kind, client.resolved_capabilities().text_document_did_change)
eq(true, client.resolved_capabilities().text_document_open_close)
exec_lua [[
assert(lsp.buf_attach_client(BUFFER, TEST_RPC_CLIENT_ID))
]]
end;
on_exit = function(code, signal)
eq(0, code, "exit code") eq(0, signal, "exit signal")
end;
on_callback = function(err, method, params, client_id)
if method == 'start' then
exec_lua [[
vim.api.nvim_buf_set_lines(BUFFER, 1, 2, false, {
"boop";
})
]]
client.notify('finish')
end
eq(table.remove(expected_callbacks), {err, method, params, client_id}, "expected callback")
if method == 'finish' then
client.stop()
end
end;
}
end)
-- TODO(askhan) we don't support full for now, so we can disable these tests.
pending('should check the body and didChange incremental normal mode editting', function()
local expected_callbacks = {
{NIL, "shutdown", {}, 1};
{NIL, "finish", {}, 1};
{NIL, "start", {}, 1};
}
local client
test_rpc_server {
test_name = "basic_check_buffer_open_and_change_incremental_editting";
on_setup = function()
exec_lua [[
BUFFER = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(BUFFER, 0, -1, false, {
"testing";
"123";
})
]]
end;
on_init = function(_client)
client = _client
local sync_kind = exec_lua("return require'vim.lsp.protocol'.TextDocumentSyncKind.Incremental")
eq(sync_kind, client.resolved_capabilities().text_document_did_change)
eq(true, client.resolved_capabilities().text_document_open_close)
exec_lua [[
assert(lsp.buf_attach_client(BUFFER, TEST_RPC_CLIENT_ID))
]]
end;
on_exit = function(code, signal)
eq(0, code, "exit code") eq(0, signal, "exit signal")
end;
on_callback = function(err, method, params, client_id)
if method == 'start' then
helpers.command("normal! 1Go")
client.notify('finish')
end
eq(table.remove(expected_callbacks), {err, method, params, client_id}, "expected callback")
if method == 'finish' then
client.stop()
end
end;
}
end)
it('should check the body and didChange full with 2 changes', function()
local expected_callbacks = {
{NIL, "shutdown", {}, 1};
{NIL, "finish", {}, 1};
{NIL, "start", {}, 1};
}
local client
test_rpc_server {
test_name = "basic_check_buffer_open_and_change_multi";
on_setup = function()
exec_lua [[
BUFFER = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(BUFFER, 0, -1, false, {
"testing";
"123";
})
]]
end;
on_init = function(_client)
client = _client
local sync_kind = exec_lua("return require'vim.lsp.protocol'.TextDocumentSyncKind.Full")
eq(sync_kind, client.resolved_capabilities().text_document_did_change)
eq(true, client.resolved_capabilities().text_document_open_close)
exec_lua [[
assert(lsp.buf_attach_client(BUFFER, TEST_RPC_CLIENT_ID))
]]
end;
on_exit = function(code, signal)
eq(0, code, "exit code") eq(0, signal, "exit signal")
end;
on_callback = function(err, method, params, client_id)
if method == 'start' then
exec_lua [[
vim.api.nvim_buf_set_lines(BUFFER, 1, 2, false, {
"321";
})
vim.api.nvim_buf_set_lines(BUFFER, 1, 2, false, {
"boop";
})
]]
client.notify('finish')
end
eq(table.remove(expected_callbacks), {err, method, params, client_id}, "expected callback")
if method == 'finish' then
client.stop()
end
end;
}
end)
it('should check the body and didChange full lifecycle', function()
local expected_callbacks = {
{NIL, "shutdown", {}, 1};
{NIL, "finish", {}, 1};
{NIL, "start", {}, 1};
}
local client
test_rpc_server {
test_name = "basic_check_buffer_open_and_change_multi_and_close";
on_setup = function()
exec_lua [[
BUFFER = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(BUFFER, 0, -1, false, {
"testing";
"123";
})
]]
end;
on_init = function(_client)
client = _client
local sync_kind = exec_lua("return require'vim.lsp.protocol'.TextDocumentSyncKind.Full")
eq(sync_kind, client.resolved_capabilities().text_document_did_change)
eq(true, client.resolved_capabilities().text_document_open_close)
exec_lua [[
assert(lsp.buf_attach_client(BUFFER, TEST_RPC_CLIENT_ID))
]]
end;
on_exit = function(code, signal)
eq(0, code, "exit code") eq(0, signal, "exit signal")
end;
on_callback = function(err, method, params, client_id)
if method == 'start' then
exec_lua [[
vim.api.nvim_buf_set_lines(BUFFER, 1, 2, false, {
"321";
})
vim.api.nvim_buf_set_lines(BUFFER, 1, 2, false, {
"boop";
})
vim.api.nvim_command(BUFFER.."bwipeout")
]]
client.notify('finish')
end
eq(table.remove(expected_callbacks), {err, method, params, client_id}, "expected callback")
if method == 'finish' then
client.stop()
end
end;
}
end)
end)
describe("parsing tests", function()
it('should handle invalid content-length correctly', function()
local expected_callbacks = {
{NIL, "shutdown", {}, 1};
{NIL, "finish", {}, 1};
{NIL, "start", {}, 1};
}
local client
test_rpc_server {
test_name = "invalid_header";
on_setup = function()
end;
on_init = function(_client)
client = _client
client.stop(true)
end;
on_exit = function(code, signal)
eq(0, code, "exit code") eq(0, signal, "exit signal")
end;
on_callback = function(err, method, params, client_id)
eq(table.remove(expected_callbacks), {err, method, params, client_id}, "expected callback")
end;
}
end)
end)
end)