Problem:
Some servers write log to stdout and there's no way to avoid it.
See https://github.com/neovim/neovim/pull/35743#pullrequestreview-3379705828
Solution:
We can extract `content-length` field byte by byte and skip invalid
lines via a simple state machine (name/colon/value/invalid), with minimal
performance impact.
I chose byte parsing here instead of pattern. Although it's a bit more complex,
it provides more stable performance and allows for more accurate error info when
needed.
Here is a bench result and script:
parse header1 by pattern: 59.52377ms 45
parse header1 by byte: 7.531128ms 45
parse header2 by pattern: 26.06936ms 45
parse header2 by byte: 5.235724ms 45
parse header3 by pattern: 9.348495ms 45
parse header3 by byte: 3.452389ms 45
parse header4 by pattern: 9.73156ms 45
parse header4 by byte: 3.638386ms 45
Script:
```lua
local strbuffer = require('string.buffer')
--- @param header string
local function get_content_length(header)
for line in header:gmatch('(.-)\r?\n') do
if line == '' then
break
end
local key, value = line:match('^%s*(%S+)%s*:%s*(%d+)%s*$')
if key and key:lower() == 'content-length' then
return assert(tonumber(value))
end
end
error('Content-Length not found in header: ' .. header)
end
--- @param header string
local function get_content_length_by_byte(header)
local state = 'name'
local i, len = 1, #header
local j, name = 1, 'content-length'
local buf = strbuffer.new()
local digit = true
while i <= len do
local c = header:byte(i)
if state == 'name' then
if c >= 65 and c <= 90 then -- lower case
c = c + 32
end
if (c == 32 or c == 9) and j == 1 then
-- skip OWS for compatibility only
elseif c == name:byte(j) then
j = j + 1
elseif c == 58 and j == 15 then
state = 'colon'
else
state = 'invalid'
end
elseif state == 'colon' then
if c ~= 32 and c ~= 9 then -- skip OWS normally
state = 'value'
i = i - 1
end
elseif state == 'value' then
if c == 13 and header:byte(i + 1) == 10 then -- must end with \r\n
local value = buf:get()
return assert(digit and tonumber(value), 'value of Content-Length is not number: ' .. value)
else
buf:put(string.char(c))
end
if c < 48 and c ~= 32 and c ~= 9 or c > 57 then
digit = false
end
elseif state == 'invalid' then
if c == 10 then -- reset for next line
state, j = 'name', 1
end
end
i = i + 1
end
error('Content-Length not found in header: ' .. header)
end
--- @param fn fun(header: string): number
local function bench(label, header, fn, count)
local start = vim.uv.hrtime()
local value --- @type number
for _ = 1, count do
value = fn(header)
end
local elapsed = (vim.uv.hrtime() - start) / 1e6
print(label .. ':', elapsed .. 'ms', value)
end
-- header starting with log lines
local header1 =
'WARN: no common words file defined for Khmer - this language might not be correctly auto-detected\nWARN: no common words file defined for Japanese - this language might not be correctly auto-detected\nContent-Length: 45 \r\n\r\n'
-- header starting with content-type
local header2 = 'Content-Type: application/json-rpc; charset=utf-8\r\nContent-Length: 45 \r\n'
-- regular header
local header3 = ' Content-Length: 45\r\n'
-- regular header ending with content-type
local header4 = ' Content-Length: 45 \r\nContent-Type: application/json-rpc; charset=utf-8\r\n'
local count = 10000
collectgarbage('collect')
bench('parse header1 by pattern', header1, get_content_length, count)
collectgarbage('collect')
bench('parse header1 by byte', header1, get_content_length_by_byte, count)
collectgarbage('collect')
bench('parse header2 by pattern', header2, get_content_length, count)
collectgarbage('collect')
bench('parse header2 by byte', header2, get_content_length_by_byte, count)
collectgarbage('collect')
bench('parse header3 by pattern', header3, get_content_length, count)
collectgarbage('collect')
bench('parse header3 by byte', header3, get_content_length_by_byte, count)
collectgarbage('collect')
bench('parse header4 by pattern', header4, get_content_length, count)
collectgarbage('collect')
bench('parse header4 by byte', header4, get_content_length_by_byte, count)
```
Also, I removed an outdated test
accd392f4d/test/functional/plugin/lsp_spec.lua (L1950)
and tweaked the boilerplate in two other tests for reusability while keeping the final assertions the same.
accd392f4d/test/functional/plugin/lsp_spec.lua (L5704)accd392f4d/test/functional/plugin/lsp_spec.lua (L5721)
Problem: reuse_win will always jump to the first window containing the
target buffer rather even if the buffer is displayed in the current
window/tab
Solution: check to see if the buffer is already displayed in the
current window or any window of the current buffer
Problem:
Error extracting content-length causes all future coroutine resumes to
fail.
Solution:
Replace coroutine.wrap with coroutine.create in create_read_loop
so that we can check its status and catch any errors, allowing us to
stop the lsp client and avoid repeatedly resuming the dead coroutine.
Problem:
If a client doesn't have a config then an error may be thrown.
Probably caused by: 2f78ff816b
Lua callback: …/lsp.lua:442: attempt to index local 'config' (a nil value)
stack traceback:
…/lsp.lua:442: in function 'can_start'
…/lsp.lua:479: in function 'lsp_enable_callback'
…/lsp.lua:566: in function <…/lsp.lua:565>
Solution:
Not all clients necessarily have configs.
- Handle `config=nil` in `can_start`.
- If user "enables" an invalid name that happens to match a *client*
name, don't auto-detach the client.
* fix(lsp): type of root_dir should be annotated with string|fun|nil
* feat(lsp): support root_dir as function in _get_workspace_folders
* feat(lsp): let checkhealth support root_dir() function
Examples:
vim.lsp: Active Clients ~
- lua_ls (id: 1)
- Version: <Unknown>
- Root directories:
~/foo/bar
~/dev/neovim
Co-authored-by: Justin M. Keyes <justinkz@gmail.com>
Overriding vim.lsp.handlers['textDocument/formatting'] doesn't work here
because fake_lsp_server_setup() uses a table with __index to specify
client handlers, which takes priority over vim.lsp.handlers[], and as a
result the overridden handler is never called, and the test ends before
the vim.wait() even finishes.
Instead, set a global variable from the handler that is actually reached
(by vim.rpcrequest() from client handler), and avoid stopping the event
loop too early.
Problem:
LSP logs show misleading "cannot start" messages when editing a filetype
NOT listed in the `config.filetypes` field.
[ERROR][2025-09-13 18:55:56] …/runtime//lua/vim/lsp/log.lua:151
"cannot start cssls due to config error: …/runtime//lua/vim/lsp.lua:423:
cmd: expected expected function or table with executable command,
got table: 0x0104701b18. Info: vscode-css-language-server is not executable"
Solution:
- `can_start`: check `config.filetypes` before checking the rest of the
config.
Problem:
Hover response of MarkedString[] where the first element contains a
language identifier treated as empty.
Solution:
Fix empty check to handle case of MarkedString[] where the first element
is a pair of a language and value.
Problem:
Buffer-updates on_detach callback is invoked before buf_freeall(), which
deletes autocmds of the buffer (via apply_autocmds(EVENT_BUFWIPEOUT,
...)). Due to this, buffer-local autocmds executed in on_detach (e.g.,
LspDetach) are not actually invoked.
Solution:
Call buf_updates_unload() before buf_freeall().
- Add delimiter between function signature and documentation, matching hover formatting
- Show title only if there are multiple clients or multiple signatures
- Avoid duplicating the title inside the window if it's already shown in the border
Problem:
Previously, 'null' value in LSP responses were decoded as 'nil'.
This caused ambiguity for fields typed as '? | null' and led to
loss of explicit 'null' values, particularly in 'data' parameters.
Solution:
Decode all JSON 'null' values as 'vim.NIL' and adjust handling
where needed. This better aligns with the LSP specification,
where 'null' and absent fields are distinct, and 'null' should
not be used to represent missing values.
This also enables proper validation of response messages to
ensure that exactly one of 'result' or 'error' is present, as
required by the JSON-RPC specification.
Problem:
In LSP configs, the function form of `cmd()` cannot easily get the
resolved root dir (workspace). One of the main use-cases of a dynamic
`cmd()` is to be able to start a new server whose binary may be located
*in the workspace* ([example](https://github.com/neovim/nvim-lspconfig/pull/3912)).
Compare `reuse_client()`, which also receives the resolved config.
Solution:
Pass the resolved config to `cmd()`.
Co-authored-by: Justin M. Keyes <justinkz@gmail.com>
Problem:
Stopping a language server and then calling vim.lsp.start() with the same name/root will return the old language server that's in the middle of shutting down. vim.lsp.start() won't return a new server until the old process has terminated.
Solution:
Introducing a client._is_stopping field that tracks the shutdown phase, preventing the client from being reused.
Problem:
No way to check if a LSP config is enabled without causing it to
resolve. E.g. `vim.lsp.config['…'] ~= nil` will resolve the config,
which could be an unwanted and somewhat expensive side-effect.
Solution:
Introduce `vim.lsp.is_enabled()`.
NEW BUILD SYSTEM!
This is a MVP implementation which supports building the "nvim" binary,
including cross-compilation for some targets.
As an example, you can build a aarch64-macos binary from
an x86-64-linux-gnu host, or vice versa
Add CI target for build.zig currently for functionaltests on linux
x86_64 only
Follow up items:
- praxis for version and dependency bumping
- windows 💀
- full integration of libintl and gettext (or a desicion not to)
- update help and API metadata files
- installation into a $PREFIX
- more tests and linters
Problem:
enable() could be more flexible, so that it works even if called "late".
Solution:
- enable(true) calls `doautoall nvim.lsp.enable FileType`.
- enable(false) calls `client:stop()` on matching clients.
This will be useful for e.g. :LspStop/:LspStart also.
Problem:
Tests call `clear()` even though `clear_notrace()` is already called in
a `before_each()` handler, wasting precious milliseconds!
Solution:
Remove redundant `clear()` calls.
Problem:
In cases when the (in-process) LSP server responds to the request
immediately and calls `notify_reply_callback` the request will still be
marked as pending, because the code assumes that the response will occur
asynchronously. Then the request will be pending forever, because it was
already set as "completed" before we even set it as "pending".
A workaround is to wrap `notify_replay_callback` in `vim.shedule` ([like
so](https://github.com/neovim/neovim/pull/24338#issuecomment-2809568617)]
but that seems counterintuitive.
Solution:
Handle this case in Client:request().
Problem:
`vim.lsp.buf.[implementation|definition|...]({ reuse_win = true })` does not
jump cursor to existing window if buffer is already open.
Steps to reproduce:
1. `nvim repro.lua`
2. Insert anything that lsp can read to open the library definition/implementation, e.g., `vim.keymap.set`
3. open `repro.lua` buffer and the library buffer side by side.
4. type `gd` over `set` to jump to the library definition.
The open buffer is scrolled to the target line, but cursor does not jump.
Solution:
Call nvim_set_current_win if necessary.
Problem:
If a config name contains "*" it causes rtp discovery of `lsp/` to
consider the `*` as a wildcard and could lead to strange and unintended
behaviour. For example, accessing the `'*'` config from a `lsp/` file
would cause an infinite loop.
Solution:
- Explicitly disallow a config name from containing wildcards, with the
exception of `'*'`.
- When Resolving `'*'` config, skip the rtp step.
Problem:
Some language servers do not work properly without a workspace folder.
Solution:
Add `workspace_required`, which skips starting the lsp client if no
workspace folder is found.
Co-authored-by: Justin M. Keyes <justinkz@gmail.com>
Problem:
The root dir function is not passed any context and can only assume the
current buffer is the one being attached.
The main use case is for getting the path of the buffer using
`nvim_buf_get_name`.
Solution:
Pass the buffer number as the first argument.
When `root_dir` is a function it can (and often will) call the provided
callback function in a fast API context (e.g. in the `on_exit` handler
of `vim.system`). When the callback function is executed we should
ensure that it runs vim.lsp.start on the main event loop.
Problem:
The floating window for hover and signature help always cuts off a few lines,
because the `_make_floating_popup_size` function counts empty lines as having
zero height.
Solution:
Ensure the height is at least 1.
Allows to retrieve the configuration as it will be used by `lsp.enable`
- including the parts merged from `*` and rtp.
This is useful for explicit startup control
(`vim.lsp.start(vim.lsp.config[name])`)
Closes https://github.com/neovim/neovim/issues/31640
If root_dir is a function it is evaluated when the client is created to
determine the root directory.
This enables dynamically determining the root directory based on e.g.
project or directory structure (example: finding a parent Cargo.toml
file that contains "[workspace]" in a Rust project).
Problem:
An LSP configuration that creates client with no root_dir or
workspace_folders can result in vim.lsp.enable attaching to it multiple
times.
Solution:
When checking existing clients, reuse a client if it wasn't initially
configured have any workspace_folders. This more closely matches the
behaviour we had prior to d9235ef