From 4aa2c0d68574bbf176716b7732ec3adb61ba1d22 Mon Sep 17 00:00:00 2001 From: Ryan Ward Date: Thu, 16 Jul 2026 16:58:47 -0500 Subject: [PATCH] fix: resolve TypeScript LSP initialization failure typescript-language-server requires a valid TypeScript installation to function. Previously, no initializationOptions were sent during the LSP initialize request, causing the server to fail with: 'Could not find a valid TypeScript installation. Please ensure that the typescript dependency is installed in the workspace or that a valid tsserver.path is specified.' This fix: - Adds initializationOptions.tsserver.path resolution that searches for TypeScript in: workspace node_modules, global npm install, and npx cache - Wires initialization_options into the LSP startup flow via LSPServerType - Updates the install step to proactively install TypeScript globally if not found locally --- crates/lsp/src/config.rs | 11 +- .../src/servers/typescript_language_server.rs | 122 ++++++++++++++++++ crates/lsp/src/supported_servers.rs | 26 ++++ 3 files changed, 158 insertions(+), 1 deletion(-) diff --git a/crates/lsp/src/config.rs b/crates/lsp/src/config.rs index 79de5a22..960abd1f 100644 --- a/crates/lsp/src/config.rs +++ b/crates/lsp/src/config.rs @@ -205,7 +205,16 @@ impl LspServerConfig { custom_binary_config ); - let params = default_init_params(&self.initial_workspace, self.client_name)?; + let mut params = default_init_params(&self.initial_workspace, self.client_name)?; + + // Resolve server-specific initialization options (e.g., tsserver.path for TypeScript) + let init_options = self + .server_type + .initialization_options(&self.initial_workspace, executor.path_env_var()) + .await; + if let Some(options) = init_options { + params.initialization_options = Some(options); + } Ok(ResolvedLspCommand { command, params }) } diff --git a/crates/lsp/src/servers/typescript_language_server.rs b/crates/lsp/src/servers/typescript_language_server.rs index b59378df..5c80bcbd 100644 --- a/crates/lsp/src/servers/typescript_language_server.rs +++ b/crates/lsp/src/servers/typescript_language_server.rs @@ -4,6 +4,8 @@ use std::sync::Arc; #[cfg(feature = "local_fs")] use anyhow::Context; use async_trait::async_trait; +#[cfg(feature = "local_fs")] +use serde_json::Value; use crate::language_server_candidate::{LanguageServerCandidate, LanguageServerMetadata}; #[cfg(feature = "local_fs")] @@ -20,6 +22,97 @@ impl TypeScriptLanguageServerCandidate { Self { client } } + /// Resolves `initializationOptions` for `typescript-language-server`. + /// + /// The server requires a valid TypeScript installation to function. It searches for + /// `tsserver` in this order: + /// 1. The workspace's `node_modules/typescript/lib` (local install) + /// 2. A globally-installed TypeScript package (via `npm root -g`) + /// + /// Returns `initializationOptions.tsserver.path` pointing to the TypeScript lib directory + /// so the language server can locate `tsserver.js`. + #[cfg(feature = "local_fs")] + pub async fn resolve_initialization_options( + workspace_root: &Path, + path_env_var: Option<&str>, + ) -> Option { + // 1. Check workspace-local node_modules + let local_ts_lib = workspace_root.join("node_modules/typescript/lib"); + if local_ts_lib.join("tsserver.js").is_file() { + log::info!( + "typescript-language-server: using workspace typescript at {}", + local_ts_lib.display() + ); + return Some(serde_json::json!({ + "tsserver": { + "path": local_ts_lib.to_string_lossy() + } + })); + } + + // 2. Check global npm install location + if let Some(path_env) = path_env_var { + let mut cmd = command::r#async::Command::new("npm"); + cmd.env("PATH", path_env); + cmd.args(["root", "-g"]); + if let Ok(output) = cmd.output().await { + if output.status.success() { + let global_root = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let global_ts_lib = + std::path::PathBuf::from(&global_root).join("typescript/lib"); + if global_ts_lib.join("tsserver.js").is_file() { + log::info!( + "typescript-language-server: using global typescript at {}", + global_ts_lib.display() + ); + return Some(serde_json::json!({ + "tsserver": { + "path": global_ts_lib.to_string_lossy() + } + })); + } + } + } + } + + // 3. Try npx-managed typescript location (npx caches packages) + if let Some(path_env) = path_env_var { + let mut cmd = command::r#async::Command::new("npx"); + cmd.env("PATH", path_env); + cmd.args(["--yes", "--package", "typescript", "node", "-e", "console.log(require('typescript').sys.getExecutingFilePath())"]); + // Set cwd to workspace so npx resolves in the right context + cmd.current_dir(workspace_root); + if let Ok(output) = cmd.output().await { + if output.status.success() { + let tsserver_path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !tsserver_path.is_empty() { + // The executing file path is something like .../typescript/lib/tsserver.js + // We need the parent directory (the lib dir) + if let Some(lib_dir) = std::path::Path::new(&tsserver_path).parent() { + if lib_dir.join("tsserver.js").is_file() { + log::info!( + "typescript-language-server: using npx-resolved typescript at {}", + lib_dir.display() + ); + return Some(serde_json::json!({ + "tsserver": { + "path": lib_dir.to_string_lossy() + } + })); + } + } + } + } + } + } + + log::warn!( + "typescript-language-server: could not find a TypeScript installation for {}", + workspace_root.display() + ); + None + } + /// Returns a CustomBinaryConfig that runs the server via npx. /// npx handles downloading/caching the package automatically. #[cfg(feature = "local_fs")] @@ -89,6 +182,35 @@ impl LanguageServerCandidate for TypeScriptLanguageServerCandidate { anyhow::bail!("npx is not working. Ensure Node.js and npm are installed."); } + // Pre-warm: ensure the typescript package is available so the LSP server + // doesn't fail at initialization time with "Could not find a valid TypeScript + // installation". This uses `npm list` to check workspace-local or global installs, + // and falls back to installing typescript globally if not found. + let has_typescript = { + let output = executor + .command("npm") + .args(["list", "typescript", "--depth=0"]) + .output() + .await; + output.map(|o| o.status.success()).unwrap_or(false) + }; + + if !has_typescript { + log::info!("typescript not found locally, installing globally for LSP support"); + let output = executor + .command("npm") + .args(["install", "-g", "typescript"]) + .output() + .await + .context("Failed to install typescript globally")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + log::warn!("Failed to install typescript globally: {stderr}"); + // Non-fatal: the workspace might have it in node_modules already + } + } + log::info!("typescript-language-server will run via npx (no pre-install needed)"); Ok(()) } diff --git a/crates/lsp/src/supported_servers.rs b/crates/lsp/src/supported_servers.rs index 711f0de6..a93a301c 100644 --- a/crates/lsp/src/supported_servers.rs +++ b/crates/lsp/src/supported_servers.rs @@ -6,6 +6,8 @@ use std::sync::Arc; use command::r#async::Command; use itertools::Itertools; use serde::{Deserialize, Serialize}; +#[cfg(not(target_arch = "wasm32"))] +use serde_json::Value; use strum::IntoEnumIterator; use strum_macros::EnumIter; @@ -197,6 +199,30 @@ impl LSPServerType { } } + /// Returns server-specific `initializationOptions` to send during LSP initialization. + /// + /// For most servers this returns `None`. For `typescript-language-server`, it resolves + /// the path to `tsserver` from the workspace's `node_modules` or a globally-installed + /// TypeScript package so the server doesn't fail with "Could not find a valid TypeScript + /// installation". + #[cfg(not(target_arch = "wasm32"))] + pub async fn initialization_options( + &self, + workspace_root: &std::path::Path, + path_env_var: Option<&str>, + ) -> Option { + match self { + LSPServerType::TypeScriptLanguageServer => { + TypeScriptLanguageServerCandidate::resolve_initialization_options( + workspace_root, + path_env_var, + ) + .await + } + _ => None, + } + } + pub fn candidate(&self, client: Arc) -> Box { match self { LSPServerType::RustAnalyzer => Box::new(RustAnalyzerCandidate::new(client)),