Skip to content

Commit 8ad2d8d

Browse files
authored
feat(ts/js): improve monorepo support for Typescript, ESLint #3955
PROBLEM: Monorepos (or "workspaces") in Typescript are more and more popular and the associated tooling is evolving to improve the developer experience in such setup. Especially, the `typescript-language-server` and the `vscode-eslint-language-server` now supports monorepos, **removing the need to spawn a different server for each package of a workspace**. Example: with a few packages as the servers need to load every other package to work (the `typescript-language-server`, even if spawned multiple times with different `root_dir`, will load in memory other packages to resolve the types), the amount of memory used grows exponentially. But in fact, those servers support monorepos: they support multiple configurations in subpackages and will load the correct one to process a buffer. The ESLint server even supports loading multiple ESLint binaries (and therefore versions), while keeping one instance of the server. SOLUTION: Instead of only relying on the configuration files as `root_markers`, discover the root of the package / monorepo by finding the Lock files created by node package managers: * `package-lock.json`: Npm * `yarn.lock`: Yarn * `pnpm-lock.yaml`: Pnpm * `bun.lockb`: Bun We still need to look at configuration files to enable the conditionnaly attachment of the LSP for a buffer (for ESLint, we want to attach the LSP only if there are ESLint configuration files) in case of LSP that operates on files that are "generic" (like `typescript` or `javascript`). To do that, I replace the `root_markers` that were the configuration files by a `root_dir` function that superseds them. It will both: * look for a configuration file upward to check if the LSP needs to be attached * look for the root of the "project" via the lock files to specify the `root_dir` of the LSP PRIOR EXPERIMENTATIONS: I've tried to play with the `reuse_client` quite a lot, trying to understand if we need to spawn a new server or not looking at the Typescript / ESLint binary that was loaded, but in fact it's way easier to just have a better `root_dir` that is the true root of the project for the LSP server: in case of those two servers, the root of the package / monorepo. I also tried to use the current directory opened as the `root_dir`, but it's less powerful on nvim compared to VSCode as we navigate more inside folders using terminal commands and then open vim. I think this method also removes the need from a project-local config (which could be quite useful anyway for ESLint flat config setting which auto-detection is a bit unreliable / compute heavy) as this should work normally accross all different setups. Fixes #3910
1 parent 782dda9 commit 8ad2d8d

File tree

6 files changed

+200
-41
lines changed

6 files changed

+200
-41
lines changed

