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"