From 6dfd82de4ca6c00ba16d9808a067bfb16bec7225 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:21:42 +0800 Subject: [PATCH] fix(vue): Run Vue Language Server 3 in hybrid mode with a usable TypeScript (#46) Root cause: the tools dir installed typescript@latest, now TypeScript 7 (native port, no typescript.js/tsserver.js/ts.server), so @vue/language-server 3 crashed on ts.server.protocol and restarted forever; Volar 3 hybrid mode also needs tsserver/request forwarding, which we never answered. Fix: pin TS-as-library servers to typescript@<7 and keep shared deps on uninstall; pick a usable tsdk (setting / bundled / project, --tsdk); bridge tsserver/request to vtsls with @vue/typescript-plugin (null when vtsls is absent); answer workspace/configuration for section ""; stop restarting after 3 consecutive early crashes (IDEA); crash summary starts at the exception line. Siblings covered: typescript-language-server and svelte-language-server (same TS 7 break), vtsls config, install hints in language TOMLs. Verified: scoped cargo tests, real-server tests (vue-language-server 3.3.13 + vtsls), in-app on macOS (server running, no crash loop). Not verified on Windows. Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn --- crates/ide-lsp/src/handler.rs | 4 +- crates/ide-lsp/src/lib.rs | 1 + crates/ide-lsp/src/server.rs | 127 +++++++++++++++++++++++--- crates/ide-lsp/src/tsserver_bridge.rs | 93 +++++++++++++++++++ crates/ide-lsp/tests/real_servers.rs | 72 +++++++++++++++ crates/ide-sdk/src/apply.rs | 34 ++++++- crates/ide-sdk/src/lib.rs | 1 + crates/ide-sdk/src/tests.rs | 52 +++++++++++ crates/ide-sdk/src/vue.rs | 121 ++++++++++++++++++++++++ crates/ide-tools/src/lib.rs | 34 ++++++- crates/ide-tools/src/registry.rs | 10 +- crates/ide-tools/src/tests.rs | 48 ++++++++++ languages/javascript.toml | 2 +- languages/javascriptreact.toml | 2 +- languages/typescript.toml | 2 +- languages/typescriptreact.toml | 2 +- languages/vue.toml | 12 ++- 17 files changed, 590 insertions(+), 27 deletions(-) create mode 100644 crates/ide-lsp/src/tsserver_bridge.rs create mode 100644 crates/ide-sdk/src/vue.rs diff --git a/crates/ide-lsp/src/handler.rs b/crates/ide-lsp/src/handler.rs index 7dbf5a90..3e79608d 100644 --- a/crates/ide-lsp/src/handler.rs +++ b/crates/ide-lsp/src/handler.rs @@ -128,7 +128,8 @@ impl ClientHandler { Value::Array( items .iter() - .map(|item| match item.get("section").and_then(Value::as_str) { + // vtsls asks for the whole configuration as section `""`. + .map(|item| match item.get("section").and_then(Value::as_str).filter(|s| !s.is_empty()) { Some(section) if section == self.server.name => options.clone(), // Servers that take settings under `initializationOptions.settings` // (jdtls) ask for the same sections later. @@ -193,6 +194,7 @@ impl Handler for ClientHandler { _ => tracing::debug!("[{}] {text}", self.server.name), } } + "tsserver/request" => mgr.forward_tsserver_requests(self.server.clone(), self.generation, params), "telemetry/event" | "$/logTrace" | "$/cancelRequest" => {} other => tracing::trace!("[{}] unhandled notification {other}", self.server.name), } diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index ee171c36..bb46369d 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -15,6 +15,7 @@ pub mod jdtls; mod launch; mod server; mod text; +mod tsserver_bridge; pub mod types; mod uri; mod virtual_doc; diff --git a/crates/ide-lsp/src/server.rs b/crates/ide-lsp/src/server.rs index 4568ca44..43d5ba0b 100644 --- a/crates/ide-lsp/src/server.rs +++ b/crates/ide-lsp/src/server.rs @@ -13,7 +13,8 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::sync::{Notify, watch}; -const CRASH_WINDOW: Duration = Duration::from_secs(180); +/// An instance that stayed up this long ran fine: earlier crashes no longer count. +const STABLE_UPTIME: Duration = Duration::from_secs(180); const MAX_RESTARTS: usize = 3; const STDERR_LINES: usize = 50; @@ -66,7 +67,10 @@ pub struct Server { state: Mutex, phase: watch::Sender, status: Mutex<(ServerStatus, Option)>, - crashes: Mutex>, + /// Consecutive crashes of short-lived instances. + crashes: Mutex, + /// When the current instance was launched. + started: Mutex>, stderr: Mutex>, pub progress: Mutex>, diagnostics: Mutex>>, @@ -88,7 +92,8 @@ impl Server { state: Mutex::new(State { generation: 0, conn: Conn::Idle }), phase: watch::channel(Phase::Down).0, status: Mutex::new((ServerStatus::Stopped, None)), - crashes: Mutex::new(VecDeque::new()), + crashes: Mutex::new(0), + started: Mutex::new(None), stderr: Mutex::new(VecDeque::new()), progress: Mutex::new(HashMap::new()), diagnostics: Mutex::new(HashMap::new()), @@ -127,6 +132,7 @@ impl Server { } st.generation += 1; st.conn = Conn::Starting; + *self.started.lock() = Some(Instant::now()); self.phase.send_replace(Phase::Starting); Some(st.generation) } @@ -237,21 +243,22 @@ impl Server { } } - /// Records a crash; returns the backoff before restarting, or `None` when - /// the restart budget for the window is exhausted. + /// Records a crash; returns the backoff before restarting, or `None` once + /// [`MAX_RESTARTS`] instances in a row died before running [`STABLE_UPTIME`] + /// (a server that crashes on every start, however slowly, is not restarted forever). pub fn record_crash(&self) -> Option { + let uptime = self.started.lock().map(|t| t.elapsed()).unwrap_or_default(); let mut crashes = self.crashes.lock(); - let now = Instant::now(); - crashes.push_back(now); - while crashes.front().is_some_and(|t| now.duration_since(*t) > CRASH_WINDOW) { - crashes.pop_front(); + if uptime >= STABLE_UPTIME { + *crashes = 0; } - let n = crashes.len(); + *crashes += 1; + let n = *crashes; (n <= MAX_RESTARTS).then(|| Duration::from_millis(500) * (1 << (n - 1)) as u32) } pub fn reset_for_restart(&self) { - self.crashes.lock().clear(); + *self.crashes.lock() = 0; let mut st = self.state.lock(); if matches!(st.conn, Conn::Missing | Conn::Failed) { st.conn = Conn::Idle; @@ -300,8 +307,8 @@ impl Server { pub fn stderr_tail(&self, n: usize) -> String { let buf = self.stderr.lock(); - let skip = buf.len().saturating_sub(n); - buf.iter().skip(skip).cloned().collect::>().join("\n") + let lines: Vec<&str> = buf.iter().map(String::as_str).collect(); + crash_excerpt(&lines, n) } pub fn take_progress(&self) -> Vec<(TaskId, String)> { @@ -341,3 +348,97 @@ impl Server { self.diagnostics.lock().drain().map(|(p, _)| p).collect() } } + +/// The last `n` stderr lines, led by the exception headline: Node.js / Python / +/// JVM crashes print the error first and a stack after it, so a plain tail +/// shows frames without saying what went wrong. +pub(crate) fn crash_excerpt(lines: &[&str], n: usize) -> String { + const MAX_LINES: usize = 12; + let start = lines.len().saturating_sub(n); + let excerpt: Vec<&str> = match lines.iter().rposition(|l| is_error_headline(l)) { + Some(i) if i >= start => lines[start..].to_vec(), + Some(i) if lines.len() - i <= MAX_LINES => lines[i..].to_vec(), + Some(i) => [lines[i], "…"].into_iter().chain(lines[start..].iter().copied()).collect(), + None => lines[start..].to_vec(), + }; + excerpt.join("\n") +} + +fn is_error_headline(line: &str) -> bool { + let l = line.trim_start(); + let head = l.split(':').next().unwrap_or_default(); + let named = (head.ends_with("Error") || head.ends_with("Exception")) && !head.contains(' ') && head.len() < l.len(); + named || l.starts_with("Exception in thread") || l.starts_with("thread '") && l.contains("panicked") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn server() -> Server { + let cfg: LanguageServerConfig = toml::from_str("name = \"s\"\ncommand = \"s\"").unwrap(); + Server::new(cfg, PathBuf::from("/p"), LanguageId::new("vue")) + } + + #[test] + fn crashes_of_short_lived_instances_exhaust_the_budget_whatever_the_spacing() { + let s = server(); + for _ in 0..MAX_RESTARTS { + // Even crashes spread minutes apart count while each instance dies young. + *s.started.lock() = Some(Instant::now()); + assert!(s.record_crash().is_some()); + } + *s.started.lock() = Some(Instant::now()); + assert_eq!(s.record_crash(), None); + s.reset_for_restart(); + assert!(s.record_crash().is_some()); + } + + #[test] + fn a_stable_run_resets_the_budget() { + let s = server(); + for _ in 0..MAX_RESTARTS { + assert!(s.record_crash().is_some()); + } + *s.started.lock() = Instant::now().checked_sub(STABLE_UPTIME); + assert_eq!(s.record_crash(), Some(Duration::from_millis(500))); + } + + #[test] + fn crash_excerpt_keeps_the_exception_line() { + // Node.js 24 crash of vue-language-server (issue #25), verbatim shape. + let node = [ + "C:\\tools\\node_modules\\@vue\\language-server\\lib\\server.js:40", + " projectInfoPromise = sendTsServerRequest('_vue:' + ts.server.protocol.CommandTypes.ProjectInfo, {", + " ^", + "", + "TypeError: Cannot read properties of undefined (reading 'protocol')", + " at Object.getLanguageService (C:\\tools\\node_modules\\@vue\\language-server\\lib\\server.js:40:86)", + " at Immediate. (C:\\tools\\node_modules\\@volar\\language-server\\lib\\features\\languageFeatures.js:744:62)", + " at process.processImmediate (node:internal/timers:504:21)", + "", + "Node.js v24.12.0", + ]; + let out = crash_excerpt(&node, 5); + assert!(out.starts_with("TypeError: Cannot read properties of undefined (reading 'protocol')"), "{out}"); + assert!(out.ends_with("Node.js v24.12.0")); + + let python = [ + "Traceback (most recent call last):", + " File \"x.py\", line 1", + "ModuleNotFoundError: No module named 'jedi'", + ]; + assert_eq!(crash_excerpt(&python, 5), python.join("\n")); + + let mut long_stack = vec!["Exception in thread \"main\" java.lang.IllegalStateException: boom"]; + long_stack.extend(std::iter::repeat_n("\tat a.b.C.d(C.java:1)", 30)); + let out = crash_excerpt(&long_stack, 5); + assert!(out.starts_with("Exception in thread \"main\""), "{out}"); + assert_eq!(out.lines().count(), 7); + + let plain = ["starting", "listening", "bye"]; + assert_eq!(crash_excerpt(&plain, 2), "listening\nbye"); + // "Error" in prose is not a headline. + assert_eq!(crash_excerpt(&["No Error here: fine", "a", "b"], 2), "a\nb"); + } +} diff --git a/crates/ide-lsp/src/tsserver_bridge.rs b/crates/ide-lsp/src/tsserver_bridge.rs new file mode 100644 index 00000000..5997fb36 --- /dev/null +++ b/crates/ide-lsp/src/tsserver_bridge.rs @@ -0,0 +1,93 @@ +//! Vue Language Server (Volar 3) runs in "hybrid mode": TypeScript work goes to +//! tsserver with `@vue/typescript-plugin`, through the client. It sends +//! `tsserver/request` `[id, command, args]` and waits for `tsserver/response` +//! `[id, body]`; the client forwards the command to vtsls, whose tsserver has the +//! plugin loaded (`typescript.tsserverRequest`). Without an answer the server +//! waits forever, so every request is answered — `null` when vtsls can't. + +use crate::LspManager; +use crate::server::Server; +use serde_json::{Value, json}; +use std::sync::Arc; +use std::time::Duration; + +/// The server that hosts tsserver for the bridge. +pub(crate) const TSSERVER_HOST: &str = "vtsls"; +const HOST_START_WAIT: Duration = Duration::from_secs(60); +const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + +/// `[id, command, args]` items of a `tsserver/request` notification +/// (vscode-jsonrpc wraps the single array argument: `[[id, command, args]]`). +pub(crate) fn parse_requests(params: &Value) -> Vec<(Value, String, Value)> { + let one = |v: &Value| { + let a = v.as_array()?; + let command = a.get(1)?.as_str()?.to_owned(); + Some((a.first()?.clone(), command, a.get(2).cloned().unwrap_or(Value::Null))) + }; + match params.as_array() { + Some(items) if items.first().is_some_and(Value::is_array) => items.iter().filter_map(one).collect(), + Some(_) => one(params).into_iter().collect(), + None => Vec::new(), + } +} + +impl LspManager { + /// Answers `tsserver/request`s of `from` (instance `generation`) via [`TSSERVER_HOST`]. + pub(crate) fn forward_tsserver_requests(&self, from: Arc, generation: u64, params: Value) { + let Some(me) = self.me.upgrade() else { return }; + for (id, command, args) in parse_requests(¶ms) { + let me = me.clone(); + let from = from.clone(); + self.runtime.spawn(async move { + let body = me.tsserver_request(&from, &command, args).await.unwrap_or(Value::Null); + if let Some(run) = from.running_gen(generation) + && let Err(e) = run.peer.notify("tsserver/response", json!([[id, body]])) + { + tracing::debug!("{}: tsserver/response: {e}", from.name); + } + }); + } + } + + async fn tsserver_request(&self, from: &Server, command: &str, args: Value) -> Option { + let host = self + .servers + .lock() + .iter() + .filter(|s| s.name == TSSERVER_HOST) + .min_by_key(|s| s.root != from.root) + .cloned()?; + let run = host.wait_ready(HOST_START_WAIT).await?; + let params = json!({ + "command": "typescript.tsserverRequest", + "arguments": [command, args, { "isAsync": true, "lowPriority": true }], + }); + let request = run.peer.request::<_, Value>("workspace/executeCommand", params, None); + match tokio::time::timeout(REQUEST_TIMEOUT, request).await { + Ok(Ok(result)) => result.get("body").cloned(), + Ok(Err(e)) => { + tracing::debug!("{TSSERVER_HOST}: {command}: {e}"); + None + } + Err(_) => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_wrapped_and_bare_requests() { + let wrapped = json!([[1, "_vue:projectInfo", { "file": "/p/App.vue" }]]); + assert_eq!( + parse_requests(&wrapped), + vec![(json!(1), "_vue:projectInfo".to_owned(), json!({ "file": "/p/App.vue" }))] + ); + let bare = json!([2, "_vue:getComponentNames", ["/p/App.vue"]]); + assert_eq!(parse_requests(&bare), vec![(json!(2), "_vue:getComponentNames".to_owned(), json!(["/p/App.vue"]))]); + assert!(parse_requests(&json!({ "id": 1 })).is_empty()); + assert!(parse_requests(&json!([[3]])).is_empty()); + } +} diff --git a/crates/ide-lsp/tests/real_servers.rs b/crates/ide-lsp/tests/real_servers.rs index 42f23597..5c42f7fa 100644 --- a/crates/ide-lsp/tests/real_servers.rs +++ b/crates/ide-lsp/tests/real_servers.rs @@ -227,3 +227,75 @@ async fn typescript_language_server_end_to_end() { .await; tokio::time::timeout(Duration::from_secs(10), mgr.shutdown_all()).await.unwrap(); } + +const APP_VUE: &str = "\n\n\n"; + +fn npm_server(tools: &Path, name: &str, bin: &str, init: Option) -> LanguageServerConfig { + LanguageServerConfig { + name: name.into(), + command: tools.join("node_modules/.bin").join(bin).to_string_lossy().into_owned(), + args: vec!["--stdio".into()], + env: Default::default(), + root_markers: vec!["package.json".into()], + initialization_options: init.map(|v| toml::Value::try_from(v).unwrap()), + install_hint: None, + fallback: false, + launch_error: None, + } +} + +/// Vue Language Server 3 in hybrid mode: its `tsserver/request`s go through the +/// client to vtsls with `@vue/typescript-plugin` (issue #25). Set +/// `OPENIDEA_TEST_NPM_TOOLS` to a dir with `npm i @vue/language-server typescript@"<7" @vtsls/language-server`. +#[tokio::test(flavor = "multi_thread")] +#[ignore = "requires @vue/language-server and @vtsls/language-server (OPENIDEA_TEST_NPM_TOOLS)"] +async fn vue_hybrid_mode_end_to_end() { + let tools = std::path::PathBuf::from(std::env::var("OPENIDEA_TEST_NPM_TOOLS").expect("OPENIDEA_TEST_NPM_TOOLS")); + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().canonicalize().unwrap(); + std::fs::write(root.join("package.json"), "{\"name\":\"app\",\"private\":true}").unwrap(); + std::fs::write(root.join("tsconfig.json"), "{\"compilerOptions\":{\"strict\":true},\"include\":[\"src/**/*\"]}") + .unwrap(); + std::fs::create_dir_all(root.join("src")).unwrap(); + let file = root.join("src/App.vue"); + std::fs::write(&file, APP_VUE).unwrap(); + + let plugin = serde_json::json!({ "vtsls": { "tsserver": { "globalPlugins": [{ + "name": "@vue/typescript-plugin", + "location": tools.join("node_modules/@vue/language-server").to_string_lossy(), + "languages": ["vue"], + "configNamespace": "typescript", + "enableForWorkspaceTypeScriptVersions": true, + }] } } }); + for with_vtsls in [false, true] { + let mut vue = lang("vue", "vue", npm_server(&tools, "vue-language-server", "vue-language-server", None)); + if with_vtsls { + vue.language_servers.push(npm_server(&tools, "vtsls", "vtsls", Some(plugin.clone()))); + } + let (mgr, events) = manager(&root, LanguageRegistry::from_configs(vec![vue])); + let doc = DocId::next(); + mgr.did_open(doc, &file, &LanguageId::new("vue"), 1, APP_VUE); + // `count` in `{{ count }}`. + let pos = Pos::new(5, 12); + if with_vtsls { + let hover = retry("hover", Duration::from_secs(90), || { + let mgr = mgr.clone(); + async move { mgr.hover(doc, pos, Default::default()).await.ok().flatten() } + }) + .await; + assert!(hover.contents.value.contains("count: number"), "{}", hover.contents.value); + } + retry("vue-language-server ready", Duration::from_secs(30), || { + let s = last_status(&events, "vue-language-server"); + async move { matches!(s, Some((ServerStatus::Ready | ServerStatus::Busy, _))).then_some(()) } + }) + .await; + // Every request of vue-language-server waits for a tsserver answer (the + // project info); without vtsls it is `null` and the server falls back. + let symbols = tokio::time::timeout(Duration::from_secs(20), mgr.document_symbols(doc)).await; + assert!(matches!(symbols, Ok(Ok(_))), "vue-language-server did not answer: {symbols:?}"); + let crashed = matches!(last_status(&events, "vue-language-server"), Some((ServerStatus::Crashed, _))); + assert!(!crashed, "{:?}", last_status(&events, "vue-language-server")); + tokio::time::timeout(Duration::from_secs(10), mgr.shutdown_all()).await.unwrap(); + } +} diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index 490defe0..93e0ce64 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -2,7 +2,8 @@ //! language servers, run configurations and terminals. use crate::discover::{project_venv_python, workspace_tsdk}; -use crate::exec::path_with; +use crate::exec::{path_with, search_path}; +use crate::vue; use crate::{bin_dir, exe, venv_of}; use ide_core::settings::Settings; use ide_language::LanguageServerConfig; @@ -258,6 +259,17 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root _ => {} } } + let vue_command = s.tool_path(vue::SERVER).and_then(|p| setting_path(root, p)); + let vue_command = vue_command.as_deref().map_or(vue::SERVER.into(), Path::to_string_lossy); + if name == "vtsls" + && let Some(vue) = vue::server_package(&vue_command, &search_path()) + { + let plugin = vue::tsserver_plugin(&vue); + merge_init_options(cfg, json!({ "vtsls": { "tsserver": { "globalPlugins": [plugin] } } })); + } + if name == vue::SERVER { + configure_vue_typescript(cfg, root, server_root, s); + } // Node-based servers (`#!/usr/bin/env node`) run on the selected Node.js. if let Some(dir) = node_dir(root, s) { @@ -270,6 +282,26 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root } } +/// vue-language-server loads TypeScript as a library (`--tsdk`, else its own +/// `require('typescript')`): the selected version, else its own, else the +/// project's — whichever still has the language-service API. +fn configure_vue_typescript(cfg: &mut LanguageServerConfig, root: &Path, server_root: &Path, s: &Settings) { + let selected = tsdk(server_root, s).or_else(|| tsdk(root, s)).filter(|l| vue::has_ts_api(l)); + let package = vue::server_package(&cfg.command, &search_path()); + let own = package.as_deref().and_then(vue::own_typescript); + let workspace = || workspace_tsdk(server_root).or_else(|| workspace_tsdk(root)).filter(|l| vue::has_ts_api(l)); + let lib = match selected { + Some(lib) => Some(lib), + None if own.is_some() => None, + None => workspace(), + }; + match lib { + Some(lib) => cfg.args.push(format!("--tsdk={}", lib.to_string_lossy())), + None if own.is_none() && package.is_some() => cfg.launch_error = Some(vue::missing_typescript_message()), + None => {} + } +} + fn is_python_command(cmd: &str) -> bool { let name = Path::new(cmd).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); let name = name.strip_suffix(".exe").unwrap_or(&name); diff --git a/crates/ide-sdk/src/lib.rs b/crates/ide-sdk/src/lib.rs index 1068f19e..95651dd4 100644 --- a/crates/ide-sdk/src/lib.rs +++ b/crates/ide-sdk/src/lib.rs @@ -13,6 +13,7 @@ pub mod libraries; pub mod locate; pub mod lombok; pub mod python; +mod vue; use serde::{Deserialize, Serialize}; use specta::Type; diff --git a/crates/ide-sdk/src/tests.rs b/crates/ide-sdk/src/tests.rs index 86088a93..5bcc70e4 100644 --- a/crates/ide-sdk/src/tests.rs +++ b/crates/ide-sdk/src/tests.rs @@ -461,3 +461,55 @@ fn no_ecosystem_libraries_without_manifests() { std::fs::create_dir_all(tmp.path().join("node_modules/react")).unwrap(); assert!(crate::libraries::project_libraries(tmp.path(), &Settings::default(), &[]).is_empty()); } + +/// Tools dir with vue-language-server and the given TypeScript files, plus a +/// project; returns (tools, project). +fn vue_setup(tmp: &Path, tools_ts: &str, project_ts: &str) -> (std::path::PathBuf, std::path::PathBuf) { + let tools = tmp.join("tools/node_modules"); + let project = tmp.join("app"); + for (p, content) in [ + (tools.join(".bin/vue-language-server"), ""), + (tools.join("@vue/language-server/package.json"), "{}"), + (tools.join("typescript/lib").join(tools_ts), ""), + (project.join("node_modules/typescript/lib").join(project_ts), ""), + (project.join("node_modules/typescript/lib/tsserver.js"), ""), + ] { + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(p, content).unwrap(); + } + (tools, project) +} + +#[test] +fn vue_language_server_gets_a_typescript_with_the_language_service_api() { + // Issue #25: `typescript@latest` (7.x) in the tools dir has no `ts.server`. + let s = Settings::default(); + let cases = [ + // (tools TypeScript, project TypeScript, expected --tsdk = project lib, launch error) + ("typescript.js", "typescript.js", false, false), // own TypeScript 5/6 works + ("tsc.js", "typescript.js", true, false), // own TS 7: the project's + ("tsc.js", "tsc.js", false, true), // nothing usable: clear error + ]; + for (tools_ts, project_ts, uses_project, error) in cases { + let tmp = tempfile::tempdir().unwrap(); + let (tools, project) = vue_setup(tmp.path(), tools_ts, project_ts); + let mut cfg = server("vue-language-server"); + cfg.command = tools.join(".bin/vue-language-server").to_string_lossy().into_owned(); + configure_server(&mut cfg, &project, &project, &s); + let tsdk = cfg.args.iter().find_map(|a| a.strip_prefix("--tsdk=")).map(std::path::PathBuf::from); + let project_lib = project.join("node_modules/typescript/lib"); + assert_eq!(tsdk, uses_project.then_some(project_lib), "{tools_ts} / {project_ts}"); + assert_eq!(cfg.launch_error.is_some(), error, "{tools_ts} / {project_ts}"); + } + + // A selected version wins over the server's own. + let tmp = tempfile::tempdir().unwrap(); + let (tools, project) = vue_setup(tmp.path(), "typescript.js", "typescript.js"); + let mut s = Settings::default(); + s.languages.typescript.version = "workspace".into(); + let mut cfg = server("vue-language-server"); + cfg.command = tools.join(".bin/vue-language-server").to_string_lossy().into_owned(); + configure_server(&mut cfg, &project, &project, &s); + let lib = project.join("node_modules/typescript/lib"); + assert_eq!(cfg.args, [format!("--tsdk={}", lib.to_string_lossy())]); +} diff --git a/crates/ide-sdk/src/vue.rs b/crates/ide-sdk/src/vue.rs new file mode 100644 index 00000000..6809485b --- /dev/null +++ b/crates/ide-sdk/src/vue.rs @@ -0,0 +1,121 @@ +//! Vue Language Server (Volar 3): the TypeScript it loads as a library, and the +//! `@vue/typescript-plugin` that vtsls' tsserver hosts for it (hybrid mode). + +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; + +pub(crate) const SERVER: &str = "vue-language-server"; +const PACKAGE: &str = "@vue/language-server"; +const PLUGIN: &str = "@vue/typescript-plugin"; + +/// The TypeScript `lib` directory has the language-service API (`typescript.js`). +/// TypeScript 7, the native port, ships only `tsc`. +pub(crate) fn has_ts_api(lib: &Path) -> bool { + lib.join("typescript.js").is_file() +} + +/// `command` as given (a path) or found on `path`, npm shims included. +fn find_command(command: &str, path: &OsStr) -> Option { + let p = Path::new(command); + if p.is_absolute() { + return p.is_file().then(|| p.to_path_buf()); + } + let exts: &[&str] = if cfg!(windows) { &[".cmd", ".exe", ""] } else { &[""] }; + std::env::split_paths(path) + .flat_map(|d| exts.iter().map(move |e| d.join(format!("{command}{e}")))) + .find(|c| c.is_file()) +} + +/// The npm package an executable comes from: next to a `node_modules/.bin` shim +/// (the IDE's tools dir, a project), a Windows global shim (`%APPDATA%\npm`), a +/// Unix global `/bin` or a symlink into the package. +fn package_dir(bin: &Path, package: &str) -> Option { + let dir = bin.parent()?; + let mut candidates = vec![dir.join("node_modules").join(package)]; + if dir.file_name() == Some(OsStr::new(".bin")) + && let Some(node_modules) = dir.parent() + { + candidates.push(node_modules.join(package)); + } + if let Ok(real) = dunce::canonicalize(bin) { + candidates.extend(real.ancestors().filter(|a| a.ends_with(package)).map(Path::to_path_buf)); + } + if let Some(prefix) = dir.parent() { + candidates.push(prefix.join("lib").join("node_modules").join(package)); + } + candidates.into_iter().find(|c| c.join("package.json").is_file()) +} + +/// `@vue/language-server`'s directory for the `vue-language-server` command. +pub(crate) fn server_package(command: &str, path: &OsStr) -> Option { + package_dir(&find_command(command, path)?, PACKAGE) +} + +/// The TypeScript `require('typescript')` resolves to from `package`, when usable. +pub(crate) fn own_typescript(package: &Path) -> Option { + package.ancestors().map(|a| a.join("node_modules").join("typescript").join("lib")).find(|l| has_ts_api(l)) +} + +/// vtsls `globalPlugins` entry loading the Vue plugin shipped with `package`. +pub(crate) fn tsserver_plugin(package: &Path) -> serde_json::Value { + serde_json::json!({ + "name": PLUGIN, + "location": package.to_string_lossy(), + "languages": ["vue"], + "configNamespace": "typescript", + "enableForWorkspaceTypeScriptVersions": true, + }) +} + +pub(crate) fn missing_typescript_message() -> String { + "Vue Language Server needs TypeScript 5 or 6 (TypeScript 7 has no language service API). \ + Reinstall it in Settings › Languages & Frameworks › Language Servers, add typescript@\"<7\" \ + to the project, or pick a TypeScript version in Settings › Languages & Frameworks › TypeScript." + .to_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn touch(p: &Path) { + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(p, "{}").unwrap(); + } + + #[test] + fn finds_the_package_of_every_npm_install_layout() { + let tmp = tempfile::tempdir().unwrap(); + let t = tmp.path(); + // (executable, package.json) per layout. + let layouts = [ + // The IDE's tools dir / a project: node_modules/.bin shim. + ("tools/node_modules/.bin/vue-language-server", "tools/node_modules/@vue/language-server/package.json"), + // Windows global: %APPDATA%\npm\vue-language-server(.cmd) + %APPDATA%\npm\node_modules. + ("npm/vue-language-server", "npm/node_modules/@vue/language-server/package.json"), + // Unix global prefix: /bin + /lib/node_modules. + ("prefix/bin/vue-language-server", "prefix/lib/node_modules/@vue/language-server/package.json"), + ]; + for (bin, pkg) in layouts { + touch(&t.join(bin)); + touch(&t.join(pkg)); + let found = server_package(&t.join(bin).to_string_lossy(), OsStr::new("")); + assert_eq!(found, Some(t.join(pkg).parent().unwrap().to_path_buf()), "{bin}"); + } + let path = std::env::join_paths([t.join("tools/node_modules/.bin")]).unwrap(); + assert_eq!(server_package(SERVER, &path), Some(t.join("tools/node_modules/@vue/language-server"))); + assert_eq!(server_package("not-installed", &path), None); + } + + #[test] + fn own_typescript_skips_typescript_7() { + let tmp = tempfile::tempdir().unwrap(); + let pkg = tmp.path().join("node_modules/@vue/language-server"); + touch(&pkg.join("package.json")); + // TypeScript 7 ships only `tsc`. + touch(&tmp.path().join("node_modules/typescript/lib/tsc.js")); + assert_eq!(own_typescript(&pkg), None); + touch(&tmp.path().join("node_modules/typescript/lib/typescript.js")); + assert_eq!(own_typescript(&pkg), Some(tmp.path().join("node_modules/typescript/lib"))); + } +} diff --git a/crates/ide-tools/src/lib.rs b/crates/ide-tools/src/lib.rs index 1b1ec4a5..f11db8bf 100644 --- a/crates/ide-tools/src/lib.rs +++ b/crates/ide-tools/src/lib.rs @@ -90,6 +90,14 @@ struct Manifest { const MANIFEST: &str = ".openidea-tool.json"; +/// `typescript@<7` → `typescript`, `@vue/language-server` unchanged. +fn npm_name(spec: &str) -> &str { + match spec[1..].find('@') { + Some(i) => &spec[..i + 1], + None => spec, + } +} + pub fn tools_dir(app_data: &Path) -> PathBuf { app_data.join("tools") } @@ -349,6 +357,17 @@ impl Tools { json.get("version")?.as_str().map(String::from) } + /// Whether an installed npm tool other than `tool` also installs `package`. + fn npm_dependency_in_use(&self, tool: &str, package: &str) -> bool { + TOOLS.iter().any(|t| match t.method { + Method::Npm { packages } if t.name != tool => { + packages.iter().skip(1).any(|p| npm_name(p) == package) + && self.npm_package_version(npm_name(packages[0])).is_some() + } + _ => false, + }) + } + fn query_version(&self, spec: &ToolSpec, found: &Path) -> Option { let (cmd, args) = spec.version?; let sibling = found.parent().and_then(|d| self.exe_in(d, cmd)); @@ -518,7 +537,7 @@ impl Tools { let managed = managed_bin_dir(&self.root, spec, self.platform).and_then(|d| self.exe_in(&d, main)); if let Some(path) = managed { let version = match spec.method { - Method::Npm { packages } => self.npm_package_version(packages[0]), + Method::Npm { packages } => self.npm_package_version(npm_name(packages[0])), _ => self.manifest(spec.name).and_then(|m| m.version), } .or_else(|| self.query_version(spec, &path)); @@ -644,7 +663,7 @@ impl Tools { let (manager, pnpm) = self.npm_manager()?; self.ensure_package_json()?; let mut c = self.command(manager); - let specs = packages.iter().map(|p| format!("{p}@latest")); + let specs = packages.iter().map(|p| if npm_name(p) == *p { format!("{p}@latest") } else { p.to_string() }); if pnpm { c.arg("add").arg("--dir").arg(&self.root).args(specs); } else { @@ -1038,12 +1057,19 @@ impl Tools { } Method::Npm { packages } => { if self.root.join("node_modules").exists() { + // Shared dependencies (`typescript`) stay while another installed tool needs them. + let packages: Vec<&str> = packages + .iter() + .enumerate() + .filter(|(i, p)| *i == 0 || !self.npm_dependency_in_use(spec.name, npm_name(p))) + .map(|(_, p)| npm_name(p)) + .collect(); let (manager, pnpm) = self.npm_manager()?; let mut c = self.command(manager); if pnpm { - c.arg("remove").arg("--dir").arg(&self.root).args(packages); + c.arg("remove").arg("--dir").arg(&self.root).args(&packages); } else { - c.args(["uninstall", "--no-fund", "--no-audit", "--prefix"]).arg(&self.root).args(packages); + c.args(["uninstall", "--no-fund", "--no-audit", "--prefix"]).arg(&self.root).args(&packages); } c.current_dir(&self.root); run_streamed(c, &cancel, &quiet)?; diff --git a/crates/ide-tools/src/registry.rs b/crates/ide-tools/src/registry.rs index bd25e806..7ca612b8 100644 --- a/crates/ide-tools/src/registry.rs +++ b/crates/ide-tools/src/registry.rs @@ -112,6 +112,10 @@ const fn ver(cmd: &'static str) -> Option<(&'static str, &'static [&'static str] Some((cmd, &["--version"])) } +/// The TypeScript these npm servers load as a library (`tsserver.js`, `ts.server`): +/// TypeScript 7 (the native port) ships neither, and `@latest` resolves to it. +pub const TYPESCRIPT: &str = "typescript@<7"; + pub static TOOLS: &[ToolSpec] = &[ ToolSpec { name: "rust-analyzer", @@ -138,7 +142,7 @@ pub static TOOLS: &[ToolSpec] = &[ display: "typescript-language-server", kind: LanguageServer, commands: &["typescript-language-server"], - method: Method::Npm { packages: &["typescript-language-server", "typescript"] }, + method: Method::Npm { packages: &["typescript-language-server", TYPESCRIPT] }, version: ver("typescript-language-server"), homepage: "https://github.com/typescript-language-server/typescript-language-server", needs: &[], @@ -228,7 +232,7 @@ pub static TOOLS: &[ToolSpec] = &[ display: "Svelte Language Server", kind: LanguageServer, commands: &["svelteserver"], - method: Method::Npm { packages: &["svelte-language-server"] }, + method: Method::Npm { packages: &["svelte-language-server", TYPESCRIPT] }, version: V, homepage: "https://github.com/sveltejs/language-tools", needs: &[], @@ -238,7 +242,7 @@ pub static TOOLS: &[ToolSpec] = &[ display: "Vue Language Server", kind: LanguageServer, commands: &["vue-language-server"], - method: Method::Npm { packages: &["@vue/language-server"] }, + method: Method::Npm { packages: &["@vue/language-server", TYPESCRIPT] }, version: V, homepage: "https://github.com/vuejs/language-tools", needs: &[], diff --git a/crates/ide-tools/src/tests.rs b/crates/ide-tools/src/tests.rs index 2edf2507..b6b9b8a6 100644 --- a/crates/ide-tools/src/tests.rs +++ b/crates/ide-tools/src/tests.rs @@ -272,6 +272,54 @@ echo "added 1 package""#, assert!(env.root.join("package.json").exists()); } +/// Servers that load TypeScript as a library get one with its JS API: `@latest` +/// is TypeScript 7 (native, no `ts.server`), which crashed vue-language-server (#25). +/// Uninstalling one keeps the TypeScript another installed server still uses. +#[cfg(unix)] +#[test] +fn npm_typescript_is_pinned_and_shared() { + let env = Env::new(); + let log = env.root.parent().unwrap().join("npm-args.txt"); + env.script( + &env.sys, + "npm", + &format!( + r#"echo "$@" >> '{log}' +prefix=""; for a in "$@"; do [ "$prev" = "--prefix" ] && prefix="$a"; prev="$a"; done +[ "$1" = "install" ] || exit 0 +/bin/mkdir -p "$prefix/node_modules/.bin" +for a in "$@"; do case "$a" in + typescript-language-server@latest) n=typescript-language-server;; + @vue/language-server@latest) n=vue-language-server; d=@vue/language-server;; + *) continue;; +esac +d="${{d:-$n}}"; /bin/mkdir -p "$prefix/node_modules/$d" +echo '{{"version":"1.0.0"}}' > "$prefix/node_modules/$d/package.json" +printf '#!/bin/sh\necho 1.0.0\n' > "$prefix/node_modules/.bin/$n"; /bin/chmod +x "$prefix/node_modules/.bin/$n"; d="" +done"#, + log = log.display() + ), + ); + let tools = env.tools(); + for name in ["vue-language-server", "typescript-language-server"] { + assert_eq!( + tools.install(name, &langs(), &CancellationToken::new(), &|_| {}).unwrap().status, + ToolStatus::Installed + ); + } + let installs = fs::read_to_string(&log).unwrap(); + for line in installs.lines() { + assert!(line.contains("typescript@<7") && !line.contains("typescript@latest"), "{line}"); + } + assert!(installs.contains("@vue/language-server@latest"), "{installs}"); + + fs::write(&log, "").unwrap(); + tools.uninstall("vue-language-server", &langs()).unwrap(); + let uninstall = fs::read_to_string(&log).unwrap(); + assert!(uninstall.contains("uninstall") && uninstall.contains("@vue/language-server"), "{uninstall}"); + assert!(!uninstall.contains("typescript"), "typescript-language-server still needs it: {uninstall}"); +} + #[test] fn installed_bundle_is_detected_and_resolvable() { let env = Env::new(); diff --git a/languages/javascript.toml b/languages/javascript.toml index 9eca84ee..d16c1ad5 100644 --- a/languages/javascript.toml +++ b/languages/javascript.toml @@ -22,7 +22,7 @@ fallback = true command = "typescript-language-server" args = ["--stdio"] root-markers = ["jsconfig.json", "tsconfig.json", "package.json"] -install-hint = "npm i -g typescript typescript-language-server" +install-hint = "npm i -g typescript@\"<7\" typescript-language-server" [debugger] adapter = "js-debug" diff --git a/languages/javascriptreact.toml b/languages/javascriptreact.toml index ddca8d52..540722ee 100644 --- a/languages/javascriptreact.toml +++ b/languages/javascriptreact.toml @@ -21,7 +21,7 @@ fallback = true command = "typescript-language-server" args = ["--stdio"] root-markers = ["jsconfig.json", "tsconfig.json", "package.json"] -install-hint = "npm i -g typescript typescript-language-server" +install-hint = "npm i -g typescript@\"<7\" typescript-language-server" [debugger] adapter = "js-debug" diff --git a/languages/typescript.toml b/languages/typescript.toml index 60432e16..c8dc5779 100644 --- a/languages/typescript.toml +++ b/languages/typescript.toml @@ -21,7 +21,7 @@ fallback = true command = "typescript-language-server" args = ["--stdio"] root-markers = ["tsconfig.json", "jsconfig.json", "package.json"] -install-hint = "npm i -g typescript typescript-language-server" +install-hint = "npm i -g typescript@\"<7\" typescript-language-server" [debugger] adapter = "js-debug" diff --git a/languages/typescriptreact.toml b/languages/typescriptreact.toml index ab3c27c8..8c5a439d 100644 --- a/languages/typescriptreact.toml +++ b/languages/typescriptreact.toml @@ -21,7 +21,7 @@ fallback = true command = "typescript-language-server" args = ["--stdio"] root-markers = ["tsconfig.json", "jsconfig.json", "package.json"] -install-hint = "npm i -g typescript typescript-language-server" +install-hint = "npm i -g typescript@\"<7\" typescript-language-server" [debugger] adapter = "js-debug" diff --git a/languages/vue.toml b/languages/vue.toml index aab4e5e0..1176b0ec 100644 --- a/languages/vue.toml +++ b/languages/vue.toml @@ -10,4 +10,14 @@ name = "vue-language-server" command = "vue-language-server" args = ["--stdio"] root-markers = ["package.json", "vite.config.ts", "vite.config.js", "nuxt.config.ts", "tsconfig.json"] -install-hint = "npm i -g @vue/language-server (TypeScript support also needs @vue/typescript-plugin in vtsls)" +install-hint = "npm i -g @vue/language-server typescript@\"<7\"" + +# Hybrid mode: TypeScript in `.vue` files comes from tsserver with +# `@vue/typescript-plugin` (configured by the settings hook); vue-language-server +# reaches it through `tsserver/request`, which the client forwards to vtsls. +[[language-servers]] +name = "vtsls" +command = "vtsls" +args = ["--stdio"] +root-markers = ["tsconfig.json", "jsconfig.json", "package.json"] +install-hint = "npm i -g @vtsls/language-server"