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
This commit is contained in:
l0ng-ai
2026-10-27 08:21:42 +08:00
committed by GitHub
parent b79522fd03
commit 6dfd82de4c
17 changed files with 590 additions and 27 deletions
+3 -1
View File
@@ -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),
}
+1
View File
@@ -15,6 +15,7 @@ pub mod jdtls;
mod launch;
mod server;
mod text;
mod tsserver_bridge;
pub mod types;
mod uri;
mod virtual_doc;
+114 -13
View File
@@ -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<State>,
phase: watch::Sender<Phase>,
status: Mutex<(ServerStatus, Option<String>)>,
crashes: Mutex<VecDeque<Instant>>,
/// Consecutive crashes of short-lived instances.
crashes: Mutex<usize>,
/// When the current instance was launched.
started: Mutex<Option<Instant>>,
stderr: Mutex<VecDeque<String>>,
pub progress: Mutex<HashMap<String, Progress>>,
diagnostics: Mutex<HashMap<PathBuf, Vec<lsp::Diagnostic>>>,
@@ -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<Duration> {
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::<Vec<_>>().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.<anonymous> (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");
}
}
+93
View File
@@ -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<Server>, generation: u64, params: Value) {
let Some(me) = self.me.upgrade() else { return };
for (id, command, args) in parse_requests(&params) {
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<Value> {
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());
}
}
+72
View File
@@ -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 = "<script setup lang=\"ts\">\nconst count: number = 1\n</script>\n\n<template>\n <div>{{ count }}</div>\n</template>\n";
fn npm_server(tools: &Path, name: &str, bin: &str, init: Option<serde_json::Value>) -> 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();
}
}
+33 -1
View File
@@ -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);
+1
View File
@@ -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;
+52
View File
@@ -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())]);
}
+121
View File
@@ -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<PathBuf> {
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 `<prefix>/bin` or a symlink into the package.
fn package_dir(bin: &Path, package: &str) -> Option<PathBuf> {
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<PathBuf> {
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<PathBuf> {
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: <prefix>/bin + <prefix>/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")));
}
}
+30 -4
View File
@@ -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<String> {
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)?;
+7 -3
View File
@@ -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: &[],
+48
View File
@@ -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();
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
+11 -1
View File
@@ -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"