diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index e63e8db8b..e45d346a0 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -56,6 +56,9 @@ pub struct Project { pub(crate) watcher: Watcher, /// Watchers of the Project Structure content roots outside the project folder. pub(crate) content_watchers: Mutex>, + /// Watchers of folders outside the project that language servers registered file + /// watchers for (a virtualenv elsewhere, a path dependency). + lsp_watchers: Mutex>, pub(crate) _scratch_watcher: Option, /// A LightEdit window's loose files ([`Host::open_light`]): global settings only, /// nothing indexed, no language servers, repositories or project services. @@ -305,7 +308,7 @@ impl Host { index.update(&changed, &batch.removed); kotlin_bridge.files_changed(changed.iter().chain(&batch.removed)); lsp.project_files_changed(batch.created.iter().chain(&batch.removed)); - lsp.watched_files_changed(&batch.created, &batch.modified, &batch.removed); + lsp.watched_files_changed(&batch.created, &batch.modified, &batch.removed, &batch.excluded_dirs); // Atomic saves by external tools surface as `created`. documents.on_disk_changed(&changed); // A framework's root marker (`angular.json`) created or deleted: its @@ -458,6 +461,7 @@ impl Host { kotlin_bridge: Some(kotlin_bridge), watcher, content_watchers: Mutex::new(content_watchers), + lsp_watchers: Mutex::new(Vec::new()), _scratch_watcher: scratch_watcher, light: false, }); @@ -467,6 +471,14 @@ impl Host { weak.upgrade().map(|p| p.reference_candidates(q, cancel)).unwrap_or_default() })); } + { + let weak = Arc::downgrade(&project); + project.lsp.set_file_watch_roots_hook(Box::new(move |roots| { + if let Some(p) = weak.upgrade() { + p.watch_for_language_servers(roots); + } + })); + } { let weak = Arc::downgrade(&project); project.lsp.set_project_excludes_hook(Box::new(move || { @@ -582,6 +594,7 @@ impl Host { kotlin_bridge: None, watcher, content_watchers: Mutex::new(Vec::new()), + lsp_watchers: Mutex::new(Vec::new()), _scratch_watcher: None, light: true, root: dir, @@ -991,6 +1004,30 @@ fn content_roots(settings: &ide_core::settings::Settings, root: &Path, build_cac /// Watches a content root outside the project: the index, open documents and /// Local History follow its changes like the project folder's. +impl Project { + /// Watches `roots` (outside the project) for the language servers that asked to. + fn watch_for_language_servers(&self, roots: Vec) { + let mut watchers = self.lsp_watchers.lock(); + watchers.retain(|(r, _)| roots.contains(r)); + for root in roots { + if watchers.iter().any(|(r, _)| *r == root) || !root.is_dir() { + continue; + } + let lsp = Arc::downgrade(&self.lsp); + let on_change = Box::new(move |batch: &ide_vfs::FsBatch| { + if let Some(lsp) = lsp.upgrade() { + lsp.watched_files_changed(&batch.created, &batch.modified, &batch.removed, &batch.excluded_dirs); + } + }); + // Not project files: nothing else hears of them. + match Watcher::start(&root, Vec::new(), Arc::new(ide_core::NullSink), on_change) { + Ok(w) => watchers.push((root, w)), + Err(e) => tracing::warn!("language server watcher {}: {e}", root.display()), + } + } + } +} + fn content_root_watcher( dir: &Path, excludes: &[String], diff --git a/crates/ide-lsp/src/caps.rs b/crates/ide-lsp/src/caps.rs index a20296ef6..550736c6e 100644 --- a/crates/ide-lsp/src/caps.rs +++ b/crates/ide-lsp/src/caps.rs @@ -37,7 +37,7 @@ pub fn client_capabilities() -> Value { "fileOperations": { "dynamicRegistration": true, "willRename": true, "didRename": true }, "configuration": true, "didChangeConfiguration": { "dynamicRegistration": false }, - "didChangeWatchedFiles": { "dynamicRegistration": false, "relativePatternSupport": false }, + "didChangeWatchedFiles": { "dynamicRegistration": true, "relativePatternSupport": true }, "symbol": { "dynamicRegistration": false, "symbolKind": { "valueSet": symbol_kinds }, diff --git a/crates/ide-lsp/src/file_watch.rs b/crates/ide-lsp/src/file_watch.rs new file mode 100644 index 000000000..6ec5290ef --- /dev/null +++ b/crates/ide-lsp/src/file_watch.rs @@ -0,0 +1,150 @@ +//! `workspace/didChangeWatchedFiles` registrations: which changes on disk a +//! server asked to hear of (its globs, relative patterns and change kinds). + +use crate::uri; +use globset::{GlobBuilder, GlobMatcher}; +use serde_json::Value; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +/// `FileChangeType`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Change { + Created = 1, + Changed = 2, + Deleted = 3, +} + +impl Change { + /// The `WatchKind` bit. + fn kind_bit(self) -> u64 { + match self { + Self::Created => 1, + Self::Changed => 2, + Self::Deleted => 4, + } + } +} + +struct Watcher { + /// Relative pattern's base; `None` for a plain glob, matched below the server's root. + base: Option, + /// A plain glob that is an absolute path pattern. + absolute: bool, + glob: GlobMatcher, + kind: u64, +} + +/// A server's registrations, by registration id. +#[derive(Default)] +pub struct FileWatchers(BTreeMap>); + +impl FileWatchers { + pub fn register(&mut self, id: &str, options: Option<&Value>) { + let watchers = options + .and_then(|o| o.get("watchers")) + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(parse) + .collect(); + self.0.insert(id.to_owned(), watchers); + } + + pub fn unregister(&mut self, id: &str) { + self.0.remove(id); + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// Whether a server rooted at `root` wants to hear of `change` to `path`. + pub fn wants(&self, root: &Path, path: &Path, change: Change) -> bool { + self.0.values().flatten().any(|w| { + if w.kind & change.kind_bit() == 0 { + return false; + } + match &w.base { + Some(base) => path.strip_prefix(base).is_ok_and(|rel| w.glob.is_match(rel)), + None if w.absolute => w.glob.is_match(path), + None => path.strip_prefix(root).is_ok_and(|rel| w.glob.is_match(rel)), + } + }) + } + + /// Bases of relative patterns, which may lie outside the project (a virtualenv's + /// `site-packages`, a path dependency): the client watches them for the server. + pub fn bases(&self) -> impl Iterator { + self.0.values().flatten().filter_map(|w| w.base.as_deref()) + } +} + +fn parse(watcher: &Value) -> Option { + let kind = watcher.get("kind").and_then(Value::as_u64).unwrap_or(7); + let (base, pattern) = match watcher.get("globPattern")? { + Value::String(p) => (None, p.as_str()), + relative => { + let base = relative.get("baseUri")?; + // A `WorkspaceFolder` or a URI. + let base = base.get("uri").unwrap_or(base).as_str()?; + let base = uri::uri_to_path(base)?; + // Changes are reported for canonical paths (`/tmp` is `/private/tmp` on macOS). + (Some(dunce::canonicalize(&base).unwrap_or(base)), relative.get("pattern")?.as_str()?) + } + }; + let glob = GlobBuilder::new(pattern).literal_separator(true).build().ok()?.compile_matcher(); + let absolute = base.is_none() && Path::new(pattern).is_absolute(); + Some(Watcher { base, absolute, glob, kind }) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn watchers(list: Value) -> FileWatchers { + let mut w = FileWatchers::default(); + w.register("1", Some(&json!({ "watchers": list }))); + w + } + + #[test] + fn matches_globs_relative_patterns_and_kinds() { + let root = Path::new("/p"); + let venv = uri::path_to_uri(Path::new("/opt/venv/lib/site-packages")); + let w = watchers(json!([ + { "globPattern": "**/*.{go,mod,sum}" }, + { "globPattern": "**/pyrightconfig.json", "kind": 2 }, + { "globPattern": { "baseUri": venv, "pattern": "**" } }, + { "globPattern": { "baseUri": { "uri": uri::path_to_uri(Path::new("/shared")), "name": "s" }, "pattern": "*.toml" } }, + ])); + let cases = [ + ("/p/go.mod", Change::Changed, true), + ("/p/sub/main.go", Change::Created, true), + ("/elsewhere/main.go", Change::Created, false), + ("/p/pyrightconfig.json", Change::Changed, true), + ("/p/pyrightconfig.json", Change::Created, false), + ("/opt/venv/lib/site-packages/idna/__init__.py", Change::Created, true), + ("/opt/venv/lib/site-packages", Change::Deleted, true), + ("/shared/Cargo.toml", Change::Changed, true), + ("/shared/nested/Cargo.toml", Change::Changed, false), + ("/p/readme.md", Change::Changed, false), + ]; + for (path, change, expected) in cases { + assert_eq!(w.wants(root, Path::new(path), change), expected, "{path} {change:?}"); + } + let mut bases: Vec<_> = w.bases().collect(); + bases.sort(); + assert_eq!(bases, [Path::new("/opt/venv/lib/site-packages"), Path::new("/shared")]); + } + + #[test] + fn unregistering_drops_the_watchers() { + let mut w = watchers(json!([{ "globPattern": "**" }])); + assert!(w.wants(Path::new("/p"), Path::new("/p/a"), Change::Created)); + w.unregister("1"); + assert!(w.is_empty()); + assert!(!w.wants(Path::new("/p"), Path::new("/p/a"), Change::Created)); + } +} diff --git a/crates/ide-lsp/src/handler.rs b/crates/ide-lsp/src/handler.rs index 006e4d090..55130d689 100644 --- a/crates/ide-lsp/src/handler.rs +++ b/crates/ide-lsp/src/handler.rs @@ -206,19 +206,33 @@ impl ClientHandler { Ok(reply) } - fn registration(&self, params: &Value, register: bool) { - let Some(run) = self.server.running_gen(self.generation) else { return }; + fn registration(&self, mgr: &LspManager, params: &Value, register: bool) { + let Some(run) = self.server.initialized_gen(self.generation) else { return }; let key = if register { "registrations" } else { "unregisterations" }; let list = params.get(key).or_else(|| params.get("unregistrations")).and_then(Value::as_array); let mut caps = run.caps.write(); + let mut watchers_changed = false; for reg in list.into_iter().flatten() { let Some(method) = reg.get("method").and_then(Value::as_str) else { continue }; - if register { + if method == "workspace/didChangeWatchedFiles" { + let id = reg.get("id").and_then(Value::as_str).unwrap_or_default(); + let mut watchers = run.watchers.write(); + if register { + watchers.register(id, reg.get("registerOptions")); + } else { + watchers.unregister(id); + } + watchers_changed = true; + } else if register { caps.register(method, reg.get("registerOptions")); } else { caps.unregister(method); } } + drop(caps); + if watchers_changed { + mgr.file_watch_roots_changed(); + } } } @@ -278,11 +292,11 @@ impl Handler for ClientHandler { "workspace/configuration" => Ok(self.configuration(¶ms)), "workspace/workspaceFolders" => Ok(mgr.workspace_folders(&self.server)), "client/registerCapability" => { - self.registration(¶ms, true); + self.registration(&mgr, ¶ms, true); Ok(Value::Null) } "client/unregisterCapability" => { - self.registration(¶ms, false); + self.registration(&mgr, ¶ms, false); Ok(Value::Null) } "workspace/applyEdit" => self.apply_edit(&mgr, params), diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index 252b66bf3..40665fc34 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -12,6 +12,7 @@ mod clang_tidy; mod convert; mod external_project; mod file_ops; +mod file_watch; mod handler; mod inspect; pub mod jdtls; @@ -160,6 +161,9 @@ impl DocSnap { } } +/// Receives the folders outside the project that servers want watched. +pub type WatchRootsFn = dyn Fn(Vec) + Send + Sync; + pub struct LspManager { me: Weak, project_root: PathBuf, @@ -177,6 +181,7 @@ pub struct LspManager { servers_hook: RwLock>>, candidates_hook: RwLock>>, excludes_hook: RwLock>>, + watch_roots_hook: RwLock>>, shutting_down: AtomicBool, execution_allowed: AtomicBool, java_usages_warned: AtomicBool, @@ -336,6 +341,7 @@ impl LspManager { servers_hook: RwLock::new(None), candidates_hook: RwLock::new(None), excludes_hook: RwLock::new(None), + watch_roots_hook: RwLock::new(None), shutting_down: AtomicBool::new(false), execution_allowed: AtomicBool::new(true), java_usages_warned: AtomicBool::new(false), @@ -587,6 +593,9 @@ impl LspManager { return; } }; + let run = + Running { generation, peer: peer.clone(), caps: Arc::new(RwLock::new(caps)), watchers: Default::default() }; + server.set_initializing(Some(run.clone())); if let Err(e) = peer.notify("initialized", json!({})) { tracing::warn!("{}: {e}", server.name); } @@ -598,7 +607,6 @@ impl LspManager { { tracing::debug!("{}: didChangeConfiguration: {e}", server.name); } - let run = Running { generation, peer: peer.clone(), caps: Arc::new(RwLock::new(caps)) }; self.refresh_external_project(&server, &run).await; @@ -607,6 +615,7 @@ impl LspManager { let mut docs = self.docs.lock(); if !server.mark_ready(generation, run.clone()) { drop(docs); + server.set_initializing(None); peer.kill(); return; } @@ -644,6 +653,7 @@ impl LspManager { None => return, // superseded instance }; self.clear_server_state(&server); + self.file_watch_roots_changed(); if intentional { server.announce_down(); return; @@ -1973,29 +1983,78 @@ impl LspManager { } } - /// Files that changed on disk, outside the editor too (a checkout, another tool): each running - /// server hears of those of its languages under its root through `workspace/didChangeWatchedFiles`. - /// Without it jdtls's incremental build never sees them, so a launch runs stale classes. - pub fn watched_files_changed(&self, created: &[PathBuf], modified: &[PathBuf], removed: &[PathBuf]) { + /// Files that changed on disk, outside the editor too (a checkout, another tool), sent to + /// running servers through `workspace/didChangeWatchedFiles`. A server that registered file + /// watchers hears of what they match, including `excluded_dirs` (folders in excluded or + /// ignored trees whose entries came or went, reported as created): a package installed + /// into `.venv` or a `go.mod` rewritten by `go get` is picked up without a restart. Other + /// servers hear of their languages' files under their root; without it jdtls's incremental + /// build never sees them, so a launch runs stale classes. + pub fn watched_files_changed( + &self, + created: &[PathBuf], + modified: &[PathBuf], + removed: &[PathBuf], + excluded_dirs: &[PathBuf], + ) { + use file_watch::Change; let servers: Vec> = self.servers.lock().clone(); for s in servers { let Some(run) = s.running() else { continue }; - let languages = s.languages(); - let changes: Vec = [(created, 1), (modified, 2), (removed, 3)] + let all = [(created, Change::Created), (modified, Change::Changed), (removed, Change::Deleted)] .into_iter() - .flat_map(|(paths, kind)| paths.iter().map(move |p| (p, kind))) - .filter(|(p, _)| p.starts_with(&s.root) && languages.contains(&self.languages.detect(p, None))) - .map(|(p, kind)| json!({ "uri": uri::path_to_uri(p), "type": kind })) - .collect(); + .flat_map(|(paths, change)| paths.iter().map(move |p| (p, change))); + let watchers = run.watchers.read(); + let changes: Vec<(&PathBuf, Change)> = if watchers.is_empty() { + let languages = s.languages(); + all.filter(|(p, _)| p.starts_with(&s.root) && languages.contains(&self.languages.detect(p, None))) + .collect() + } else { + all.chain(excluded_dirs.iter().map(|p| (p, Change::Created))) + .filter(|(p, change)| watchers.wants(&s.root, p, *change)) + .collect() + }; + drop(watchers); if changes.is_empty() { continue; } + let changes: Vec = + changes.iter().map(|(p, c)| json!({ "uri": uri::path_to_uri(p), "type": *c as u8 })).collect(); if let Err(e) = run.peer.notify("workspace/didChangeWatchedFiles", json!({ "changes": changes })) { tracing::debug!("{}: didChangeWatchedFiles: {e}", s.name); } } } + /// Called with the folders outside the project that servers registered file watchers + /// for (a virtualenv elsewhere, a path dependency), whenever they change. Changes + /// below them go to [`Self::watched_files_changed`]. + pub fn set_file_watch_roots_hook(&self, hook: Box) { + *self.watch_roots_hook.write() = Some(Arc::from(hook)); + self.file_watch_roots_changed(); + } + + pub(crate) fn file_watch_roots_changed(&self) { + let Some(hook) = self.watch_roots_hook.read().clone() else { return }; + let servers: Vec> = self.servers.lock().clone(); + let mut roots: Vec = Vec::new(); + for run in servers.iter().filter_map(|s| s.instance()) { + roots.extend( + run.watchers.read().bases().filter(|b| !b.starts_with(&self.project_root)).map(Path::to_path_buf), + ); + } + roots.sort(); + roots.dedup(); + // Sorted, so a folder's subfolders follow it. + let mut outer: Vec = Vec::new(); + for r in roots { + if !outer.last().is_some_and(|o| r.starts_with(o)) { + outer.push(r); + } + } + hook(outer); + } + /// Checks initialized capabilities, including dynamic registrations, before choosing a formatter. pub async fn can_format(&self, doc: DocId) -> IdeResult { let snap = self.snapshot(doc)?; diff --git a/crates/ide-lsp/src/server.rs b/crates/ide-lsp/src/server.rs index f15a893e6..76f08cc67 100644 --- a/crates/ide-lsp/src/server.rs +++ b/crates/ide-lsp/src/server.rs @@ -27,6 +27,8 @@ pub struct Running { pub generation: u64, pub peer: Peer, pub caps: Arc>, + /// `workspace/didChangeWatchedFiles` registrations. + pub watchers: Arc>, } pub enum Conn { @@ -82,6 +84,9 @@ pub struct Server { pub resolved_binary: RwLock>, languages: Mutex>, state: Mutex, + /// The instance being initialized, from `initialized` until ready: registrations + /// sent right after `initialized` must not be lost. + initializing: Mutex>, /// Fired when the instance being started is abandoned (stop / restart while starting). start_cancel: Mutex, phase: watch::Sender, @@ -118,6 +123,7 @@ impl Server { resolved_binary: RwLock::new(None), languages: Mutex::new(vec![language]), state: Mutex::new(State { generation: 0, conn: Conn::Idle }), + initializing: Mutex::new(None), start_cancel: Mutex::new(CancellationToken::new()), phase: watch::channel(Phase::Down).0, status: Mutex::new((ServerStatus::Stopped, None)), @@ -210,6 +216,8 @@ impl Server { return false; } st.conn = Conn::Ready(run); + drop(st); + self.set_initializing(None); true } @@ -234,6 +242,20 @@ impl Server { } } + /// The instance told `initialized`, ready or not. + pub fn instance(&self) -> Option { + self.running().or_else(|| self.initializing.lock().clone()) + } + + /// Generation `generation` once it was told `initialized`, ready or not. + pub fn initialized_gen(&self, generation: u64) -> Option { + self.instance().filter(|r| r.generation == generation) + } + + pub fn set_initializing(&self, run: Option) { + *self.initializing.lock() = run; + } + /// The running instance if it is still generation `generation`. pub fn running_gen(&self, generation: u64) -> Option { self.running().filter(|r| r.generation == generation) diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index 485fae03c..324876666 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -419,6 +419,7 @@ async fn files_changed_on_disk_reach_the_servers_of_their_language() { &[created.clone(), other], &[modified.clone(), outside], std::slice::from_ref(&removed), + &[h.root.join(".venv/lib/site-packages")], ); eventually(|| !s.notified("workspace/didChangeWatchedFiles").is_empty()).await; assert_eq!( @@ -430,12 +431,81 @@ async fn files_changed_on_disk_reach_the_servers_of_their_language() { ]) ); // Nothing of its languages: no notification. - h.mgr.watched_files_changed(&[h.root.join("b.txt")], &[], &[]); + h.mgr.watched_files_changed(&[h.root.join("b.txt")], &[], &[], &[]); tokio::time::sleep(std::time::Duration::from_millis(100)).await; assert_eq!(s.notified("workspace/didChangeWatchedFiles").len(), 1); h.mgr.shutdown_all().await; } +/// A server that registers file watchers hears of what they match, inside excluded folders +/// too (`uv add` into `.venv`, `go get` rewriting `go.mod`); folders outside the project +/// that relative patterns name are reported for watching; unregistered, it falls back to +/// its languages' files. +#[tokio::test(flavor = "multi_thread")] +async fn registered_file_watchers_choose_the_changes_a_server_hears_of() { + let h = harness(&[("mock", incremental_caps())]); + let roots = Arc::new(Mutex::new(Vec::>::new())); + { + let roots = roots.clone(); + h.mgr.set_file_watch_roots_hook(Box::new(move |r| roots.lock().push(r))); + } + h.open("a.mk", "x"); + h.wait_status("mock", ServerStatus::Ready).await; + let s = h.launcher.state("mock"); + let outside = tempfile::tempdir().unwrap(); + let outside = dunce::canonicalize(outside.path()).unwrap(); + let external = outside.join("venv/lib/site-packages"); + s.peer() + .request::<_, Value>( + "client/registerCapability", + json!({ "registrations": [{ "id": "w", "method": "workspace/didChangeWatchedFiles", "registerOptions": { "watchers": [ + { "globPattern": "**/*.{mk,mod}" }, + { "globPattern": "**/pyrightconfig.json", "kind": 2 }, + { "globPattern": "**/site-packages/**" }, + { "globPattern": { "baseUri": uri_of(&external), "pattern": "**" } }, + { "globPattern": { "baseUri": uri_of(&h.root.join("sub")), "pattern": "**" } }, + ] } }] }), + None, + ) + .await + .unwrap(); + assert_eq!(roots.lock().last(), Some(&vec![external.clone()]), "only folders outside the project"); + + let sp = h.root.join(".venv/lib/python3.14/site-packages"); + let notified = || s.notified("workspace/didChangeWatchedFiles"); + h.mgr.watched_files_changed( + &[h.root.join("go.mod"), h.root.join("notes.txt"), h.root.join("pyrightconfig.json")], + &[h.root.join("pyrightconfig.json"), external.join("idna/core.py")], + &[h.root.join("old.mk")], + &[sp.join("idna"), h.root.join("node_modules/x")], + ); + eventually(|| !notified().is_empty()).await; + assert_eq!( + notified()[0]["changes"], + json!([ + { "uri": uri_of(&h.root.join("go.mod")), "type": 1 }, + { "uri": uri_of(&h.root.join("pyrightconfig.json")), "type": 2 }, + { "uri": uri_of(&external.join("idna/core.py")), "type": 2 }, + { "uri": uri_of(&h.root.join("old.mk")), "type": 3 }, + { "uri": uri_of(&sp.join("idna")), "type": 1 }, + ]) + ); + + s.peer() + .request::<_, Value>( + "client/unregisterCapability", + json!({ "unregisterations": [{ "id": "w", "method": "workspace/didChangeWatchedFiles" }] }), + None, + ) + .await + .unwrap(); + assert_eq!(roots.lock().last(), Some(&vec![])); + h.mgr.watched_files_changed(&[h.root.join("go.mod"), h.root.join("b.mk")], &[], &[], &[sp.join("idna")]); + eventually(|| notified().len() == 2).await; + assert_eq!(notified()[1]["changes"], json!([{ "uri": uri_of(&h.root.join("b.mk")), "type": 1 }])); + h.mgr.shutdown_all().await; +} + #[tokio::test(flavor = "multi_thread")] async fn initialize_and_incremental_sync() { let h = harness(&[("mock", incremental_caps())]);