author: Oliver Jan Krylow date: 2026-04-06 status: wip tags: [lsp, projects]
Multi module workspaces with LSPs
In NeoVim, the model to map a buffer (file) to LSP server is very simplistic:
Get the root directory of the buffer and start the LSP with that as its
rootUri. If a LSP with that rootUri is already running, map the buffer to
that LSP, otherwise start a fresh one.
The way to determine the root directory of a buffer, is highly dependent on the
configuration of the LSP in lsp/*.lua, but typically it uses a function based
on vim.fs.root to traverse up the buffers path, until some special file or
folder is found.
That pattern is typically encoded with the root_markers shorthand config in
the LSP configuration, that simply lists those special files.
A common root_marker is .git, because for most simple cases, it can be
assumed, that a version controlled directory managed by git, is some kind of
"software project".
However, what constitutes a "project" in practice is undefined and highly dependent on the context and practices of the development team.
For example, I tend to work in projects, that are setup with a more complicated and nested structure. Projects may contain sub-projects and sub-projects may or may not use the same programming language as the host project.
Some examples of nested projects I work on:
Dotfiles
My dotfiles repo at ~/Workspace/dotfiles is a git repository, that contains
configuration files for my host machines for various programs. Each program,
such as neovim, wezterm or git, uses wildly different configuration formats and
languages. This is an instance, where the simplistic root_markers = {'.git'}
pattern can cause issues, because when I open the neovim config directory, I
expect the root to be that, and not the dotfiles repo, where the .git folder
happens to reside.
Java
The Java projects I work on tend to be organized as maven multi module projects.
If the root_marker was simply a pom.xml file, NeoVim, with a naive
configuration, would spawn an LSP per sub module.
Zig
Projects in Zig often use git sub modules for dependencies. Similar to Java projects, NeoVim would spawn multiple LSPs, when navigating such a code base.
Rust
Projects in Rust are typically organized with cargo, and often I use workspaces
to manage crates. There is complicated setup code in lsp/rust_analyzer.lua
that is highly specific to cargo, that handles this case, so technically, this
problem is solved for this case.
The Problem
In each case, the problem I experience with the simplistic default model of NeoVim is
- performance: Many LSP servers get launched in the background for large projects
- usability: Since the LSPs of the sub projects are not aware of each other, certain features like code navigation, rename and others, do not work across sub projects.
Attempted solution in the past
In the past, I wrote the std.project module to solve this issue. The idea was
to have a more complicated mechanism to determine the root directory, by scoring
many marker files based on their importance in a given language and their
distance to the buffer file.
And while the module does work well and as intended, I never really figured out how to integrate it with the LSP configuration such that:
- the LSP initially starts with the correct
rootUriof the host project - sub projects do not spawn a new LSP, but add themselves to the
workspaceUrisof the LSP - simple projects still work well
- single file mode for buffers, where it makes sense, still works well
In theory, LSPs can be configured in their initializations and during runtime,
to add more "root directories" to a running instance. Those are called
workspaces in the language server spec.
Investigation results
Root resolution already works
Sample projects in sample_files/projects/ were created to mimic the scenarios
above. Testing std.project and vim.fs.root (via root_markers) against
them showed that the stated problems are largely already solved by existing
mechanisms:
| Scenario | Naive root_markers |
Result | Why |
|---|---|---|---|
| Java multi-module | pom.xml -> per module |
jdtls uses priority root_markers: mvnw/gradlew/.git (high) then pom.xml/build.gradle (low) -> host root |
No std.project needed |
| Zig + C deps | build.zig -> host |
zls finds build.zig at host level. C files in submodules have their own .git boundary. zls filetype gate prevents attaching to .c files |
Works out of the box |
| Dotfiles neovim | .git -> dotfiles root |
lua_ls uses std.project which scores .luarc.json/.stylua.toml higher than .git -> roots to neovim/ |
Works with std.project, also works with lspconfig root_markers priority |
| Dotfiles wezterm | .git -> dotfiles root |
No lua-specific markers nearby -> falls back to dotfiles root. Fix: add .luarc.jsonc with wezterm-specific settings |
Trivial fix, no module needed |
| Polyglot mono | .git -> monorepo root |
Each lspconfig config has language-specific markers (build.zig, Cargo.toml, deno.json) that resolve to the correct sub-project |
Works out of the box |
std.project is only used by lua_ls
Grep shows std.project is required only in lsp/lua_ls.lua. Every other LSP
config uses root_markers or a custom root_dir function.
nvim-lspconfig replaces almost all custom configs
Diffing each lsp/*.lua against upstream nvim-lspconfig showed that most are
copies with only cosmetic differences (indentation, doc comments). lspconfig
now ships configs as lsp/*.lua that vim.lsp.config auto-discovers and
merges with local overrides.
.lsp.settings.lua is a dead convention
The file in ~/Workspace/dotfiles/wezterm/ is only used as a heavy marker
(weight=7) by std.project. Its contents (lua_ls settings) are never loaded.
The same purpose is better served by .luarc.jsonc, which lua_ls reads
natively and which also acts as a root_marker.
Action plan
1. Migrate wezterm to .luarc.jsonc
Replace ~/Workspace/dotfiles/wezterm/.lsp.settings.lua with
~/Workspace/dotfiles/wezterm/.luarc.jsonc:
{
"$schema": "https://raw.githubusercontent.com/LuaLS/vscode-lua/master/setting/schema.json",
"runtime.version": "Lua 5.4",
"hint.enable": false
}
This also makes .luarc.jsonc a root_marker, fixing wezterm root detection
without std.project.
2. Add nvim-lspconfig as a plugin
nvim-lspconfig provides lsp/*.lua configs that vim.lsp.config auto-discovers
and merges with local overrides.
3. Delete configs that lspconfig handles
Delete all lsp/*.lua files where lspconfig provides an equivalent or better
config. Verified by diffing against upstream for each:
- ast_grep, clangd, contextive, cssls, denols, gopls, html, jdtls, jsonls, just, kdl_ls, markdown_oxide, marksman, nushell, protols, rust_analyzer, sqls, superhtml, taplo, yamlls, ziggy, ziggy_schema, zls
Keep tofu_ls (not in lspconfig).
4. Replace lua_ls with minimal override
Lspconfig provides cmd, filetypes, root_markers (with priority), and
settings (codeLens + hints). Keep only the on_init override that
conditionally applies Neovim-config-specific settings (LuaJIT, diagnostics
globals, runtime paths).
Must use realpath + normalize because ~/.config/nvim is a symlink.
l``stdpath('config')returns/home/me/.config/nvim` (symlink), while
`workspace_folders[1].name` resolves to the real path
(`/home/me/Workspace/dotfiles/neovim/...`). The comparison would fail
without `realpath`.
5. Remove std.project module
- Delete
lua/std/project.lua - Delete
tests/std/project_spec.lua - Delete
tests/project_probe.lua - Delete
~/Workspace/dotfiles/wezterm/.lsp.settings.lua
6. Clean up sample projects
Keep sample_files/projects/ — they document the edge cases that informed
this decision.