--- 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 `rootUri` of the host project - sub projects do not spawn a new LSP, but add themselves to the `workspaceUris` of 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`: ```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.