Workspace diagnostics in Neovim

3 min readneovimlsp

Displaying LSP diagnostics for all files in a project, not just the opened ones, is something Neovim does not do out of the box, which is a shame. People vent out their frustration about this every now and then.

VSCode does it "almost out-of-the-box" apparently, using the same LSP servers (e.g. tsserver) as Neovim. So, VSCode must be doing something that Neovim isn't, and perhaps we could bridge that gap with a bit of code. But first we need to understand how file or workspace diagnostics reach the IDE.

LSP spec defines two ways to obtain diagnostics: push and pull. The former is a message sent from the server to the client about a single file, and the latter is a request from the client to the server to get the diagnostics for either a single file or all files in a project. I can't speak for all LSP servers, but tsserver, for one, only implements push (as evidenced by the fact that there is no diagnosticProvider capability when the server is initialized). So, whatever VSCode is doing, receiving diagnostic notification for individual files is enough.

Much like VSCode, Neovim is also listening for those diagnostic notification events. It then stores them in an internal cache, and when the user wants to actually see them, displays the contents of the said cache in a quickfix window. Diagnostic events are in turn triggered by textDocument/didOpen and textDocument/didChange events, that are sent from the client to the server.

Therefore, all we might need to do is send textDocument/didOpen for all files without actually opening them. Turns out, that's all there's is to it! The code that did the trick is rather simple:

local loaded_clients = {} local function trigger_workspace_diagnostics(client, bufnr, workspace_files) if vim.tbl_contains(loaded_clients, then return end table.insert(loaded_clients, if not vim.tbl_get(client.server_capabilities, 'textDocumentSync', 'openClose') then return end for _, path in ipairs(workspace_files) do if path == vim.api.nvim_buf_get_name(bufnr) then goto continue end local filetype = vim.filetype.match({ filename = path }) if not vim.tbl_contains(client.config.filetypes, filetype) then goto continue end local params = { textDocument = { uri = vim.uri_from_fname(path), version = 0, text = vim.fn.join(vim.fn.readfile(path), "\n"), languageId = filetype } } client.notify('textDocument/didOpen', params) ::continue:: end end local workspace_files = vim.fn.split(vim.fn.system('git ls-files'), "\n") -- convert paths to absolute workspace_files = map(workspace_files, function(_, path) return vim.fn.fnamemodify(path, ":p") end)

We then call trigger_workspace_diagnostics inside lspconfig's on_attach callback:

require('lspconfig').tsserver.setup({ on_attach = function(client, bufnr) ... trigger_workspace_diagnostics(client, bufnr, workspace_files) ... end }) end

And voila. In the video below you can see that even though only one file is opened, diagnostics are shown for the other files as well:

Now, this appears to be working, but I'd be really surprised if it doesn't break some things in some subtle ways. For example, LSP spec says that an open notification must not be sent more than once without a corresponding close notification send before, but, if that rules was ever enforced, it clearly isn't anymore. Another potential issue might be the performance hit for some LSP servers. I don't know - it's just an experiment.

For this reason, the above code is also available as a Neovim plugin, so that hopefully some smarter and more informed people can help me make it actually useful.