Repository: neovim/nvim-lspconfig
Pull request #3795: fix: improve typescript server path search for ts dependents lsp
Author: login: fuegoio
Created: 2025-04-27T17:53:55Z | Merged: 2025-04-28T12:11:00Z

LSPs like `volar` (for VueJS), `astro` or `mdx_analyzer` rely on a path for the Typescript server they should use. But this path is wrongly assumed to be inside `node_modules/typescript/lib` starting from the nearest `package.json` file.

In fact, in case of monorepos / workspaces, with all package managers like `npm`, `yarn` or `pnpm`, some hoisting can take place. This means that the `typescript` node module will get move higher up in the tree to be shared between all projects and avoid duplication. The NodeJS module resolution works like this, by trying to find the module upward in the tree, one directory after another.

Therefore, it can happen a case like this:
```
monorepo-root
 - apps
 > - app-number-one
 >> - node_modules (without typescript)
 >> - package.json
 > - app-number-two
 >> - node_modules (without typescript)
 >> - package.json
 - node_modules (with typescript)
 > - typescript
 - package.json
```

I therefore adapted the Lua algorithm to match the one of NodeJS (and the one of the `ts_ls` server, that does it natively).

> Even if some LSPs like `volar` going towards "hybrid modes" where they don't need to spawn their full TS server (because usually users will spawn it nevertheless for other projects), this is still required and needs to be correctly setup to work on the files they target.

I've also mutualized this logic at one place to avoid de-duplication, but feel free to correct me on that. I've created a simple `typescript.lua` file, imagining that we could have language specific utils for LSPs that need it.


Diff:
diff --git a/lsp/astro.lua b/lsp/astro.lua
index 2bd64310ff..6f33439c79 100644
--- a/lsp/astro.lua
+++ b/lsp/astro.lua
@@ -7,10 +7,7 @@
 --- npm install -g @astrojs/language-server
 --- ```
 
-local function get_typescript_server_path(root_dir)
-  local project_root = vim.fs.dirname(vim.fs.find('node_modules', { path = root_dir, upward = true })[1])
-  return project_root and vim.fs.joinpath(project_root, 'node_modules', 'typescript', 'lib') or ''
-end
+local util = require 'lspconfig.util'
 
 return {
   cmd = { 'astro-ls', '--stdio' },
@@ -21,7 +18,7 @@ return {
   },
   before_init = function(_, config)
     if config.init_options and config.init_options.typescript and not config.init_options.typescript.tsdk then
-      config.init_options.typescript.tsdk = get_typescript_server_path(config.root_dir)
+      config.init_options.typescript.tsdk = util.get_typescript_server_path(config.root_dir)
     end
   end,
 }
diff --git a/lsp/mdx_analyzer.lua b/lsp/mdx_analyzer.lua
index 0eb5a0080a..ce87f53827 100644
--- a/lsp/mdx_analyzer.lua
+++ b/lsp/mdx_analyzer.lua
@@ -3,10 +3,7 @@
 ---
 --- `mdx-analyzer`, a language server for MDX
 
-local function get_typescript_server_path(root_dir)
-  local project_root = vim.fs.dirname(vim.fs.find('node_modules', { path = root_dir, upward = true })[1])
-  return project_root and vim.fs.joinpath(project_root, 'node_modules', 'typescript', 'lib') or ''
-end
+local util = require 'lspconfig.util'
 
 return {
   cmd = { 'mdx-language-server', '--stdio' },
@@ -18,7 +15,7 @@ return {
   },
   before_init = function(_, config)
     if config.init_options and config.init_options.typescript and not config.init_options.typescript.tsdk then
-      config.init_options.typescript.tsdk = get_typescript_server_path(config.root_dir)
+      config.init_options.typescript.tsdk = util.get_typescript_server_path(config.root_dir)
     end
   end,
 }
diff --git a/lsp/volar.lua b/lsp/volar.lua
index 50b9a3261f..bd8006cc76 100644
--- a/lsp/volar.lua
+++ b/lsp/volar.lua
@@ -73,10 +73,7 @@
 --- })
 --- ```
 
