mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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:
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(¶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<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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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())]);
|
||||
}
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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: &[],
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user