Luigit
repositories / dotfiles

dotfiles

bugabingas dorkfiles

owned by admin

neovim/documentation/specs/001-Multi-Module-Workspaces-With-LSPs.md

Raw
Rendered preview

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:

{
  "$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.

---
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.