Skip to content

Commit 386c9a3

Browse files
committed
feat(lsp): use smarter detection of typescript project
1 parent f1237a8 commit 386c9a3

File tree

7 files changed

+168
-73
lines changed

7 files changed

+168
-73
lines changed

lsp/biome.lua

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
--- `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.
1313

1414
local util = require 'lspconfig.util'
15+
local typescript = require 'lspconfig.typescript'
1516

1617
---@type vim.lsp.Config
1718
return {
@@ -40,22 +41,15 @@ return {
4041
},
4142
workspace_required = true,
4243
root_dir = function(bufnr, on_dir)
43-
-- The project root is where the LSP can be started from
44-
-- As stated in the documentation above, this LSP supports monorepos and simple projects.
45-
-- We select then from the project root, which is identified by the presence of a package
46-
-- manager lock file.
47-
local root_markers = { 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lockb', 'bun.lock' }
48-
-- Give the root markers equal priority by wrapping them in a table
49-
root_markers = vim.fn.has('nvim-0.11.3') == 1 and { root_markers, { '.git' } }
50-
or vim.list_extend(root_markers, { '.git' })
44+
local project = typescript.detect_project(bufnr)
5145

5246
-- exclude deno
53-
if vim.fs.root(bufnr, { 'deno.json', 'deno.jsonc', 'deno.lock' }) then
47+
if project and (project.kind == 'deno') then
5448
return
5549
end
5650

5751
-- We fallback to the current working directory if no project root is found
58-
local project_root = vim.fs.root(bufnr, root_markers) or vim.fn.getcwd()
52+
local project_root = project and project.root_dir or vim.fn.getcwd()
5953

6054
-- We know that the buffer is using Biome if it has a config file
6155
-- in its directory tree.

lsp/denols.lua

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
--- }
1414
--- ```
1515

16+
local typescript = require 'lspconfig.typescript'
1617
local lsp = vim.lsp
1718

