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
This commit is contained in:
Ryan Ward
2026-07-16 16:58:47 -05:00
parent 0db915712f
commit 4aa2c0d685
3 changed files with 158 additions and 1 deletions
+10 -1
View File
@@ -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 })
}
@@ -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<Value> {
// 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(())
}
+26
View File
@@ -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<Value> {
match self {
LSPServerType::TypeScriptLanguageServer => {
TypeScriptLanguageServerCandidate::resolve_initialization_options(
workspace_root,
path_env_var,
)
.await
}
_ => None,
}
}
pub fn candidate(&self, client: Arc<http_client::Client>) -> Box<dyn LanguageServerCandidate> {
match self {
LSPServerType::RustAnalyzer => Box::new(RustAnalyzerCandidate::new(client)),