From 2cd16a723def52bc8542d157e800f0ad528953d5 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 4 Nov 2026 20:44:24 +0800 Subject: [PATCH] fix(lsp): let language servers register the files they watch (#1223) * fix(lsp): resolve newly installed Python packages without a restart Packages installed into an excluded .venv (uv add, uv pip install) never reached the Python language servers: the watcher reports changes in excluded folders only as excluded_dirs, and didChangeWatchedFiles forwarded nothing but the server's own source files. basedpyright kept its failed import resolution until restarted. Language servers get a watched-files list of globs. Files matching it, and excluded folders matching it, are forwarded too; the Python servers watch their configuration and site-packages. * fix(lsp): let servers register the files they watch Clients now support dynamic registration of workspace/didChangeWatchedFiles, with relative patterns. Changes on disk go to each server as its registered watchers match them, including folders in excluded trees (a package installed into .venv, go.mod rewritten by go get). Folders outside the project that relative patterns name (a virtualenv elsewhere, a path dependency) get their own watchers. Servers that register nothing keep hearing of their languages' files. Registrations sent right after initialized are no longer dropped while the server is still being marked ready. Replaces the per-server watched-files list. --- crates/ide-host/src/project.rs | 39 +++++++- crates/ide-lsp/src/caps.rs | 2 +- crates/ide-lsp/src/file_watch.rs | 150 ++++++++++++++++++++++++++++ crates/ide-lsp/src/handler.rs | 24 ++++- crates/ide-lsp/src/lib.rs | 81 +++++++++++++-- crates/ide-lsp/src/server.rs | 22 ++++ crates/ide-lsp/tests/mock_server.rs | 72 ++++++++++++- 7 files changed, 371 insertions(+), 19 deletions(-) create mode 100644 crates/ide-lsp/src/file_watch.rs 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())]);