lsp/biome.lua

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66
--- ```sh
77
--- npm install [-g] @biomejs/biome
88
--- ```
9+
---
10+
--- ### Monorepo support
11+
---
12+
--- `biome` supports monorepos by default. It will automatically find the `biome.json` corresponding to the package you are working on, as described in the [documentation](https://biomejs.dev/guides/big-projects/#monorepo). This works without the need of spawning multiple instances of `biome`, saving memory.
913

1014
local util = require 'lspconfig.util'
1115

@@ -34,13 +38,33 @@ return {
3438
'vue',
3539
},
3640
workspace_required = true,
37-
root_dir = function(_, on_dir)
38-
-- To support monorepos, biome recommends starting the search for the root from cwd
39-
-- https://biomejs.dev/guides/big-projects/#use-multiple-configuration-files
40-
local cwd = vim.fn.getcwd()
41-
local root_files = { 'biome.json', 'biome.jsonc' }
42-
root_files = util.insert_package_json(root_files, 'biome', cwd)
43-
local root_dir = vim.fs.dirname(vim.fs.find(root_files, { path = cwd, upward = true })[1])
44-
on_dir(root_dir)
41+
root_dir = function(bufnr, on_dir)
42+
-- The project root is where the LSP can be started from
43+
-- As stated in the documentation above, this LSP supports monorepos and simple projects.
44+
-- We select then from the project root, which is identified by the presence of a package
45+
-- manager lock file.
46+
local project_root_markers = { 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lockb' }
47+
local project_root = vim.fs.root(bufnr, project_root_markers)
48+
if not project_root then
49+
return nil
50+
end
51+
52+
-- We know that the buffer is using Biome if it has a config file
53+
-- in its directory tree.
54+
local filename = vim.api.nvim_buf_get_name(bufnr)
55+
local biome_config_files = { 'biome.json', 'biome.jsonc' }
56+
biome_config_files = util.insert_package_json(biome_config_files, 'biome', filename)
57+
local is_buffer_using_biome = vim.fs.find(biome_config_files, {
58+
path = filename,
59+
type = 'file',
60+
limit = 1,
61+
upward = true,
62+
stop = vim.fs.dirname(project_root),
63+
})[1]
64+
if not is_buffer_using_biome then
65+
return nil
66+
end
67+
68+
on_dir(project_root)
4569
end,
4670
}

lsp/eslint.lua

Lines changed: 63 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,33 @@
3030
--- Messages handled in lspconfig: `eslint/openDoc`, `eslint/confirmESLintExecution`, `eslint/probeFailed`, `eslint/noLibrary`
3131
---
3232
--- Additional messages you can handle: `eslint/noConfig`
33+
---
34+
--- ### Monorepo support
35+
---
36+
--- `vscode-eslint-language-server` supports monorepos by default. It will automatically find the config file corresponding to the package you are working on. You can use different configs in different packages.
37+
--- This works without the need of spawning multiple instances of `vscode-eslint-language-server`.
38+
--- You can use a different version of ESLint in each package, but it is recommended to use the same version of ESLint in all packages. The location of the ESLint binary will be determined automatically.
39+
---
40+
--- /!\ When using flat config files, you need to use them across all your packages in your monorepo, as it's a global setting for the server.
3341

3442
local util = require 'lspconfig.util'
3543
local lsp = vim.lsp
3644

45+
local eslint_config_files = {
46+
'.eslintrc',
47+
'.eslintrc.js',
48+
'.eslintrc.cjs',
49+
'.eslintrc.yaml',
50+
'.eslintrc.yml',
51+
'.eslintrc.json',
52+
'eslint.config.js',
53+
'eslint.config.mjs',
54+
'eslint.config.cjs',
55+
'eslint.config.ts',
56+
'eslint.config.mts',
57+
'eslint.config.cts',
58+
}
59+
3760
return {
3861
cmd = { 'vscode-eslint-language-server', '--stdio' },
3962
filetypes = {
@@ -62,26 +85,37 @@ return {
6285
}, nil, bufnr)
6386
end, {})
6487
end,
65-
-- https://eslint.org/docs/user-guide/configuring/configuration-files#configuration-file-formats
6688
root_dir = function(bufnr, on_dir)
67-
local root_file_patterns = {
68-
'.eslintrc',
69-
'.eslintrc.js',
70-
'.eslintrc.cjs',
71-
'.eslintrc.yaml',
72-
'.eslintrc.yml',
73-
'.eslintrc.json',
74-
'eslint.config.js',
75-
'eslint.config.mjs',
76-
'eslint.config.cjs',
77-
'eslint.config.ts',
78-
'eslint.config.mts',
79-
'eslint.config.cts',
80-
}
89+
-- The project root is where the LSP can be started from
90+
-- As stated in the documentation above, this LSP supports monorepos and simple projects.
91+
-- We select then from the project root, which is identified by the presence of a package
92+
-- manager lock file.
93+
local project_root_markers = { 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lockb' }
94+
local project_root = vim.fs.root(bufnr, project_root_markers)
95+
if not project_root then
96+
return nil
97+
end
98+
99+
-- We know that the buffer is using ESLint if it has a config file
100+
-- in its directory tree.
101+
--
102+
-- Eslint used to support package.json files as config files, but it doesn't anymore.
103+
-- We keep this for backward compatibility.
104+
local filename = vim.api.nvim_buf_get_name(bufnr)
105+
local eslint_config_files_with_package_json =
106+
util.insert_package_json(eslint_config_files, 'eslintConfig', filename)
107+
local is_buffer_using_eslint = vim.fs.find(eslint_config_files_with_package_json, {
108+
path = filename,
109+
type = 'file',
110+
limit = 1,
111+
upward = true,
112+
stop = vim.fs.dirname(project_root),
113+
})[1]
114+
if not is_buffer_using_eslint then
115+
return nil
116+
end
81117

82-
local fname = vim.api.nvim_buf_get_name(bufnr)
83-
root_file_patterns = util.insert_package_json(root_file_patterns, 'eslintConfig', fname)
84-
on_dir(vim.fs.dirname(vim.fs.find(root_file_patterns, { path = fname, upward = true })[1]))
118+
on_dir(project_root)
85119
end,
86120
-- Refer to https://github.com/Microsoft/vscode-eslint#settings-options for documentation.
87121
settings = {
@@ -107,7 +141,7 @@ return {
107141
-- This path is relative to the workspace folder (root dir) of the server instance.
108142
nodePath = '',
109143
-- use the workspace folder location or the file location (if no workspace folder is open) as the working directory
110-
workingDirectory = { mode = 'location' },
144+
workingDirectory = { mode = 'auto' },
111145
codeAction = {
112146
disableRuleComment = {
113147
enable = true,
@@ -131,18 +165,18 @@ return {
131165
name = vim.fn.fnamemodify(root_dir, ':t'),
132166
}
133167

134-
-- Support flat config
135-
local flat_config_files = {
136-
'eslint.config.js',
137-
'eslint.config.mjs',
138-
'eslint.config.cjs',
139-
'eslint.config.ts',
140-
'eslint.config.mts',
141-
'eslint.config.cts',
142-
}
168+
-- Support flat config files
169+
-- They contain 'config' in the file name
170+
local flat_config_files = vim.tbl_filter(function(file)
171+
return file:match('config')
172+
end, eslint_config_files)
143173

144174
for _, file in ipairs(flat_config_files) do
145-
if vim.fn.filereadable(root_dir .. '/' .. file) == 1 then
175+
local found_files = vim.fs.find(function(name, path)
176+
return name == file and not path:match('[/\\]node_modules[/\\]')
177+
end, { path = root_dir, type = 'file', limit = 1 })
178+
179+
if #found_files > 0 then
146180
config.settings.experimental = config.settings.experimental or {}
147181
config.settings.experimental.useFlatConfig = true
148182
break

lsp/svelte.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ return {
1313
cmd = { 'svelteserver', '--stdio' },
1414
filetypes = { 'svelte' },
1515
root_dir = function(bufnr, on_dir)
16-
local root_files = { 'package.json', '.git' }
16+
local root_files = { 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lockb' }
1717
local fname = vim.api.nvim_buf_get_name(bufnr)
1818
-- Svelte LSP only supports file:// schema. https://github.com/sveltejs/language-tools/issues/2777
1919
if vim.uv.fs_stat(fname) ~= nil then

lsp/ts_ls.lua

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,14 @@
3232
--- Use the `:LspTypescriptSourceAction` command to see "whole file" ("source") code-actions such as:
3333
--- - organize imports
3434
--- - remove unused code
35+
---
36+
--- ### Monorepo support
37+
---
38+
--- `ts_ls` supports monorepos by default. It will automatically find the `tsconfig.json` or `jsconfig.json` corresponding to the package you are working on.
39+
--- This works without the need of spawning multiple instances of `ts_ls`, saving memory.
40+
---
41+
--- It is recommended to use the same version of TypeScript in all packages, and therefore have it available in your workspace root. The location of the TypeScript binary will be determined automatically, but only once.
42+
---
3543

3644
return {
3745
init_options = { hostInfo = 'neovim' },
@@ -44,7 +52,33 @@ return {
4452
'typescriptreact',
4553
'typescript.tsx',
4654
},
47-
root_markers = { 'tsconfig.json', 'jsconfig.json', 'package.json', '.git' },
55+
root_dir = function(bufnr, on_dir)
56+
-- The project root is where the LSP can be started from
57+
-- As stated in the documentation above, this LSP supports monorepos and simple projects.
58+
-- We select then from the project root, which is identified by the presence of a package
59+
-- manager lock file.
60+
local project_root_markers = { 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lockb' }
61+
local project_root = vim.fs.root(bufnr, project_root_markers)
62+
if not project_root then
63+
return nil
64+
end
65+
66+
-- We know that the buffer is using Typescript if it has a config file
67+
-- in its directory tree.
68+
local ts_config_files = { 'tsconfig.json', 'jsconfig.json' }
69+
local is_buffer_using_typescript = vim.fs.find(ts_config_files, {
70+
path = vim.api.nvim_buf_get_name(bufnr),
71+
type = 'file',
72+
limit = 1,
73+
upward = true,
74+
stop = vim.fs.dirname(project_root),
75+
})[1]
76+
if not is_buffer_using_typescript then
77+
return nil
78+
end
79+
80+
on_dir(project_root)
81+
end,
4882
handlers = {
4983
-- handle rename request for certain code actions like extracting functions / types
5084
['_typescript.rename'] = function(_, result, ctx)

lsp/tsgo.lua

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@
55
--- `typescript-go` is experimental port of the TypeScript compiler (tsc) and language server (tsserver) to the Go programming language.
66
---
77
--- `tsgo` can be installed via npm `npm install @typescript/native-preview`.
8+
---
9+
--- ### Monorepo support
10+
---
11+
--- `tsgo` supports monorepos by default. It will automatically find the `tsconfig.json` or `jsconfig.json` corresponding to the package you are working on.
12+
--- This works without the need of spawning multiple instances of `tsgo`, saving memory.
13+
---
14+
--- It is recommended to use the same version of TypeScript in all packages, and therefore have it available in your workspace root. The location of the TypeScript binary will be determined automatically, but only once.
15+
---
816
return {
917
cmd = { 'tsgo', '--lsp', '--stdio' },
1018
filetypes = {
@@ -15,5 +23,31 @@ return {
1523
'typescriptreact',
1624
'typescript.tsx',
1725
},
18-
root_markers = { 'tsconfig.json', 'jsconfig.json', 'package.json', '.git' },
26+
root_dir = function(bufnr, on_dir)
27+
-- The project root is where the LSP can be started from
28+
-- As stated in the documentation above, this LSP supports monorepos and simple projects.
29+
-- We select then from the project root, which is identified by the presence of a package
30+
-- manager lock file.
31+
local project_root_markers = { 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lockb' }
32+
local project_root = vim.fs.root(bufnr, project_root_markers)
33+
if not project_root then
34+
return nil
35+
end
36+
37+
-- We know that the buffer is using Typescript if it has a config file
38+
-- in its directory tree.
39+
local ts_config_files = { 'tsconfig.json', 'jsconfig.json' }
40+
local is_buffer_using_typescript = vim.fs.find(ts_config_files, {
41+
path = vim.api.nvim_buf_get_name(bufnr),
42+
type = 'file',
43+
limit = 1,
44+
upward = true,
45+
stop = vim.fs.dirname(project_root),
46+
})[1]
47+
if not is_buffer_using_typescript then
48+
return nil
49+
end
50+
51+
on_dir(project_root)
52+
end,
1953
}

lsp/vtsls.lua

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,13 @@
5757
--- ```
5858
---
5959
--- See `vue_ls` section and https://github.com/vuejs/language-tools/wiki/Neovim for more information.
60+
---
61+
--- ### Monorepo support
62+
---
63+
--- `vtsls` supports monorepos by default. It will automatically find the `tsconfig.json` or `jsconfig.json` corresponding to the package you are working on.
64+
--- This works without the need of spawning multiple instances of `vtsls`, saving memory.
65+
---
66+
--- It is recommended to use the same version of TypeScript in all packages, and therefore have it available in your workspace root. The location of the TypeScript binary will be determined automatically, but only once.
6067

6168
return {
6269
cmd = { 'vtsls', '--stdio' },
@@ -68,5 +75,31 @@ return {
6875
'typescriptreact',
6976
'typescript.tsx',
7077
},
71-
root_markers = { 'tsconfig.json', 'package.json', 'jsconfig.json', '.git' },
78+
root_dir = function(bufnr, on_dir)
79+
-- The project root is where the LSP can be started from
80+
-- As stated in the documentation above, this LSP supports monorepos and simple projects.
81+
-- We select then from the project root, which is identified by the presence of a package
82+
-- manager lock file.
83+
local project_root_markers = { 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lockb' }
84+
local project_root = vim.fs.root(bufnr, project_root_markers)
85+
if not project_root then
86+
return nil
87+
end
88+
89+
-- We know that the buffer is using Typescript if it has a config file
90+
-- in its directory tree.
91+
local ts_config_files = { 'tsconfig.json', 'jsconfig.json' }
92+
local is_buffer_using_typescript = vim.fs.find(ts_config_files, {
93+
path = vim.api.nvim_buf_get_name(bufnr),
94+
type = 'file',
95+
limit = 1,
96+
upward = true,
97+
stop = vim.fs.dirname(project_root),
98+
})[1]
99+
if not is_buffer_using_typescript then
100+
return nil
101+
end
102+
103+
on_dir(project_root)
104+
end,
72105
}

0 commit comments

Comments
 (0)