1819
local function virtual_text_document_handler(uri, res, client)
@@ -76,22 +77,15 @@ return {
7677
'typescript.tsx',
7778
},
7879
root_dir = function(bufnr, on_dir)
79-
-- The project root is where the LSP can be started from
80-
local root_markers = { 'deno.lock' }
81-
-- Give the root markers equal priority by wrapping them in a table
82-
root_markers = vim.fn.has('nvim-0.11.3') == 1 and { root_markers, { '.git' } }
83-
or vim.list_extend(root_markers, { '.git' })
84-
-- exclude non-deno projects (npm, yarn, pnpm, bun)
85-
local non_deno_path = vim.fs.root(
86-
bufnr,
87-
{ 'package.json', 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lockb', 'bun.lock' }
88-
)
89-
local project_root = vim.fs.root(bufnr, root_markers)
90-
if non_deno_path and (not project_root or #non_deno_path >= #project_root) then
80+
local project = typescript.detect_project(bufnr)
81+
82+
if not project then
9183
return
9284
end
93-
-- We fallback to the current working directory if no project root is found
94-
on_dir(project_root or vim.fn.getcwd())
85+
86+
if project.kind == 'deno' then
87+
on_dir(project.root_dir)
88+
end
9589
end,
9690
settings = {
9791
deno = {

lsp/eslint.lua

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
--- /!\ 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.
4141

4242
local util = require 'lspconfig.util'
43+
local typescript = require 'lspconfig.typescript'
4344
local lsp = vim.lsp
4445

4546
local eslint_config_files = {
@@ -87,22 +88,15 @@ return {
8788
end, {})
8889
end,
8990
root_dir = function(bufnr, on_dir)
90-
-- The project root is where the LSP can be started from
91-
-- As stated in the documentation above, this LSP supports monorepos and simple projects.
92-
-- We select then from the project root, which is identified by the presence of a package
93-
-- manager lock file.
94-
local root_markers = { 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lockb', 'bun.lock' }
95-
-- Give the root markers equal priority by wrapping them in a table
96-
root_markers = vim.fn.has('nvim-0.11.3') == 1 and { root_markers, { '.git' } }
97-
or vim.list_extend(root_markers, { '.git' })
91+
local project = typescript.detect_project(bufnr)
9892

9993
-- exclude deno
100-
if vim.fs.root(bufnr, { 'deno.json', 'deno.jsonc', 'deno.lock' }) then
94+
if project and (project.kind == 'deno') then
10195
return
10296
end
10397

10498
-- We fallback to the current working directory if no project root is found
105-
local project_root = vim.fs.root(bufnr, root_markers) or vim.fn.getcwd()
99+
local project_root = project and project.root_dir or vim.fn.getcwd()
106100

107101
-- We know that the buffer is using ESLint if it has a config file
108102
-- in its directory tree.

lsp/ts_ls.lua

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@
4141
--- 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.
4242
---
4343

44+
local typescript = require 'lspconfig.typescript'
45+
4446
---@type vim.lsp.Config
4547
return {
4648
init_options = { hostInfo = 'neovim' },
@@ -54,22 +56,18 @@ return {
5456
'typescript.tsx',
5557
},
5658
root_dir = function(bufnr, on_dir)
57-
-- The project root is where the LSP can be started from
58-
-- As stated in the documentation above, this LSP supports monorepos and simple projects.
59-
-- We select then from the project root, which is identified by the presence of a package
60-
-- manager lock file.
61-
local root_markers = { 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lockb', 'bun.lock' }
62-
-- Give the root markers equal priority by wrapping them in a table
63-
root_markers = vim.fn.has('nvim-0.11.3') == 1 and { root_markers, { '.git' } }
64-
or vim.list_extend(root_markers, { '.git' })
59+
local project = typescript.detect_project(bufnr)
60+
61+
if not project then
62+
return
63+
end
64+
6565
-- exclude deno
66-
local deno_path = vim.fs.root(bufnr, { 'deno.json', 'deno.jsonc', 'deno.lock' })
67-
local project_root = vim.fs.root(bufnr, root_markers)
68-
if deno_path and (not project_root or #deno_path >= #project_root) then
66+
if project.kind == 'deno' then
6967
return
7068
end
71-
-- We fallback to the current working directory if no project root is found
72-
on_dir(project_root or vim.fn.getcwd())
69+
70+
on_dir(project.root_dir)
7371
end,
7472
handlers = {
7573
-- handle rename request for certain code actions like extracting functions / types

lsp/tsgo.lua

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
--- 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.
1515
---
1616

17+
local typescript = require 'lspconfig.typescript'
18+
1719
---@type vim.lsp.Config
1820
return {
1921
cmd = { 'tsgo', '--lsp', '--stdio' },
@@ -26,23 +28,17 @@ return {
2628
'typescript.tsx',
2729
},
2830
root_dir = function(bufnr, on_dir)
29-
-- The project root is where the LSP can be started from
30-
-- As stated in the documentation above, this LSP supports monorepos and simple projects.
31-
-- We select then from the project root, which is identified by the presence of a package
32-
-- manager lock file.
33-
local root_markers = { 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lockb', 'bun.lock' }
34-
-- Give the root markers equal priority by wrapping them in a table
35-
root_markers = vim.fn.has('nvim-0.11.3') == 1 and { root_markers, { '.git' } }
36-
or vim.list_extend(root_markers, { '.git' })
31+
local project = typescript.detect_project(bufnr)
3732

38-
-- exclude deno
39-
if vim.fs.root(bufnr, { 'deno.json', 'deno.jsonc', 'deno.lock' }) then
33+
if not project then
4034
return
4135
end
4236

43-
-- We fallback to the current working directory if no project root is found
44-
local project_root = vim.fs.root(bufnr, root_markers) or vim.fn.getcwd()
37+
-- exclude deno
38+
if project.kind == 'deno' then
39+
return
40+
end
4541

46-
on_dir(project_root)
42+
on_dir(project.root_dir)
4743
end,
4844
}

lsp/vtsls.lua

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@
6565
---
6666
--- 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.
6767

68+
local typescript = require 'lspconfig.typescript'
69+
6870
---@type vim.lsp.Config
6971
return {
7072
cmd = { 'vtsls', '--stdio' },
@@ -80,23 +82,17 @@ return {
8082
'typescript.tsx',
8183
},
8284
root_dir = function(bufnr, on_dir)
83-
-- The project root is where the LSP can be started from
84-
-- As stated in the documentation above, this LSP supports monorepos and simple projects.
85-
-- We select then from the project root, which is identified by the presence of a package
86-
-- manager lock file.
87-
local root_markers = { 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lockb', 'bun.lock' }
88-
-- Give the root markers equal priority by wrapping them in a table
89-
root_markers = vim.fn.has('nvim-0.11.3') == 1 and { root_markers, { '.git' } }
90-
or vim.list_extend(root_markers, { '.git' })
85+
local project = typescript.detect_project(bufnr)
9186

92-
-- exclude deno
93-
if vim.fs.root(bufnr, { 'deno.json', 'deno.jsonc', 'deno.lock' }) then
87+
if not project then
9488
return
9589
end
9690

97-
-- We fallback to the current working directory if no project root is found
98-
local project_root = vim.fs.root(bufnr, root_markers) or vim.fn.getcwd()
91+
-- exclude deno
92+
if project.kind == 'deno' then
93+
return
94+
end
9995

100-
on_dir(project_root)
96+
on_dir(project.root_dir)
10197
end,
10298
}

lua/lspconfig/typescript.lua

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
--- @brief
2+
--- Utility for detecting the root directory of a TypeScript project
3+
4+
local M = {}
5+
6+
--- @class lspconfig.typescript.Project
7+
--- @field kind '"node"' | '"deno"' | '"bun"'
8+
--- @field root_dir string
9+
10+
--- @class lspconfig.typescript.Package
11+
--- @field kind '"non-deno"' | '"deno"'
12+
--- @field path string
13+
14+
--- @class lspconfig.typescript.Workspace
15+
--- @field kind '"node"' | '"deno"' | '"bun"'
16+
--- @field root_dir string
17+
18+
--- Detect the TypeScript project in the current working directory.
19+
--- @param source (integer | string)
20+
--- @return lspconfig.typescript.Project?
21+
function M.detect_project(source)
22+
-- The search scope limit is the closer of either the current directory or the closest Git repository directly above the file.
23+
-- This prevents false positives if files like package-lock.json exist in the home directory.
24+
local cwd = vim.fn.getcwd()
25+
local git_root = vim.fs.root(source, '.git')
26+
local root = (git_root and #git_root >= #cwd) and git_root or cwd
27+
28+
-- First, we look for configuration files that indicate the presence of a specific runtime under the root.
29+
30+
--- @type lspconfig.typescript.Package[]
31+
local packages = {}
32+
33+
local non_deno_package = vim.fs.root(source, { 'package.json' })
34+
if non_deno_package and (#non_deno_package >= #root) then
35+
table.insert(packages, { kind = 'non-deno', path = non_deno_package })
36+
end
37+
38+
local deno_package = vim.fs.root(source, { 'deno.json', 'deno.jsonc' })
39+
if deno_package and (#deno_package >= #root) then
40+
table.insert(packages, { kind = 'deno', path = deno_package })
41+
end
42+
43+
table.sort(packages, function(a, b)
44+
return #a.path > #b.path
45+
end)
46+
47+
--- @type lspconfig.typescript.Package?
48+
local closest_package = (#packages == 1 or (#packages > 1 and #packages[1].path > #packages[2].path)) and packages[1]
49+
or nil
50+
51+
if not closest_package then
52+
-- If no package is found, there is no useful information regarding the project.
53+
return nil
54+
end
55+
56+
-- Second, we look for a lock file that can reliably distinguish between runtime kind.
57+
58+
--- @type lspconfig.typescript.Workspace[]
59+
local workspaces = {}
60+
61+
local node_workspace_root = vim.fs.root(source, { 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml' })
62+
if node_workspace_root and (#root <= #node_workspace_root) and (#node_workspace_root <= #closest_package.path) then
63+
table.insert(workspaces, { kind = 'node', root_dir = node_workspace_root })
64+
end
65+
66+
local deno_workspace_root = vim.fs.root(source, { 'deno.lock' })
67+
if deno_workspace_root and (#root <= #deno_workspace_root) and (#deno_workspace_root <= #closest_package.path) then
68+
table.insert(workspaces, { kind = 'deno', root_dir = deno_workspace_root })
69+
end
70+
71+
local bun_workspace_root = vim.fs.root(source, { 'bun.lock', 'bun.lockb' })
72+
if bun_workspace_root and (#root <= #bun_workspace_root) and (#bun_workspace_root <= #closest_package.path) then
73+
table.insert(workspaces, { kind = 'bun', root_dir = bun_workspace_root })
74+
end
75+
76+
-- If there is only one directory that is closest, this is the workspace we should find.
77+
-- If there are multiple workspaces pointing to the same directory, or if no workspaces are found,
78+
-- `detected_workspace` will be nil as there is no useful information regarding the workspace.
79+
80+
table.sort(workspaces, function(a, b)
81+
return #a.root_dir > #b.root_dir
82+
end)
83+
84+
--- @type lspconfig.typescript.Workspace?
85+
local detected_workspace = (
86+
#workspaces == 1 or (#workspaces > 1 and #workspaces[1].root_dir > #workspaces[2].root_dir)
87+
)
88+
and workspaces[1]
89+
or nil
90+
91+
-- If no workspace is detected, the closest package directly above the file will be detected.
92+
if not detected_workspace then
93+
if closest_package.kind == 'deno' then
94+
return { kind = 'deno', root_dir = closest_package.path }
95+
else
96+
return { kind = 'node', root_dir = closest_package.path }
97+
end
98+
end
99+
100+
-- If a non-Deno workspace is detected:
101+
-- - If the immediate parent package is non-Deno, it is considered a package within that workspace, and root_dir is set to the workspace root.
102+
-- - If the immediate parent package is Deno, it is considered a single Deno project located under an unrelated non-Deno workspace.
103+
if detected_workspace.kind == 'node' or detected_workspace.kind == 'bun' then
104+
if closest_package.kind == 'non-deno' then
105+
return { kind = detected_workspace.kind, root_dir = detected_workspace.root_dir }
106+
else
107+
return { kind = 'deno', root_dir = closest_package.path }
108+
end
109+
end
110+
111+
-- If a Deno workspace is detected:
112+
-- A package.json file within a Deno workspace cannot be used as a determining factor,
113+
-- as it might be leveraging Deno's first-class package.json support.
114+
-- Therefore, it is immediately considered a package within a Deno workspace.
115+
-- See: https://docs.deno.com/runtime/fundamentals/node/#first-class-package.json-support
116+
if detected_workspace.kind == 'deno' then
117+
return { kind = 'deno', root_dir = detected_workspace.root_dir }
118+
end
119+
120+
return nil
121+
end
122+
123+
return M

0 commit comments

Comments
 (0)