From 921f1f2b2dcd94750f89ea34d6bcdcbd65ce87fd Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 14 Nov 2026 04:32:29 +0800 Subject: [PATCH] fix(lsp): run a server chosen from a Node.js installation on that Node (#3251) A server Path pointing at a script in a Node.js installation's bin (nvm's bin/tsc, bin/npx) ran through #!/usr/bin/env node on the project's Node.js interpreter or the first node on PATH; TypeScript 7's extensionless ESM tsc crashed on Node 20.9. That installation's bin now comes first on the server's PATH. --- crates/ide-sdk/src/apply.rs | 7 ++++++- crates/ide-sdk/src/tests.rs | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index 12dce10c3..99ccd4dcc 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -957,10 +957,15 @@ pub fn resolve_run_command(config: &mut RunConfig, root: &Path, s: &Settings) { } pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root: &Path, s: &Settings) { + let mut path_dirs: Vec = Vec::new(); if let Some(custom) = tool_command(root, s, &cfg.command) { + // An executable chosen from a Node.js installation (`~/.nvm/versions/node/v26/bin/tsc`, + // its `npx`) runs on that Node: its `#!/usr/bin/env node` finds the `node` beside it. + if let Some(bin) = custom.parent().filter(|bin| bin.join(exe("node")).is_file()) { + path_dirs.push(bin.to_path_buf()); + } cfg.command = custom.to_string_lossy().into_owned(); } - let mut path_dirs: Vec = Vec::new(); let name = cfg.name.clone(); let name = name.as_str(); diff --git a/crates/ide-sdk/src/tests.rs b/crates/ide-sdk/src/tests.rs index 6bf610a4c..9fe04058f 100644 --- a/crates/ide-sdk/src/tests.rs +++ b/crates/ide-sdk/src/tests.rs @@ -1325,6 +1325,40 @@ fn custom_tool_path_replaces_the_command() { assert_eq!(cfg.command, "zls"); } +/// A server picked from a Node.js installation (nvm's `bin/tsc`, `bin/npx`) is a +/// `#!/usr/bin/env node` script: it runs on that installation's Node, ahead of the +/// project's Node.js interpreter. +#[test] +fn a_server_chosen_from_a_node_installation_runs_on_that_node() { + let tmp = tempfile::tempdir().unwrap(); + let v26 = tmp.path().join("nvm/versions/node/v26.4.0/bin"); + let v20 = tmp.path().join("node20/bin"); + for dir in [&v26, &v20] { + std::fs::create_dir_all(dir).unwrap(); + std::fs::write(dir.join(exe("node")), "").unwrap(); + } + std::fs::write(v26.join("tsc"), "#!/usr/bin/env node\n").unwrap(); + let mut s = Settings::default(); + s.languages.node.interpreter = v20.join(exe("node")).to_string_lossy().into_owned(); + s.tools.insert( + "typescript-language-server".into(), + ide_core::settings::ToolSettings { path: v26.join("tsc").to_string_lossy().into_owned() }, + ); + let mut cfg = server("typescript-language-server"); + configure_server(&mut cfg, tmp.path(), tmp.path(), &s); + let path: Vec = std::env::split_paths(&cfg.env["PATH"]).collect(); + assert_eq!(path[..2], [v26.clone(), v20.clone()]); + + // Elsewhere, the project's Node.js interpreter comes first. + s.tools.insert( + "typescript-language-server".into(), + ide_core::settings::ToolSettings { path: tmp.path().join("tools/tsc").to_string_lossy().into_owned() }, + ); + let mut cfg = server("typescript-language-server"); + configure_server(&mut cfg, tmp.path(), tmp.path(), &s); + assert_eq!(std::env::split_paths(&cfg.env["PATH"]).next(), Some(v20)); +} + #[test] fn the_godot_setting_applies_to_server_debugger_and_run_configurations() { let tmp = tempfile::tempdir().unwrap();