mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 10:16:03 -10:00
fix(lsp): register .vue files and Vue CLI aliases in config-less tsserver projects (#1771)
In a Vue CLI project without jsconfig.json, tsserver only knew the files that were opened: usage counts said "no usages" for code used from unopened .vue files, and `@/` imports resolved to nothing, so a mixin imported that way made the component's whole `this` `any` and Go to Declaration failed in the same file. The tsserver project registered for config-less trees now also serves Vue (vtsls of vue.toml), includes the files the Vue tsserver plugin handles, and maps the webpack aliases that @vue/cli-service resolves (else Vue CLI's `@` -> `src`). Until vtsls has diagnostics for an open file it answers from its syntax server, so the server reports loading meanwhile and empty usage counts are asked again.
This commit is contained in:
10 files changed
+353
-51
No files matched your search
@@ -93,6 +93,16 @@ pub struct IndentConfig {
|
||||
pub insert_spaces: bool,
|
||||
}
|
||||
|
||||
/// Additions to the tsserver project of a tree without `tsconfig.json` / `jsconfig.json`.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct TsserverProjectExtras {
|
||||
/// File extensions a tsserver plugin handles, registered with the sources (`vue`).
|
||||
pub extensions: Vec<String>,
|
||||
/// `compilerOptions.paths` with absolute targets: the bundler's module aliases
|
||||
/// (`@/*` → `<root>/src/*` in a Vue CLI project).
|
||||
pub paths: BTreeMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct LanguageServerConfig {
|
||||
@@ -155,6 +165,9 @@ pub struct LanguageServerConfig {
|
||||
/// Register source files with tsserver when no project configuration exists.
|
||||
#[serde(default)]
|
||||
pub tsserver_project: bool,
|
||||
/// Set by the settings hook: what that registered project adds to its defaults.
|
||||
#[serde(skip)]
|
||||
pub tsserver_project_extras: TsserverProjectExtras,
|
||||
/// Re-sends the open documents (unchanged) once `workspace/configuration` is answered:
|
||||
/// the server (Intelephense) applies some settings, such as the PHP version, only when
|
||||
/// it parses a document.
|
||||
|
||||
@@ -14,6 +14,8 @@ const SCAN_LIMIT: usize = 100_000;
|
||||
/// tsserver turns its language service off past 20 MiB of JavaScript in a project.
|
||||
const SCRIPT_BYTES_LIMIT: u64 = 16 << 20;
|
||||
const REFRESH_DELAY: Duration = Duration::from_millis(300);
|
||||
/// Bounds the loading phase reported after registering a project.
|
||||
const LOADING_LIMIT: Duration = Duration::from_secs(60);
|
||||
const TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -47,7 +49,13 @@ fn is_source(ext: &str) -> bool {
|
||||
|
||||
/// All-or-nothing at the caps: a partial project could displace an inferred
|
||||
/// project while omitting some of its files. Configured trees stay with tsserver.
|
||||
fn source_files(root: &Path, exclude_root: &Path, excludes: Vec<String>, limit: usize) -> Vec<PathBuf> {
|
||||
fn source_files(
|
||||
root: &Path,
|
||||
exclude_root: &Path,
|
||||
excludes: Vec<String>,
|
||||
extra: &[String],
|
||||
limit: usize,
|
||||
) -> Vec<PathBuf> {
|
||||
if root.ancestors().any(configured) {
|
||||
return Vec::new();
|
||||
}
|
||||
@@ -80,8 +88,9 @@ fn source_files(root: &Path, exclude_root: &Path, excludes: Vec<String>, limit:
|
||||
return Vec::new();
|
||||
}
|
||||
let ext = entry.path().extension().and_then(|s| s.to_str()).unwrap_or_default();
|
||||
if entry.file_type().is_some_and(|t| t.is_file()) && is_source(ext) {
|
||||
if is_script(ext) {
|
||||
let plugin = extra.iter().any(|e| e == ext);
|
||||
if entry.file_type().is_some_and(|t| t.is_file()) && (is_source(ext) || plugin) {
|
||||
if is_script(ext) || plugin {
|
||||
script_bytes += entry.metadata().map_or(0, |m| m.len());
|
||||
if script_bytes > SCRIPT_BYTES_LIMIT {
|
||||
tracing::debug!("external TypeScript project exceeds its script size; retaining inferred projects");
|
||||
@@ -104,16 +113,22 @@ fn project_name(server: &Server) -> PathBuf {
|
||||
}
|
||||
|
||||
async fn request(run: &Running, command: &str, args: Value) -> bool {
|
||||
send(run, command, args, false, TIMEOUT).await
|
||||
}
|
||||
|
||||
/// `is_async`: a command tsserver answers with events, such as `geterr` (vtsls returns once
|
||||
/// they all arrived).
|
||||
async fn send(run: &Running, command: &str, args: Value, is_async: bool, timeout: Duration) -> bool {
|
||||
let request = run.peer.request::<_, Value>(
|
||||
"workspace/executeCommand",
|
||||
json!({
|
||||
"command": COMMAND,
|
||||
"arguments": [command, args, { "isAsync": false, "expectsResult": true }]
|
||||
"arguments": [command, args, { "isAsync": is_async, "expectsResult": true }]
|
||||
}),
|
||||
None,
|
||||
);
|
||||
match tokio::time::timeout(TIMEOUT, request).await {
|
||||
Ok(Ok(result)) if result.get("success").and_then(Value::as_bool) == Some(true) => true,
|
||||
match tokio::time::timeout(timeout, request).await {
|
||||
Ok(Ok(result)) if is_async || result.get("success").and_then(Value::as_bool) == Some(true) => true,
|
||||
result => {
|
||||
tracing::debug!("tsserver {command}: {result:?}");
|
||||
false
|
||||
@@ -126,7 +141,7 @@ impl LspManager {
|
||||
*self.excludes_hook.write() = Some(Arc::from(hook));
|
||||
}
|
||||
|
||||
pub(crate) async fn refresh_external_project(&self, server: &Server, run: &Running) {
|
||||
pub(crate) async fn refresh_external_project(&self, server: &Arc<Server>, run: &Running) {
|
||||
if !supported(server, run) {
|
||||
return;
|
||||
}
|
||||
@@ -135,11 +150,13 @@ impl LspManager {
|
||||
*state = State { generation: run.generation, files: Vec::new() };
|
||||
server.external_diagnostics.store(false, Ordering::Release);
|
||||
}
|
||||
let extras = server.effective_config().tsserver_project_extras;
|
||||
let root = server.root.clone();
|
||||
let exclude_root = self.project_root_for(&root);
|
||||
let hook = self.excludes_hook.read().clone();
|
||||
let extensions = extras.extensions.clone();
|
||||
let files = tokio::task::spawn_blocking(move || {
|
||||
source_files(&root, &exclude_root, hook.map(|h| h()).unwrap_or_default(), LIMIT)
|
||||
source_files(&root, &exclude_root, hook.map(|h| h()).unwrap_or_default(), &extensions, LIMIT)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
@@ -155,17 +172,19 @@ impl LspManager {
|
||||
}
|
||||
server.external_diagnostics.store(true, Ordering::Release);
|
||||
let roots: Vec<_> = files.iter().map(|p| json!({ "fileName": p })).collect();
|
||||
// What an inferred project assumes, so sharing a project changes no diagnostics.
|
||||
let mut options = json!({ "allowJs": true, "target": "ES2022", "module": "ESNext",
|
||||
"moduleResolution": "Node", "allowNonTsExtensions": true, "jsx": "preserve",
|
||||
"allowSyntheticDefaultImports": true, "strictNullChecks": true,
|
||||
"strictFunctionTypes": true, "moduleDetection": "force" });
|
||||
// Absolute targets need no `baseUrl` (deprecated in TypeScript 6).
|
||||
if !extras.paths.is_empty() {
|
||||
options["paths"] = json!(extras.paths);
|
||||
}
|
||||
if request(
|
||||
run,
|
||||
"openExternalProject",
|
||||
json!({
|
||||
"projectFileName": project_name(server), "rootFiles": roots,
|
||||
// What an inferred project assumes, so sharing a project changes no diagnostics.
|
||||
"options": { "allowJs": true, "target": "ES2022", "module": "ESNext",
|
||||
"moduleResolution": "Node", "allowNonTsExtensions": true, "jsx": "preserve",
|
||||
"allowSyntheticDefaultImports": true, "strictNullChecks": true,
|
||||
"strictFunctionTypes": true, "moduleDetection": "force" }
|
||||
}),
|
||||
json!({ "projectFileName": project_name(server), "rootFiles": roots, "options": options }),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -175,24 +194,32 @@ impl LspManager {
|
||||
files.len(),
|
||||
server.root.display()
|
||||
);
|
||||
if state.files.is_empty() {
|
||||
self.await_external_project(server, run.generation, files[0].clone());
|
||||
}
|
||||
state.files = files;
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-registers config-less projects after sources or configurations changed on disk.
|
||||
pub fn project_files_changed<'a>(&self, paths: impl IntoIterator<Item = &'a PathBuf>) {
|
||||
let relevant = paths.into_iter().any(|p| {
|
||||
p.extension().and_then(|s| s.to_str()).is_some_and(is_source)
|
||||
|| p.file_name().is_some_and(|n| n == "tsconfig.json" || n == "jsconfig.json")
|
||||
});
|
||||
if !relevant {
|
||||
return;
|
||||
}
|
||||
for server in self.servers.lock().iter().filter(|s| s.config.tsserver_project) {
|
||||
let paths: Vec<&PathBuf> = paths.into_iter().collect();
|
||||
let servers: Vec<_> = self.servers.lock().iter().filter(|s| s.config.tsserver_project).cloned().collect();
|
||||
for server in servers {
|
||||
let extensions = server.effective_config().tsserver_project_extras.extensions;
|
||||
let relevant = paths.iter().any(|p| {
|
||||
p.extension()
|
||||
.and_then(|s| s.to_str())
|
||||
.is_some_and(|ext| is_source(ext) || extensions.iter().any(|e| e == ext))
|
||||
|| p.file_name().is_some_and(|n| n == "tsconfig.json" || n == "jsconfig.json")
|
||||
});
|
||||
if !relevant {
|
||||
continue;
|
||||
}
|
||||
if server.external_refresh_queued.swap(true, Ordering::AcqRel) {
|
||||
continue;
|
||||
}
|
||||
let (me, server) = (self.me.clone(), server.clone());
|
||||
let me = self.me.clone();
|
||||
self.runtime.spawn(async move {
|
||||
tokio::time::sleep(REFRESH_DELAY).await;
|
||||
server.external_refresh_queued.store(false, Ordering::Release);
|
||||
@@ -203,6 +230,36 @@ impl LspManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// vtsls answers requests from its single-file syntax server until the semantic server
|
||||
/// reports a project loaded or diagnostics; a project registered this way reports no
|
||||
/// loading, so the first usage counts would all be zero. Until the diagnostics of an open
|
||||
/// file arrive, the server is loading: empty answers are incomplete and asked again.
|
||||
fn await_external_project(&self, server: &Arc<Server>, generation: u64, fallback: PathBuf) {
|
||||
server.external_loading.store(generation, Ordering::Release);
|
||||
server.set_loading(&self.sink, true);
|
||||
let (me, server) = (self.me.clone(), server.clone());
|
||||
self.runtime.spawn(async move {
|
||||
// Documents are (re)opened once the instance is ready. An unopened file has no
|
||||
// project in tsserver until one of its files is opened: no diagnostics come.
|
||||
if let Some(run) = server.wait_ready(LOADING_LIMIT).await.filter(|r| r.generation == generation)
|
||||
&& let Some(file) = me.upgrade().map(|me| {
|
||||
me.docs
|
||||
.lock()
|
||||
.values()
|
||||
.find(|d| d.servers.iter().any(|a| a.server.id == server.id))
|
||||
.map_or(fallback, |d| d.path.clone())
|
||||
})
|
||||
{
|
||||
send(&run, "geterr", json!({ "files": [file], "delay": 0 }), true, LOADING_LIMIT).await;
|
||||
}
|
||||
if server.external_loading.compare_exchange(generation, 0, Ordering::AcqRel, Ordering::Acquire).is_ok()
|
||||
&& let Some(me) = me.upgrade()
|
||||
{
|
||||
server.set_loading(&me.sink, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) async fn close_external_project(&self, server: &Server, run: &Running) {
|
||||
let mut state = server.external_project.lock().await;
|
||||
if state.generation == run.generation && !state.files.is_empty() {
|
||||
@@ -241,17 +298,32 @@ mod tests {
|
||||
}
|
||||
std::fs::write(root.join(".gitignore"), "ignored/\n").unwrap();
|
||||
assert_eq!(
|
||||
source_files(root, root, vec!["excluded".into()], LIMIT),
|
||||
source_files(root, root, vec!["excluded".into()], &[], LIMIT),
|
||||
vec![root.join("a.ts"), root.join("src/b.js"), root.join("src/c.tsx")]
|
||||
);
|
||||
assert!(source_files(root, root, vec![], 1).is_empty());
|
||||
assert!(source_files(root, root, vec![], &[], 1).is_empty());
|
||||
std::fs::File::create(root.join("large.js")).unwrap().set_len(SCRIPT_BYTES_LIMIT + 1).unwrap();
|
||||
assert!(source_files(root, root, vec![], LIMIT).is_empty());
|
||||
assert!(source_files(root, root, vec![], &[], LIMIT).is_empty());
|
||||
std::fs::remove_file(root.join("large.js")).unwrap();
|
||||
std::fs::File::create(root.join("large.ts")).unwrap().set_len(SCRIPT_BYTES_LIMIT + 1).unwrap();
|
||||
assert_eq!(source_files(root, root, vec!["excluded".into()], LIMIT).len(), 4);
|
||||
assert_eq!(source_files(root, root, vec!["excluded".into()], &[], LIMIT).len(), 4);
|
||||
std::fs::remove_file(root.join("large.ts")).unwrap();
|
||||
assert_eq!(source_files(&root.join("src"), root, vec![], LIMIT).len(), 2);
|
||||
assert_eq!(source_files(&root.join("src"), root, vec![], &[], LIMIT).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_extensions_join_the_sources() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
for path in ["src/main.js", "src/App.vue", "src/page.svelte"] {
|
||||
write(dir.path(), path);
|
||||
}
|
||||
assert_eq!(source_files(dir.path(), dir.path(), vec![], &[], LIMIT), vec![dir.path().join("src/main.js")]);
|
||||
assert_eq!(
|
||||
source_files(dir.path(), dir.path(), vec![], &["vue".into()], LIMIT),
|
||||
vec![dir.path().join("src/App.vue"), dir.path().join("src/main.js")]
|
||||
);
|
||||
std::fs::File::create(dir.path().join("src/Big.vue")).unwrap().set_len(SCRIPT_BYTES_LIMIT + 1).unwrap();
|
||||
assert!(source_files(dir.path(), dir.path(), vec![], &["vue".into()], LIMIT).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -261,13 +333,13 @@ mod tests {
|
||||
let root = dir.path();
|
||||
write(root, "a.ts");
|
||||
write(root, "pkg/b.js");
|
||||
assert_eq!(source_files(root, root, vec![], LIMIT).len(), 2);
|
||||
assert_eq!(source_files(root, root, vec![], &[], LIMIT).len(), 2);
|
||||
write(root, &format!("pkg/{name}"));
|
||||
assert!(source_files(root, root, vec![], LIMIT).is_empty());
|
||||
assert!(source_files(root, root, vec![], &[], LIMIT).is_empty());
|
||||
std::fs::remove_file(root.join("pkg").join(name)).unwrap();
|
||||
write(root, name);
|
||||
assert!(source_files(root, root, vec![], LIMIT).is_empty());
|
||||
assert!(source_files(&root.join("pkg"), root, vec![], LIMIT).is_empty());
|
||||
assert!(source_files(root, root, vec![], &[], LIMIT).is_empty());
|
||||
assert!(source_files(&root.join("pkg"), root, vec![], &[], LIMIT).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,12 +347,12 @@ mod tests {
|
||||
fn additions_deletions_and_renames_change_membership() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write(dir.path(), "a.js");
|
||||
let first = source_files(dir.path(), dir.path(), vec![], LIMIT);
|
||||
let first = source_files(dir.path(), dir.path(), vec![], &[], LIMIT);
|
||||
write(dir.path(), "b.ts");
|
||||
assert_eq!(source_files(dir.path(), dir.path(), vec![], LIMIT).len(), 2);
|
||||
assert_eq!(source_files(dir.path(), dir.path(), vec![], &[], LIMIT).len(), 2);
|
||||
std::fs::rename(dir.path().join("b.ts"), dir.path().join("b.txt")).unwrap();
|
||||
assert_eq!(source_files(dir.path(), dir.path(), vec![], LIMIT), first);
|
||||
assert_eq!(source_files(dir.path(), dir.path(), vec![], &[], LIMIT), first);
|
||||
std::fs::remove_file(dir.path().join("a.js")).unwrap();
|
||||
assert!(source_files(dir.path(), dir.path(), vec![], LIMIT).is_empty());
|
||||
assert!(source_files(dir.path(), dir.path(), vec![], &[], LIMIT).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -121,6 +121,9 @@ pub struct Server {
|
||||
pub external_project: tokio::sync::Mutex<crate::external_project::State>,
|
||||
pub external_diagnostics: std::sync::atomic::AtomicBool,
|
||||
pub external_refresh_queued: std::sync::atomic::AtomicBool,
|
||||
/// The instance whose just registered external project is loading, until an open file's
|
||||
/// diagnostics arrive (0: none).
|
||||
pub external_loading: std::sync::atomic::AtomicU64,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
@@ -157,6 +160,7 @@ impl Server {
|
||||
external_project: tokio::sync::Mutex::new(Default::default()),
|
||||
external_diagnostics: std::sync::atomic::AtomicBool::new(false),
|
||||
external_refresh_queued: std::sync::atomic::AtomicBool::new(false),
|
||||
external_loading: std::sync::atomic::AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -412,7 +416,8 @@ impl Server {
|
||||
*self.loading.lock() == Some(true)
|
||||
}
|
||||
|
||||
/// Records `experimental/serverStatus`: `quiescent` false while the project loads.
|
||||
/// Records `experimental/serverStatus`: `quiescent` false while the project loads (also
|
||||
/// set while vtsls loads an external project, see `external_project`).
|
||||
pub fn set_loading(&self, sink: &SharedSink, loading: bool) {
|
||||
if self.loading.lock().replace(loading) == Some(loading) {
|
||||
return;
|
||||
@@ -533,6 +538,7 @@ impl Server {
|
||||
|
||||
pub fn take_progress(&self) -> Vec<(TaskId, String)> {
|
||||
*self.loading.lock() = None;
|
||||
self.external_loading.store(0, std::sync::atomic::Ordering::Release);
|
||||
self.progress.lock().drain().map(|(_, p)| (p.task, p.title)).collect()
|
||||
}
|
||||
|
||||
|
||||
@@ -238,6 +238,7 @@ fn server_cfg(name: &str) -> LanguageServerConfig {
|
||||
max_java: None,
|
||||
launch_error: None,
|
||||
tsserver_project: false,
|
||||
tsserver_project_extras: Default::default(),
|
||||
resync_on_configuration: false,
|
||||
pinned: None,
|
||||
}
|
||||
@@ -3387,6 +3388,11 @@ async fn rename_edits_files_outside_the_analysis_scope() {
|
||||
eventually(|| uris(&s.notified("textDocument/didClose")) == vec![uri_of(&b)]).await;
|
||||
}
|
||||
|
||||
/// tsserver project registrations sent through vtsls, without the `geterr` that follows the first.
|
||||
fn project_requests(s: &MockState) -> Vec<Value> {
|
||||
s.requested("workspace/executeCommand").into_iter().filter(|r| r["arguments"][0] != "geterr").collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_projects_are_capability_gated_and_follow_lifecycle() {
|
||||
for capable in [false, true] {
|
||||
@@ -3408,24 +3414,21 @@ async fn external_projects_are_capability_gated_and_follow_lifecycle() {
|
||||
assert!(s.requested("workspace/executeCommand").is_empty());
|
||||
continue;
|
||||
}
|
||||
let requests = s.requested("workspace/executeCommand");
|
||||
let requests = project_requests(&s);
|
||||
assert_eq!(requests[0]["arguments"][0], "openExternalProject");
|
||||
assert_eq!(requests[0]["arguments"][1]["rootFiles"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(s.docs.lock().len(), 1, "registration must not open source documents");
|
||||
std::fs::write(h.root.join("new.ts"), "export const newHelper = 1;").unwrap();
|
||||
h.mgr.project_files_changed([&h.root.join("new.ts")]);
|
||||
eventually(|| s.requested("workspace/executeCommand").len() >= 2).await;
|
||||
assert_eq!(
|
||||
s.requested("workspace/executeCommand")[1]["arguments"][1]["rootFiles"].as_array().unwrap().len(),
|
||||
3
|
||||
);
|
||||
eventually(|| project_requests(&s).len() >= 2).await;
|
||||
assert_eq!(project_requests(&s)[1]["arguments"][1]["rootFiles"].as_array().unwrap().len(), 3);
|
||||
std::fs::rename(h.root.join("new.ts"), h.root.join("renamed.ts")).unwrap();
|
||||
h.mgr.project_files_changed([&h.root.join("renamed.ts")]);
|
||||
eventually(|| s.requested("workspace/executeCommand").len() >= 3).await;
|
||||
assert!(s.requested("workspace/executeCommand")[2].to_string().contains("renamed.ts"));
|
||||
eventually(|| project_requests(&s).len() >= 3).await;
|
||||
assert!(project_requests(&s)[2].to_string().contains("renamed.ts"));
|
||||
std::fs::remove_file(h.root.join("renamed.ts")).unwrap();
|
||||
h.mgr.project_files_changed([&h.root.join("renamed.ts")]);
|
||||
eventually(|| s.requested("workspace/executeCommand").len() >= 4).await;
|
||||
eventually(|| project_requests(&s).len() >= 4).await;
|
||||
let id = h.mgr.servers()[0].id;
|
||||
h.mgr.restart(id).await.unwrap();
|
||||
h.wait_status("arbitrary-name", ServerStatus::Ready).await;
|
||||
@@ -3451,6 +3454,55 @@ async fn external_projects_are_capability_gated_and_follow_lifecycle() {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_projects_carry_plugin_files_and_module_aliases() {
|
||||
let mut caps = incremental_caps();
|
||||
caps["executeCommandProvider"] = json!({ "commands": ["typescript.tsserverRequest"] });
|
||||
let h = harness_configured("vue", "vue", &[("vtsls", caps)], |cfg| {
|
||||
cfg.tsserver_project = true;
|
||||
cfg.tsserver_project_extras.extensions = vec!["vue".into()];
|
||||
cfg.tsserver_project_extras.paths.insert("@/*".into(), vec!["/project/src/*".into()]);
|
||||
});
|
||||
let s = h.launcher.state("vtsls");
|
||||
s.respond("workspace/executeCommand", json!({ "success": true, "body": true }));
|
||||
s.respond("textDocument/references", json!([]));
|
||||
// vtsls answers from one file until its project reports diagnostics: loading till then,
|
||||
// so an empty usage count is not final.
|
||||
let diagnostics = Arc::new(Gate::default());
|
||||
let gate = diagnostics.clone();
|
||||
*s.responder.lock() = Some(Arc::new(move |_, method, params| {
|
||||
if method == "workspace/executeCommand" && params["arguments"][0] == "geterr" {
|
||||
gate.hold();
|
||||
return Some(Value::Null);
|
||||
}
|
||||
None
|
||||
}));
|
||||
std::fs::write(h.root.join("format.js"), "export const sum = 1;").unwrap();
|
||||
let (doc, app) = h.open("App.vue", "<script>import { sum } from '@/format'</script>");
|
||||
h.wait_status("vtsls", ServerStatus::Busy).await;
|
||||
eventually(|| diagnostics.entered()).await;
|
||||
let geterr = s.requested("workspace/executeCommand").into_iter().find(|r| r["arguments"][0] == "geterr").unwrap();
|
||||
assert_eq!(geterr["arguments"][1]["files"], json!([app]), "an open file has a project in tsserver");
|
||||
assert_eq!(geterr["arguments"][2]["isAsync"], true);
|
||||
let refs = h.mgr.references(doc, Pos::new(0, 18), false, false, Default::default()).await.unwrap();
|
||||
assert_eq!(refs.incomplete, Some(ReferencesIncomplete::Indexing));
|
||||
diagnostics.open();
|
||||
h.wait_status("vtsls", ServerStatus::Ready).await;
|
||||
let refs = h.mgr.references(doc, Pos::new(0, 18), false, false, Default::default()).await.unwrap();
|
||||
assert_eq!(refs.incomplete, None);
|
||||
let requests = s.requested("workspace/executeCommand");
|
||||
let args = &requests[0]["arguments"][1];
|
||||
assert_eq!(requests[0]["arguments"][0], "openExternalProject");
|
||||
assert_eq!(args["rootFiles"].as_array().unwrap().len(), 2, "{args}");
|
||||
assert_eq!(args["options"]["paths"], json!({ "@/*": ["/project/src/*"] }));
|
||||
std::fs::write(h.root.join("Cart.vue"), "<script></script>").unwrap();
|
||||
h.mgr.project_files_changed([&h.root.join("Cart.vue")]);
|
||||
eventually(|| project_requests(&s).len() >= 2).await;
|
||||
assert!(project_requests(&s)[1].to_string().contains("Cart.vue"));
|
||||
assert_eq!(s.requested("workspace/executeCommand").len(), 3, "geterr only after the first registration");
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn gopls_interactive_command_validates_before_execution_and_applies_edits() {
|
||||
let mut caps = incremental_caps();
|
||||
|
||||
@@ -135,6 +135,7 @@ async fn missing_binary_is_reported_with_install_hint() {
|
||||
max_java: None,
|
||||
launch_error: None,
|
||||
tsserver_project: false,
|
||||
tsserver_project_extras: Default::default(),
|
||||
resync_on_configuration: false,
|
||||
pinned: None,
|
||||
};
|
||||
@@ -405,6 +406,7 @@ async fn typescript_language_server_end_to_end() {
|
||||
max_java: None,
|
||||
launch_error: None,
|
||||
tsserver_project: false,
|
||||
tsserver_project_extras: Default::default(),
|
||||
resync_on_configuration: false,
|
||||
pinned: None,
|
||||
};
|
||||
@@ -455,6 +457,7 @@ fn npm_server(tools: &Path, name: &str, bin: &str, init: Option<serde_json::Valu
|
||||
max_java: None,
|
||||
launch_error: None,
|
||||
tsserver_project: false,
|
||||
tsserver_project_extras: Default::default(),
|
||||
resync_on_configuration: false,
|
||||
pinned: None,
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! language servers, run configurations and terminals.
|
||||
|
||||
use crate::discover::{project_venv_python, workspace_tsdk};
|
||||
use crate::exec::{path_with, search_path};
|
||||
use crate::exec::{path_with, search_path, which};
|
||||
use crate::{angular, godot, pinned, vue};
|
||||
use crate::{bin_dir, exe, venv_of};
|
||||
use ide_core::settings::{InspectionLevel, InspectionSettings, LanguageServerSettings, Settings};
|
||||
@@ -833,6 +833,14 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root
|
||||
{
|
||||
let plugin = vue::tsserver_plugin(&vue);
|
||||
merge_init_options(cfg, json!({ "vtsls": { "tsserver": { "globalPlugins": [plugin] } } }));
|
||||
cfg.tsserver_project_extras.extensions = vec![vue::EXTENSION.into()];
|
||||
}
|
||||
if name == "vtsls"
|
||||
&& cfg.tsserver_project
|
||||
&& !server_root.ancestors().any(|d| d.join("tsconfig.json").is_file() || d.join("jsconfig.json").is_file())
|
||||
{
|
||||
let node = node_dir(root, s).map(|d| d.join(exe("node"))).filter(|p| p.is_file()).or_else(|| which("node"));
|
||||
cfg.tsserver_project_extras.paths = vue::cli_paths(server_root, node.as_deref());
|
||||
}
|
||||
if name == vue::SERVER {
|
||||
configure_vue_typescript(cfg, root, server_root, s);
|
||||
@@ -932,6 +940,7 @@ fn custom_server(name: &str, ls: &LanguageServerSettings) -> LanguageServerConfi
|
||||
max_java: None,
|
||||
launch_error: None,
|
||||
tsserver_project: false,
|
||||
tsserver_project_extras: Default::default(),
|
||||
resync_on_configuration: false,
|
||||
pinned: None,
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ pub fn project_libraries(root: &Path, settings: &Settings, dependencies: &[Build
|
||||
// ------------------------------------------------------------------ cache
|
||||
|
||||
/// Size and modification time of each manifest (`None`: missing).
|
||||
type Stamp = Vec<(PathBuf, Option<(SystemTime, u64)>)>;
|
||||
pub(crate) type Stamp = Vec<(PathBuf, Option<(SystemTime, u64)>)>;
|
||||
|
||||
struct Cached {
|
||||
/// Settings the result depends on.
|
||||
@@ -97,7 +97,7 @@ pub fn invalidate_go(root: &Path) {
|
||||
CACHE.lock().unwrap_or_else(|e| e.into_inner()).remove(&(root.to_path_buf(), "go"));
|
||||
}
|
||||
|
||||
fn stamp(files: &[PathBuf]) -> Stamp {
|
||||
pub(crate) fn stamp(files: &[PathBuf]) -> Stamp {
|
||||
files
|
||||
.iter()
|
||||
.map(|f| {
|
||||
|
||||
@@ -38,6 +38,7 @@ fn server(name: &str) -> LanguageServerConfig {
|
||||
max_java: None,
|
||||
launch_error: None,
|
||||
tsserver_project: false,
|
||||
tsserver_project_extras: Default::default(),
|
||||
resync_on_configuration: false,
|
||||
pinned: None,
|
||||
}
|
||||
|
||||
@@ -1,13 +1,30 @@
|
||||
//! 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 crate::libraries::{Stamp, package_dependencies, stamp};
|
||||
use crate::npm::{find_command, package_dir};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::ffi::OsStr;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
pub(crate) const SERVER: &str = "vue-language-server";
|
||||
const PACKAGE: &str = "@vue/language-server";
|
||||
const PLUGIN: &str = "@vue/typescript-plugin";
|
||||
/// The file extension the plugin adds to tsserver.
|
||||
pub(crate) const EXTENSION: &str = "vue";
|
||||
const CLI_SERVICE: &str = "@vue/cli-service";
|
||||
const CLI_CONFIG_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const ALIASES_MARKER: &str = "openidea-webpack-aliases:";
|
||||
/// Prints the `resolve.alias` of the webpack configuration module `argv[1]`, after whatever
|
||||
/// the configuration itself prints.
|
||||
const PRINT_ALIASES: &str = "const c = require(process.argv[1]); const a = (c.resolve && c.resolve.alias) || {}; \
|
||||
const e = Array.isArray(a) ? a.map((x) => [x.name, x.alias]) : Object.entries(a); \
|
||||
process.stdout.write('\\n' + process.argv[2] + JSON.stringify(e));";
|
||||
|
||||
type Paths = BTreeMap<String, Vec<String>>;
|
||||
static CLI_PATHS: LazyLock<Mutex<HashMap<PathBuf, (Stamp, Paths)>>> = LazyLock::new(Default::default);
|
||||
|
||||
/// The TypeScript `lib` directory has the language-service API (`typescript.js`).
|
||||
/// TypeScript 7, the native port, ships only `tsc`.
|
||||
@@ -36,6 +53,81 @@ pub(crate) fn tsserver_plugin(package: &Path) -> serde_json::Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// `compilerOptions.paths` for the webpack aliases of a Vue CLI project at `root` (`@/*` →
|
||||
/// `<root>/src/*`), for its tsserver project when it has no `jsconfig.json`: tsserver can't
|
||||
/// read webpack configurations, so `@/` imports resolve to nothing, and a mixin imported
|
||||
/// that way makes the component's whole `this` `any`. Like IntelliJ, evaluates the
|
||||
/// configuration `@vue/cli-service` resolves with the project's `vue.config.js` (the project
|
||||
/// is trusted: language servers only run then); else the alias Vue CLI always adds.
|
||||
/// Empty for other projects.
|
||||
pub(crate) fn cli_paths(root: &Path, node: Option<&Path>) -> Paths {
|
||||
let Ok(manifest) = std::fs::read_to_string(root.join("package.json")) else { return Paths::new() };
|
||||
if !package_dependencies(&manifest).iter().any(|d| d == CLI_SERVICE) {
|
||||
return Paths::new();
|
||||
}
|
||||
let config = root
|
||||
.ancestors()
|
||||
.map(|dir| dir.join("node_modules").join(CLI_SERVICE).join("webpack.config.js"))
|
||||
.find(|f| f.is_file());
|
||||
let mut inputs: Vec<PathBuf> =
|
||||
["package.json", "vue.config.js", "vue.config.cjs", "vue.config.mjs"].iter().map(|f| root.join(f)).collect();
|
||||
inputs.extend(config.iter().cloned());
|
||||
inputs.extend(node.map(Path::to_path_buf));
|
||||
let now = stamp(&inputs);
|
||||
if let Some((then, paths)) = CLI_PATHS.lock().unwrap_or_else(|e| e.into_inner()).get(root)
|
||||
&& *then == now
|
||||
{
|
||||
return paths.clone();
|
||||
}
|
||||
let aliases = config
|
||||
.zip(node)
|
||||
.and_then(|(config, node)| webpack_aliases(root, &config, node))
|
||||
.unwrap_or_else(|| vec![("@".into(), root.join("src").to_string_lossy().into_owned())]);
|
||||
let paths = alias_paths(&aliases);
|
||||
CLI_PATHS.lock().unwrap_or_else(|e| e.into_inner()).insert(root.to_path_buf(), (now, paths.clone()));
|
||||
paths
|
||||
}
|
||||
|
||||
/// `[alias, target]` pairs of the webpack configuration module `config`.
|
||||
fn webpack_aliases(root: &Path, config: &Path, node: &Path) -> Option<Vec<(String, String)>> {
|
||||
let root_s = root.to_string_lossy();
|
||||
let out = crate::exec::query_in(
|
||||
node,
|
||||
&["-e", PRINT_ALIASES, &config.to_string_lossy(), ALIASES_MARKER],
|
||||
Some(root),
|
||||
&[("VUE_CLI_CONTEXT", &root_s), ("NODE_ENV", "development")],
|
||||
CLI_CONFIG_TIMEOUT,
|
||||
);
|
||||
let json = out.as_deref().and_then(|o| o.rsplit_once(ALIASES_MARKER)).map(|(_, json)| json);
|
||||
let Some(pairs) = json.and_then(|j| serde_json::from_str::<Vec<(serde_json::Value, serde_json::Value)>>(j).ok())
|
||||
else {
|
||||
tracing::debug!("no webpack aliases from {}", config.display());
|
||||
return None;
|
||||
};
|
||||
Some(
|
||||
pairs
|
||||
.into_iter()
|
||||
.filter_map(|(name, target)| Some((name.as_str()?.to_owned(), target.as_str()?.to_owned())))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Directory aliases as `paths`; `name$` (exact module) and file aliases have no equivalent.
|
||||
fn alias_paths(aliases: &[(String, String)]) -> Paths {
|
||||
let mut paths = Paths::new();
|
||||
for (name, target) in aliases {
|
||||
let dir = Path::new(target);
|
||||
if name.is_empty() || name.ends_with('$') || name.contains('*') || !dir.is_absolute() || !dir.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let dir = target.replace('\\', "/");
|
||||
let dir = dir.trim_end_matches('/');
|
||||
paths.insert(format!("{name}/*"), vec![format!("{dir}/*")]);
|
||||
paths.insert(name.clone(), vec![dir.to_owned()]);
|
||||
}
|
||||
paths
|
||||
}
|
||||
|
||||
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@6 \
|
||||
@@ -80,6 +172,59 @@ mod tests {
|
||||
assert_eq!(server_package("not-installed", &path), None);
|
||||
}
|
||||
|
||||
fn cli_project(t: &Path) {
|
||||
std::fs::create_dir_all(t.join("src/components")).unwrap();
|
||||
std::fs::write(t.join("package.json"), r#"{"devDependencies":{"@vue/cli-service":"~5.0.0"}}"#).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vue_cli_projects_get_the_at_alias_without_their_webpack_configuration() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let t = tmp.path();
|
||||
std::fs::write(t.join("package.json"), r#"{"dependencies":{"vue":"^2.6.14"}}"#).unwrap();
|
||||
assert!(cli_paths(t, None).is_empty());
|
||||
cli_project(t);
|
||||
let src = t.join("src").to_string_lossy().replace('\\', "/");
|
||||
assert_eq!(
|
||||
cli_paths(t, None),
|
||||
Paths::from([("@".into(), vec![src.clone()]), ("@/*".into(), vec![format!("{src}/*")])])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vue_cli_aliases_come_from_the_resolved_webpack_configuration() {
|
||||
let Some(node) = crate::exec::which("node") else { return };
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let t = tmp.path();
|
||||
cli_project(t);
|
||||
let service = t.join("node_modules/@vue/cli-service");
|
||||
std::fs::create_dir_all(&service).unwrap();
|
||||
// What `vue.config.js` can add, and what the configuration may print while loading.
|
||||
std::fs::write(
|
||||
service.join("webpack.config.js"),
|
||||
r#"const path = require('path');
|
||||
console.log('loading plugins');
|
||||
module.exports = { resolve: { alias: {
|
||||
'@': path.join(process.env.VUE_CLI_CONTEXT, 'src'),
|
||||
comps: path.join(process.env.VUE_CLI_CONTEXT, 'src/components'),
|
||||
vue$: 'vue/dist/vue.runtime.esm.js',
|
||||
missing: path.join(process.env.VUE_CLI_CONTEXT, 'nowhere'),
|
||||
} } };"#,
|
||||
)
|
||||
.unwrap();
|
||||
let slash = |p: PathBuf| p.to_string_lossy().replace('\\', "/");
|
||||
let (src, comps) = (slash(t.join("src")), slash(t.join("src/components")));
|
||||
assert_eq!(
|
||||
cli_paths(t, Some(&node)),
|
||||
Paths::from([
|
||||
("@".into(), vec![src.clone()]),
|
||||
("@/*".into(), vec![format!("{src}/*")]),
|
||||
("comps".into(), vec![comps.clone()]),
|
||||
("comps/*".into(), vec![format!("{comps}/*")]),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn own_typescript_skips_typescript_7() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -18,6 +18,7 @@ install-hint = "npm i -g @vue/language-server typescript@6"
|
||||
# reaches it through `tsserver/request`, which the client forwards to vtsls.
|
||||
[[language-servers]]
|
||||
name = "vtsls"
|
||||
tsserver-project = true
|
||||
command = "vtsls"
|
||||
args = ["--stdio"]
|
||||
root-markers = ["tsconfig.json", "jsconfig.json", "package.json"]
|
||||
|
||||
Reference in new issue
Block a user