-local function get_typescript_server_path(root_dir)
-  local project_root = vim.fs.dirname(vim.fs.find('node_modules', { path = root_dir, upward = true })[1])
-  return project_root and vim.fs.joinpath(project_root, 'node_modules', 'typescript', 'lib') or ''
-end
+local util = require 'lspconfig.util'
 
 -- https://github.com/vuejs/language-tools/blob/master/packages/language-server/lib/types.ts
 local volar_init_options = {
@@ -92,7 +89,7 @@ return {
   init_options = volar_init_options,
   before_init = function(_, config)
     if config.init_options and config.init_options.typescript and config.init_options.typescript.tsdk == '' then
-      config.init_options.typescript.tsdk = get_typescript_server_path(config.root_dir)
+      config.init_options.typescript.tsdk = util.get_typescript_server_path(config.root_dir)
     end
   end,
 }
diff --git a/lua/lspconfig/configs/astro.lua b/lua/lspconfig/configs/astro.lua
index 106d247f05..b979d7119c 100644
--- a/lua/lspconfig/configs/astro.lua
+++ b/lua/lspconfig/configs/astro.lua
@@ -1,10 +1,5 @@
 local util = require 'lspconfig.util'
 
-local function get_typescript_server_path(root_dir)
-  local project_root = vim.fs.dirname(vim.fs.find('node_modules', { path = root_dir, upward = true })[1])
-  return project_root and (project_root .. '/node_modules/typescript/lib') or ''
-end
-
 return {
   default_config = {
     cmd = { 'astro-ls', '--stdio' },
@@ -15,7 +10,7 @@ return {
     },
     on_new_config = function(new_config, new_root_dir)
       if vim.tbl_get(new_config.init_options, 'typescript') and not new_config.init_options.typescript.tsdk then
-        new_config.init_options.typescript.tsdk = get_typescript_server_path(new_root_dir)
+        new_config.init_options.typescript.tsdk = util.get_typescript_server_path(new_root_dir)
       end
     end,
   },
diff --git a/lua/lspconfig/configs/mdx_analyzer.lua b/lua/lspconfig/configs/mdx_analyzer.lua
index 6ae65a2bda..7c68cc81f0 100644
--- a/lua/lspconfig/configs/mdx_analyzer.lua
+++ b/lua/lspconfig/configs/mdx_analyzer.lua
@@ -1,10 +1,5 @@
 local util = require 'lspconfig.util'
 
-local function get_typescript_server_path(root_dir)
-  local project_root = vim.fs.dirname(vim.fs.find('node_modules', { path = root_dir, upward = true })[1])
-  return project_root and (project_root .. '/node_modules/typescript/lib') or ''
-end
-
 return {
   default_config = {
     cmd = { 'mdx-language-server', '--stdio' },
@@ -17,7 +12,7 @@ return {
     },
     on_new_config = function(new_config, new_root_dir)
       if vim.tbl_get(new_config.init_options, 'typescript') and not new_config.init_options.typescript.tsdk then
-        new_config.init_options.typescript.tsdk = get_typescript_server_path(new_root_dir)
+        new_config.init_options.typescript.tsdk = util.get_typescript_server_path(new_root_dir)
       end
     end,
   },
diff --git a/lua/lspconfig/configs/volar.lua b/lua/lspconfig/configs/volar.lua
index ff52aed5f1..1907009012 100644
--- a/lua/lspconfig/configs/volar.lua
+++ b/lua/lspconfig/configs/volar.lua
@@ -1,10 +1,5 @@
 local util = require 'lspconfig.util'
 
-local function get_typescript_server_path(root_dir)
-  local project_root = vim.fs.dirname(vim.fs.find('node_modules', { path = root_dir, upward = true })[1])
-  return project_root and (project_root .. '/node_modules/typescript/lib') or ''
-end
-
 -- https://github.com/vuejs/language-tools/blob/master/packages/language-server/lib/types.ts
 local volar_init_options = {
   typescript = {
@@ -24,7 +19,7 @@ return {
         and new_config.init_options.typescript
         and new_config.init_options.typescript.tsdk == ''
       then
-        new_config.init_options.typescript.tsdk = get_typescript_server_path(new_root_dir)
+        new_config.init_options.typescript.tsdk = util.get_typescript_server_path(new_root_dir)
       end
     end,
   },
diff --git a/lua/lspconfig/util.lua b/lua/lspconfig/util.lua
index a8cf743952..2807edf3ea 100644
--- a/lua/lspconfig/util.lua
+++ b/lua/lspconfig/util.lua
@@ -69,6 +69,18 @@ function M.strip_archive_subpath(path)
   return path
 end
 
+function M.get_typescript_server_path(root_dir)
+  local project_roots = vim.fs.find('node_modules', { path = root_dir, upward = true, limit = math.huge })
+  for _, project_root in ipairs(project_roots) do
+    local typescript_path = project_root .. '/typescript'
+    local stat = vim.loop.fs_stat(typescript_path)
+    if stat and stat.type == 'directory' then
+      return typescript_path .. '/lib'
+    end
+  end
+  return ''
+end
+
 ---
 ---
 ---


Reviews:
user: login: justinmk
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-27T17:57:12Z
user: login: justinmk
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-27T22:37:57Z
user: login: fuegoio
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T06:54:55Z

Review comments:
diff_hunk: @@ -0,0 +1,15 @@
+local M = {}
+
+function M.get_typescript_server_path(root_dir)
path: lua/lspconfig/typescript.lua
user: login: justinmk
body: for now, add this to util.lua.

we're trying to deprecate everything in that file, but it's helpful for this to live there until we figure out how to upstream things like this.
created_at: 2025-04-27T17:57:11Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,3 @@
+local M = {}
+
+return M
path: lua/lspconfig/typescript.lua
user: login: justinmk
body: ?
created_at: 2025-04-27T22:37:56Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,3 @@
+local M = {}
+
+return M
path: lua/lspconfig/typescript.lua
user: login: fuegoio
body: Oups sorry for that ahah
created_at: 2025-04-28T06:54:55Z
author_association: CONTRIBUTOR

PR comments:
user: login: fuegoio
created_at: 2025-04-27T17:55:34Z
body: Seeing from the CI that I should maybe not update the legacies LSP configs, but I understood they were still used in older versions of Nvim. Feel free to tell me what I should update or not.
author_association: CONTRIBUTOR
user: login: justinmk
created_at: 2025-04-27T17:57:51Z
body: cc @Grahf0085

> but I understood they were still used in older versions of Nvim

well, they're frozen. anyone on an older version can either upgrade or use the old configs.
author_association: MEMBER
user: login: justinmk
created_at: 2025-04-27T17:59:22Z
body: would https://github.com/neovim/neovim/pull/33485 address your use-case here?
author_association: MEMBER
user: login: fuegoio
created_at: 2025-04-27T18:08:05Z
body: > cc @Grahf0085
> 
> > but I understood they were still used in older versions of Nvim
> 
> well, they're frozen. anyone on an older version can either upgrade or use the old configs.

So I don't update them, even for fixes like this that could benefit from a backporting? I'm myself not yet using the new config as my config is a bit messy and I have yet to understand how to change my `setup` to `enable` ahah.

> would [neovim/neovim#33485](https://github.com/neovim/neovim/pull/33485) address your use-case here?

Unfortunately the root marker is different from this as here we could have a root that is successfully determined as `app-number-one` if for example this project has Vue or Astro installed (main requirement for the LSP to work), but the TS server could still be up one level or two.

Honestly LSPs should be in charge of doing that instead but I guess they want to allow flexibility on instanciation.
author_association: CONTRIBUTOR
user: login: justinmk
created_at: 2025-04-27T22:42:27Z
body: > So I don't update them, even for fixes like this that could benefit from a backporting?

can make an exception in this case.
author_association: MEMBER
user: login: fuegoio
created_at: 2025-04-28T06:56:21Z
body: > can make an exception in this case.

Nice thanks! Normally good on my side then :)
author_association: CONTRIBUTOR