From 8fabf054ef981807724e5e24ddaab12807dc4f15 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:49:00 +0800 Subject: [PATCH] fix(lsp): offer auto-imports from unopened files in projects without a tsconfig (#496) Without a tsconfig.json or jsconfig.json, tsserver's inferred project holds only open files and what they import, so completion never offered exports from other files. Register the project's sources with tsserver as an external project, using the options an inferred project assumes, so no file is written to disk. Registration needs `typescript.tsserverRequest`. Projects with a configuration, including one in a subfolder, stay as they were, and so do trees over 10,000 sources or 16 MiB of JavaScript, since tsserver disables its language service past 20 MiB. Created and deleted files update the project, and unopened files stay out of Problems. --- Cargo.lock | 1 + crates/ide-host/src/project.rs | 8 + crates/ide-language/src/lib.rs | 3 + crates/ide-lsp/Cargo.toml | 1 + crates/ide-lsp/src/external_project.rs | 286 +++++++++++++++++++++++++ crates/ide-lsp/src/handler.rs | 5 +- crates/ide-lsp/src/lib.rs | 6 + crates/ide-lsp/src/server.rs | 6 + crates/ide-lsp/tests/mock_server.rs | 65 ++++++ crates/ide-lsp/tests/real_servers.rs | 3 + crates/ide-sdk/src/apply.rs | 1 + crates/ide-sdk/src/tests.rs | 1 + languages/javascript.toml | 2 + languages/javascriptreact.toml | 2 + languages/typescript.toml | 2 + languages/typescriptreact.toml | 2 + 16 files changed, 393 insertions(+), 1 deletion(-) create mode 100644 crates/ide-lsp/src/external_project.rs diff --git a/Cargo.lock b/Cargo.lock index 0f14a76d..e73749ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4372,6 +4372,7 @@ dependencies = [ "ide-jsonrpc", "ide-language", "ide-text", + "ignore", "lsp-types", "parking_lot", "percent-encoding", diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index 3bafc8a0..8fadce6a 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -223,6 +223,7 @@ impl Host { let history = history.clone(); let remote = self.remote.clone(); let runtime = self.runtime.clone(); + let lsp = lsp.clone(); Watcher::start( &root.clone(), excludes.clone(), @@ -230,6 +231,7 @@ impl Host { Box::new(move |batch| { let changed: Vec = batch.created.iter().chain(&batch.modified).cloned().collect(); index.update(&changed, &batch.removed); + lsp.project_files_changed(batch.created.iter().chain(&batch.removed)); // Atomic saves by external tools surface as `created`. documents.on_disk_changed(&changed); // A framework's root marker (`angular.json`) created or deleted: its @@ -358,6 +360,12 @@ impl Host { weak.upgrade().map(|p| p.reference_candidates(q, cancel)).unwrap_or_default() })); } + { + let weak = Arc::downgrade(&project); + project.lsp.set_project_excludes_hook(Box::new(move || { + weak.upgrade().map(|p| p.exclude_globs()).unwrap_or_default() + })); + } self.insert_project(window, project.clone()); if let Some(cmd) = post_start { self.environment.spawn_post_start(cmd, project.root.clone(), project.sink.clone()); diff --git a/crates/ide-language/src/lib.rs b/crates/ide-language/src/lib.rs index 632ec160..fc935338 100644 --- a/crates/ide-language/src/lib.rs +++ b/crates/ide-language/src/lib.rs @@ -120,6 +120,9 @@ pub struct LanguageServerConfig { /// a JDK 21+); reported as the failure instead of launching it. #[serde(skip)] pub launch_error: Option, + /// Register source files with tsserver when no project configuration exists. + #[serde(default)] + pub tsserver_project: bool, } #[derive(Clone, Debug, Serialize, Deserialize)] diff --git a/crates/ide-lsp/Cargo.toml b/crates/ide-lsp/Cargo.toml index ed05a90b..a8d2605f 100644 --- a/crates/ide-lsp/Cargo.toml +++ b/crates/ide-lsp/Cargo.toml @@ -19,6 +19,7 @@ parking_lot.workspace = true tracing.workspace = true futures.workspace = true globset.workspace = true +ignore.workspace = true toml.workspace = true percent-encoding = "2" diff --git a/crates/ide-lsp/src/external_project.rs b/crates/ide-lsp/src/external_project.rs new file mode 100644 index 00000000..cb9393c9 --- /dev/null +++ b/crates/ide-lsp/src/external_project.rs @@ -0,0 +1,286 @@ +//! Give tsserver a project for unopened sources, without creating a config file. +use crate::LspManager; +use crate::server::{Running, Server}; +use serde_json::{Value, json}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::Ordering; +use std::time::Duration; + +pub type ExcludesFn = dyn Fn() -> Vec + Send + Sync; +const COMMAND: &str = "typescript.tsserverRequest"; +const LIMIT: usize = 10_000; +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); +const TIMEOUT: Duration = Duration::from_secs(15); + +#[derive(Default)] +pub(crate) struct State { + generation: u64, + files: Vec, +} + +fn supported(server: &Server, run: &Running) -> bool { + server.config.tsserver_project + && run + .caps + .read() + .raw + .pointer("/executeCommandProvider/commands") + .and_then(Value::as_array) + .is_some_and(|commands| commands.iter().any(|c| c.as_str() == Some(COMMAND))) +} + +fn configured(dir: &Path) -> bool { + ["tsconfig.json", "jsconfig.json"].iter().any(|name| dir.join(name).is_file()) +} + +fn is_script(ext: &str) -> bool { + matches!(ext, "js" | "jsx" | "mjs" | "cjs") +} + +fn is_source(ext: &str) -> bool { + is_script(ext) || matches!(ext, "ts" | "tsx" | "mts" | "cts") +} + +/// 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, limit: usize) -> Vec { + if root.ancestors().any(configured) { + return Vec::new(); + } + let mut ignores = ignore::gitignore::GitignoreBuilder::new(exclude_root); + for pattern in excludes { + if ignores.add_line(None, &pattern).is_err() { + return Vec::new(); + } + } + let Ok(ignores) = ignores.build() else { return Vec::new() }; + let mut walk = ignore::WalkBuilder::new(root); + walk.require_git(false).follow_links(false).filter_entry(move |entry| { + if entry.depth() == 0 { + return true; + } + let path = entry.path(); + let name = entry.file_name().to_string_lossy(); + !matches!(name.as_ref(), "node_modules" | "dist" | "build" | "out" | "coverage" | "target" | ".git") + && !ignores.matched_path_or_any_parents(path, entry.file_type().is_some_and(|t| t.is_dir())).is_ignore() + }); + let mut files = Vec::new(); + let mut script_bytes = 0; + for (visited, entry) in walk.build().enumerate() { + // Do not claim a tree is config-less if we could not inspect it fully. + let Ok(entry) = entry else { return Vec::new() }; + if visited >= SCAN_LIMIT { + return Vec::new(); + } + if entry.file_type().is_some_and(|t| t.is_dir()) && configured(entry.path()) { + 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) { + 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"); + return Vec::new(); + } + } + files.push(entry.into_path()); + if files.len() > limit { + tracing::debug!("external TypeScript project exceeds {limit} files; retaining inferred projects"); + return Vec::new(); + } + } + } + files.sort(); + files +} + +fn project_name(server: &Server) -> PathBuf { + server.root.join(".openidea-external-project") +} + +async fn request(run: &Running, command: &str, args: Value) -> bool { + let request = run.peer.request::<_, Value>( + "workspace/executeCommand", + json!({ + "command": COMMAND, + "arguments": [command, args, { "isAsync": false, "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, + result => { + tracing::debug!("tsserver {command}: {result:?}"); + false + } + } +} + +impl LspManager { + pub fn set_project_excludes_hook(&self, hook: Box) { + *self.excludes_hook.write() = Some(Arc::from(hook)); + } + + pub(crate) async fn refresh_external_project(&self, server: &Server, run: &Running) { + if !supported(server, run) { + return; + } + let mut state = server.external_project.lock().await; + if state.generation != run.generation { + *state = State { generation: run.generation, files: Vec::new() }; + server.external_diagnostics.store(false, Ordering::Release); + } + let root = server.root.clone(); + let exclude_root = self.project_root_for(&root); + let hook = self.excludes_hook.read().clone(); + let files = tokio::task::spawn_blocking(move || { + source_files(&root, &exclude_root, hook.map(|h| h()).unwrap_or_default(), LIMIT) + }) + .await + .unwrap_or_default(); + if state.files == files { + return; + } + if files.is_empty() { + if request(run, "closeExternalProject", json!({ "projectFileName": project_name(server) })).await { + state.files.clear(); + // Keep suppressing delayed diagnostics for unopened files from this instance. + } + return; + } + server.external_diagnostics.store(true, Ordering::Release); + let roots: Vec<_> = files.iter().map(|p| json!({ "fileName": p })).collect(); + 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" } + }), + ) + .await + { + tracing::debug!( + "{}: registered {} external project files at {}", + server.name, + files.len(), + server.root.display() + ); + 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) { + 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) { + if server.external_refresh_queued.swap(true, Ordering::AcqRel) { + continue; + } + let (me, server) = (self.me.clone(), server.clone()); + self.runtime.spawn(async move { + tokio::time::sleep(REFRESH_DELAY).await; + server.external_refresh_queued.store(false, Ordering::Release); + if let (Some(me), Some(run)) = (me.upgrade(), server.running()) { + me.refresh_external_project(&server, &run).await; + } + }); + } + } + + 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() { + request(run, "closeExternalProject", json!({ "projectFileName": project_name(server) })).await; + state.files.clear(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write(root: &Path, path: &str) { + let path = root.join(path); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, "").unwrap(); + } + + #[test] + fn selects_sources_honouring_ignores_exclusions_and_roots() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + for path in [ + "a.ts", + "src/b.js", + "src/c.tsx", + "readme.md", + "node_modules/n.ts", + "dist/d.js", + "excluded/e.ts", + "ignored/i.ts", + "other/f.py", + ] { + write(root, path); + } + std::fs::write(root.join(".gitignore"), "ignored/\n").unwrap(); + assert_eq!( + 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()); + 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()); + 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); + std::fs::remove_file(root.join("large.ts")).unwrap(); + assert_eq!(source_files(&root.join("src"), root, vec![], LIMIT).len(), 2); + } + + #[test] + fn root_ancestor_and_nested_configs_keep_existing_projects() { + for name in ["tsconfig.json", "jsconfig.json"] { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + write(root, "a.ts"); + write(root, "pkg/b.js"); + assert_eq!(source_files(root, root, vec![], LIMIT).len(), 2); + write(root, &format!("pkg/{name}")); + 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()); + } + } + + #[test] + 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); + write(dir.path(), "b.ts"); + 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); + std::fs::remove_file(dir.path().join("a.js")).unwrap(); + assert!(source_files(dir.path(), dir.path(), vec![], LIMIT).is_empty()); + } +} diff --git a/crates/ide-lsp/src/handler.rs b/crates/ide-lsp/src/handler.rs index 5b15e787..24b9fa2d 100644 --- a/crates/ide-lsp/src/handler.rs +++ b/crates/ide-lsp/src/handler.rs @@ -120,7 +120,10 @@ impl ClientHandler { }; let Some(path) = uri_to_path(params.uri.as_str()) else { return }; // Files opened only for a request (references search) are not the user's problems. - if self.server.suppresses_diagnostics(&path) { + if (self.server.external_diagnostics.load(std::sync::atomic::Ordering::Acquire) + && !mgr.docs.lock().values().any(|d| d.path == path)) + || self.server.suppresses_diagnostics(&path) + { return; } let conv = mgr.conv(self.encoding()); diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index b47896fe..532f7644 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -9,6 +9,7 @@ mod angular; mod caps; mod convert; +mod external_project; mod file_ops; mod handler; mod inspect; @@ -169,6 +170,7 @@ pub struct LspManager { prepare_hook: RwLock>>, servers_hook: RwLock>>, candidates_hook: RwLock>>, + excludes_hook: RwLock>>, shutting_down: AtomicBool, /// `window/showMessage` texts already surfaced; servers repeat them on every restart/reconfigure. shown_messages: Mutex>, @@ -299,6 +301,7 @@ impl LspManager { prepare_hook: RwLock::new(None), servers_hook: RwLock::new(None), candidates_hook: RwLock::new(None), + excludes_hook: RwLock::new(None), shutting_down: AtomicBool::new(false), shown_messages: Mutex::new(HashSet::new()), virtual_origins: Mutex::new(HashMap::new()), @@ -517,6 +520,8 @@ impl LspManager { } let run = Running { generation, peer: peer.clone(), caps: Arc::new(RwLock::new(caps)) }; + self.refresh_external_project(&server, &run).await; + // Publish readiness and (re)open attached documents atomically w.r.t. did_change. { let mut docs = self.docs.lock(); @@ -2212,6 +2217,7 @@ impl LspManager { return; } let graceful = async { + self.close_external_project(server, &run).await; peer.request::<_, Value>("shutdown", (), None).await.ok(); peer.notify("exit", ()).ok(); peer.closed().await; diff --git a/crates/ide-lsp/src/server.rs b/crates/ide-lsp/src/server.rs index fb176b53..d32a9fbe 100644 --- a/crates/ide-lsp/src/server.rs +++ b/crates/ide-lsp/src/server.rs @@ -99,6 +99,9 @@ pub struct Server { /// Woken when the last `$/progress` task ends. idle_notify: Notify, transient: Mutex>, + pub external_project: tokio::sync::Mutex, + pub external_diagnostics: std::sync::atomic::AtomicBool, + pub external_refresh_queued: std::sync::atomic::AtomicBool, } impl Server { @@ -123,6 +126,9 @@ impl Server { published_notify: Notify::new(), idle_notify: Notify::new(), transient: Mutex::new(HashMap::new()), + 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), } } diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index 9d0952f3..0e5df1e4 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -210,6 +210,7 @@ fn server_cfg(name: &str) -> LanguageServerConfig { fallback: false, max_java: None, launch_error: None, + tsserver_project: false, } } @@ -2258,3 +2259,67 @@ async fn rename_edits_files_outside_the_analysis_scope() { assert_eq!(paths, vec![a, b.clone()]); eventually(|| uris(&s.notified("textDocument/didClose")) == vec![uri_of(&b)]).await; } + +#[tokio::test] +async fn external_projects_are_capability_gated_and_follow_lifecycle() { + for capable in [false, true] { + let mut caps = incremental_caps(); + caps["executeCommandProvider"] = json!({ "commands": if capable { + vec!["typescript.tsserverRequest"] + } else { vec!["_typescript.organizeImports"] } }); + let h = harness_configured("typescript", "ts", &[("arbitrary-name", caps)], |cfg| { + cfg.tsserver_project = true; + }); + let s = h.launcher.state("arbitrary-name"); + s.respond("workspace/executeCommand", json!({ "success": true, "body": true })); + std::fs::write(h.root.join("unopened.ts"), "export const helper = 1;").unwrap(); + h.open("main.ts", "helper"); + h.wait_status("arbitrary-name", ServerStatus::Ready).await; + if !capable { + assert!(s.requested("workspace/executeCommand").is_empty()); + h.mgr.shutdown_all().await; + assert!(s.requested("workspace/executeCommand").is_empty()); + continue; + } + let requests = s.requested("workspace/executeCommand"); + 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 + ); + 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")); + 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; + let id = h.mgr.servers()[0].id; + h.mgr.restart(id).await.unwrap(); + h.wait_status("arbitrary-name", ServerStatus::Ready).await; + eventually(|| { + s.requested("workspace/executeCommand") + .iter() + .filter(|r| r["arguments"][0] == "openExternalProject") + .count() + >= 5 + }) + .await; + std::fs::write(h.root.join("tsconfig.json"), "{}").unwrap(); + h.mgr.project_files_changed([&h.root.join("tsconfig.json")]); + eventually(|| { + s.requested("workspace/executeCommand") + .iter() + .filter(|r| r["arguments"][0] == "closeExternalProject") + .count() + >= 2 + }) + .await; + h.mgr.shutdown_all().await; + } +} diff --git a/crates/ide-lsp/tests/real_servers.rs b/crates/ide-lsp/tests/real_servers.rs index dd725b6b..13ab9667 100644 --- a/crates/ide-lsp/tests/real_servers.rs +++ b/crates/ide-lsp/tests/real_servers.rs @@ -78,6 +78,7 @@ async fn missing_binary_is_reported_with_install_hint() { fallback: false, max_java: None, launch_error: None, + tsserver_project: false, }; let (mgr, events) = manager(dir.path(), LanguageRegistry::from_configs(vec![lang("nope", "nope", server)])); let path = dir.path().join("a.nope"); @@ -247,6 +248,7 @@ async fn typescript_language_server_end_to_end() { fallback: false, max_java: None, launch_error: None, + tsserver_project: false, }; let (mgr, events) = manager(&root, LanguageRegistry::from_configs(vec![lang("typescript", "ts", server)])); let doc = DocId::next(); @@ -292,6 +294,7 @@ fn npm_server(tools: &Path, name: &str, bin: &str, init: Option LanguageServerConfi fallback: false, max_java: None, launch_error: None, + tsserver_project: false, } } diff --git a/crates/ide-sdk/src/tests.rs b/crates/ide-sdk/src/tests.rs index b71cf73a..7792808a 100644 --- a/crates/ide-sdk/src/tests.rs +++ b/crates/ide-sdk/src/tests.rs @@ -35,6 +35,7 @@ fn server(name: &str) -> LanguageServerConfig { fallback: false, max_java: None, launch_error: None, + tsserver_project: false, } } diff --git a/languages/javascript.toml b/languages/javascript.toml index af10ad60..edbd83d8 100644 --- a/languages/javascript.toml +++ b/languages/javascript.toml @@ -10,6 +10,7 @@ tree-sitter = "javascript" [[language-servers]] name = "vtsls" +tsserver-project = true command = "vtsls" args = ["--stdio"] root-markers = ["jsconfig.json", "tsconfig.json", "package.json"] @@ -17,6 +18,7 @@ install-hint = "npm i -g @vtsls/language-server" [[language-servers]] name = "typescript-language-server" +tsserver-project = true # Alternative: used only when the server above is not installed. fallback = true command = "typescript-language-server" diff --git a/languages/javascriptreact.toml b/languages/javascriptreact.toml index 7dd3ae69..f2864cdf 100644 --- a/languages/javascriptreact.toml +++ b/languages/javascriptreact.toml @@ -9,6 +9,7 @@ tree-sitter = "javascript" [[language-servers]] name = "vtsls" +tsserver-project = true command = "vtsls" args = ["--stdio"] root-markers = ["jsconfig.json", "tsconfig.json", "package.json"] @@ -16,6 +17,7 @@ install-hint = "npm i -g @vtsls/language-server" [[language-servers]] name = "typescript-language-server" +tsserver-project = true # Alternative: used only when the server above is not installed. fallback = true command = "typescript-language-server" diff --git a/languages/typescript.toml b/languages/typescript.toml index 2de7b2cc..e5f231bc 100644 --- a/languages/typescript.toml +++ b/languages/typescript.toml @@ -9,6 +9,7 @@ tree-sitter = "typescript" [[language-servers]] name = "vtsls" +tsserver-project = true command = "vtsls" args = ["--stdio"] root-markers = ["tsconfig.json", "jsconfig.json", "package.json"] @@ -16,6 +17,7 @@ install-hint = "npm i -g @vtsls/language-server" [[language-servers]] name = "typescript-language-server" +tsserver-project = true # Alternative: used only when the server above is not installed. fallback = true command = "typescript-language-server" diff --git a/languages/typescriptreact.toml b/languages/typescriptreact.toml index f1d8fc9c..0a5069c7 100644 --- a/languages/typescriptreact.toml +++ b/languages/typescriptreact.toml @@ -9,6 +9,7 @@ tree-sitter = "tsx" [[language-servers]] name = "vtsls" +tsserver-project = true command = "vtsls" args = ["--stdio"] root-markers = ["tsconfig.json", "jsconfig.json", "package.json"] @@ -16,6 +17,7 @@ install-hint = "npm i -g @vtsls/language-server" [[language-servers]] name = "typescript-language-server" +tsserver-project = true # Alternative: used only when the server above is not installed. fallback = true command = "typescript-language-server"