commit 6577d72d819dde32abeacd6a72d6ba64483f7ddc
parent f27fb737ceba8d36b0dd18804afa7952e87649e3
Author: Lorenzo Bellina <59364991+TheRealLorenz@users.noreply.github.com>
Date: Wed, 30 Apr 2025 15:43:32 +0200
feat(lsp): `root_markers` can control priority #33485
Problem:
root_markers cannot specify "equal priority filenames.
Solution:
Support nesting:
{
...
root_markers = { { ".stylua.toml", ".luarc.json" }, { ".git "} }
...
}
Co-authored-by: Maria José Solano <majosolano99@gmail.com>
Co-authored-by: Gregory Anders <github@gpanders.com>
Co-authored-by: Justin M. Keyes <justinkz@gmail.com>
Diffstat:
4 files changed, 155 insertions(+), 10 deletions(-)
diff --git a/runtime/doc/lsp.txt b/runtime/doc/lsp.txt
@@ -41,7 +41,8 @@ Follow these steps to get LSP features:
-- current buffer that contains either a ".luarc.json" or a
-- ".luarc.jsonc" file. Files that share a root directory will reuse
-- the connection to the same LSP server.
- root_markers = { '.luarc.json', '.luarc.jsonc' },
+ -- Nested lists indicate equal priority, see |vim.lsp.Config|.
+ root_markers = { { '.luarc.json', '.luarc.jsonc' }, '.git' },
-- Specific settings to send to the server. The schema for this is
-- defined by the server. For example the schema for lua-language-server
@@ -722,9 +723,37 @@ Lua module: vim.lsp *lsp-core*
the buffer. Thus a `root_dir()` function can
dynamically decide per-buffer whether to activate (or
skip) LSP. See example at |vim.lsp.enable()|.
- • {root_markers}? (`string[]`) Directory markers (e.g. ".git/",
- "package.json") used to decide `root_dir`. Unused if
- `root_dir` is provided.
+ • {root_markers}? (`(string|string[])[]`) Directory markers (.e.g.
+ '.git/') where the LSP server will base its
+ workspaceFolders, rootUri, and rootPath on
+ initialization. Unused if `root_dir` is provided.
+
+ The list order decides the priority. To indicate
+ "equal priority", specify names in a nested list
+ (`{ { 'a', 'b' }, ... }`) Each entry in this list is
+ a set of one or more markers. For each set, Nvim will
+ search upwards for each marker contained in the set.
+ If a marker is found, the directory which contains
+ that marker is used as the root directory. If no
+ markers from the set are found, the process is
+ repeated with the next set in the list.
+
+ Example: >lua
+ root_markers = { 'stylua.toml', '.git' }
+<
+
+ Find the first parent directory containing the file
+ `stylua.toml`. If not found, find the first parent
+ directory containing the file or directory `.git`.
+
+ Example: >lua
+ root_markers = { { 'stylua.toml', '.luarc.json' }, '.git' }
+<
+
+ Find the first parent directory containing EITHER
+ `stylua.toml` or `.luarc.json`. If not found, find
+ the first parent directory containing the file or
+ directory `.git`.
buf_attach_client({bufnr}, {client_id}) *vim.lsp.buf_attach_client()*
diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt
@@ -70,7 +70,7 @@ HIGHLIGHTS
LSP
-• todo
+• `root_markers` in |vim.lsp.Config| can now be ordered by priority.
LUA
diff --git a/runtime/lua/vim/lsp.lua b/runtime/lua/vim/lsp.lua
@@ -293,9 +293,37 @@ end
--- example at |vim.lsp.enable()|.
--- @field root_dir? string|fun(bufnr: integer, on_dir:fun(root_dir?:string))
---
---- Directory markers (e.g. ".git/", "package.json") used to decide `root_dir`. Unused if `root_dir`
---- is provided.
---- @field root_markers? string[]
+--- Directory markers (.e.g. '.git/') where the LSP server will base its workspaceFolders,
+--- rootUri, and rootPath on initialization. Unused if `root_dir` is provided.
+---
+--- The list order decides the priority. To indicate "equal priority", specify names in a nested list (`{ { 'a', 'b' }, ... }`)
+--- Each entry in this list is a set of one or more markers. For each set, Nvim
+--- will search upwards for each marker contained in the set. If a marker is
+--- found, the directory which contains that marker is used as the root
+--- directory. If no markers from the set are found, the process is repeated
+--- with the next set in the list.
+---
+--- Example:
+---
+--- ```lua
+--- root_markers = { 'stylua.toml', '.git' }
+--- ```
+---
+--- Find the first parent directory containing the file `stylua.toml`. If not
+--- found, find the first parent directory containing the file or directory
+--- `.git`.
+---
+--- Example:
+---
+--- ```lua
+--- root_markers = { { 'stylua.toml', '.luarc.json' }, '.git' }
+--- ```
+---
+--- Find the first parent directory containing EITHER `stylua.toml` or
+--- `.luarc.json`. If not found, find the first parent directory containing the
+--- file or directory `.git`.
+---
+--- @field root_markers? (string|string[])[]
--- Sets the default configuration for an LSP client (or _all_ clients if the special name "*" is
--- used).
@@ -613,7 +641,7 @@ end
--- Suppress error reporting if the LSP server fails to start (default false).
--- @field silent? boolean
---
---- @field package _root_markers? string[]
+--- @field package _root_markers? (string|string[])[]
--- Create a new LSP client and start a language server or reuses an already
--- running client if one is found matching `name` and `root_dir`.
@@ -662,8 +690,16 @@ function lsp.start(config, opts)
local bufnr = vim._resolve_bufnr(opts.bufnr)
if not config.root_dir and opts._root_markers then
+ validate('root_markers', opts._root_markers, 'table')
config = vim.deepcopy(config)
- config.root_dir = vim.fs.root(bufnr, opts._root_markers)
+
+ for _, marker in ipairs(opts._root_markers) do
+ local root = vim.fs.root(bufnr, marker)
+ if root ~= nil then
+ config.root_dir = root
+ break
+ end
+ end
end
if
diff --git a/test/functional/plugin/lsp_spec.lua b/test/functional/plugin/lsp_spec.lua
@@ -6535,5 +6535,85 @@ describe('LSP', function()
vim.lsp.config('*', {})
end)
end)
+
+ it('correctly handles root_markers', function()
+ --- Setup directories for testing
+ -- root/
+ -- ├── dir_a/
+ -- │ ├── dir_b/
+ -- │ │ ├── target
+ -- │ │ └── marker_d
+ -- │ ├── marker_b
+ -- │ └── marker_c
+ -- └── marker_a
+
+ ---@param filepath string
+ local function touch(filepath)
+ local file = io.open(filepath, 'w')
+ if file then
+ file:close()
+ end
+ end
+
+ local tmp_root = tmpname(false)
+ local marker_a = tmp_root .. '/marker_a'
+ local dir_a = tmp_root .. '/dir_a'
+ local marker_b = dir_a .. '/marker_b'
+ local marker_c = dir_a .. '/marker_c'
+ local dir_b = dir_a .. '/dir_b'
+ local marker_d = dir_b .. '/marker_d'
+ local target = dir_b .. '/target'
+
+ mkdir(tmp_root)
+ touch(marker_a)
+ mkdir(dir_a)
+ touch(marker_b)
+ touch(marker_c)
+ mkdir(dir_b)
+ touch(marker_d)
+ touch(target)
+
+ exec_lua(create_server_definition)
+ exec_lua(function()
+ _G._custom_server = _G._create_server()
+ end)
+
+ ---@param root_markers (string|string[])[]
+ ---@param expected_root_dir string?
+ local function markers_resolve_to(root_markers, expected_root_dir)
+ exec_lua(function()
+ vim.lsp.config['foo'] = {}
+ vim.lsp.config('foo', {
+ cmd = _G._custom_server.cmd,
+ reuse_client = function()
+ return false
+ end,
+ filetypes = { 'foo' },
+ root_markers = root_markers,
+ })
+ vim.lsp.enable('foo')
+ vim.cmd.edit(target)
+ vim.bo.filetype = 'foo'
+ end)
+ retry(nil, 1000, function()
+ eq(
+ expected_root_dir,
+ exec_lua(function()
+ local clients = vim.lsp.get_clients()
+ return clients[#clients].root_dir
+ end)
+ )
+ end)
+ end
+
+ markers_resolve_to({ 'marker_d' }, dir_b)
+ markers_resolve_to({ 'marker_b' }, dir_a)
+ markers_resolve_to({ 'marker_c' }, dir_a)
+ markers_resolve_to({ 'marker_a' }, tmp_root)
+ markers_resolve_to({ 'foo' }, nil)
+ markers_resolve_to({ { 'marker_b', 'marker_a' }, 'marker_d' }, dir_a)
+ markers_resolve_to({ 'marker_a', { 'marker_b', 'marker_d' } }, tmp_root)
+ markers_resolve_to({ 'foo', { 'bar', 'baz' }, 'marker_d' }, dir_b)
+ end)
end)
end)