lattice_lsp/file_watcher.rs
1//! File-watcher subscription compilation + matching (4.4.l).
2//!
3//! Servers dynamically register `workspace/didChangeWatchedFiles`
4//! via `client/registerCapability` (the matrix entry for that
5//! method in `docs/dev/notes/lsp-features.md`). Each registration
6//! carries a `DidChangeWatchedFilesRegistrationOptions` containing
7//! a list of `FileSystemWatcher { glob_pattern, kind }`.
8//!
9//! This module is the pure piece of the file-watcher pipeline:
10//! it walks the dynamic registry, compiles each registration's
11//! patterns into a `globset::GlobSet`, and produces a per-server
12//! [`WatcherSubscriptions`] snapshot. The fs-event source itself
13//! (the `notify` crate driver, debounce timer, fan-out) lives in
14//! `lattice-ui-tui::app::lsp_watcher`; that module imports
15//! [`WatcherSubscriptions`] and asks "does this absolute path
16//! match any registered glob, and if so what `FileChangeType` do
17//! we synthesise?".
18//!
19//! Keeping the pure parts here means:
20//! 1. The matching logic is testable in isolation (no fs
21//! integration in unit tests; no `notify` runtime).
22//! 2. Future renderers (GPU, web) reuse the same compilation
23//! path -- only the event source changes.
24//! 3. Plugins that want to participate in watched-file dispatch
25//! (post-1.0 WIT bridge) see one shape, not a tui-only one.
26//!
27//! ## Glob semantics
28//!
29//! LSP 3.17 `GlobPattern` is either:
30//! - a plain string (workspace-relative globs like `**/*.rs`), or
31//! - a `RelativePattern { base_uri, pattern }` where `base_uri`
32//! anchors the pattern to a specific workspace folder or URI.
33//!
34//! We normalise both shapes to `(base, pattern)` -- the pure
35//! string case anchors to the server's workspace root supplied
36//! at compile time; relative patterns honour their declared
37//! base. `WatchKind` defaults to Create|Change|Delete (= 7)
38//! when omitted, per spec.
39//!
40//! ## Path conventions
41//!
42//! Glob matching runs against the path *relative to the
43//! subscription's base*. Absolute paths are normalised by
44//! stripping the base prefix before matching; paths outside the
45//! base never match a relative-anchored pattern. This matches
46//! VSCode's behaviour, which is what most servers exercise
47//! their globs against.
48
49use std::path::Path;
50use std::sync::Arc;
51
52use globset::{Glob, GlobSet, GlobSetBuilder};
53use lsp_types::{
54 DidChangeWatchedFilesRegistrationOptions, FileChangeType, FileSystemWatcher, GlobPattern,
55 WatchKind,
56};
57
58use crate::Capabilities;
59use crate::dynamic_registration::DynamicRegistration;
60
61/// LSP's default `WatchKind` when the registration omits it:
62/// `Create | Change | Delete = 7`. Lifted from the spec text on
63/// `FileSystemWatcher.kind`.
64const DEFAULT_WATCH_KIND: WatchKind = WatchKind::all();
65
66/// One compiled file-watcher subscription. Multiple `FileSystemWatcher`
67/// entries with the same effective base get folded into one
68/// [`WatcherSubscriptions`] per server; this type is the
69/// internal bookkeeping for one (base, pattern, kind) tuple.
70#[derive(Debug, Clone)]
71struct CompiledWatcher {
72 /// Workspace-relative root the pattern matches against. Empty
73 /// for plain-string globs that don't carry a `RelativePattern`
74 /// base (we anchor those to the supplied `workspace_root` at
75 /// compile time).
76 base: std::path::PathBuf,
77 /// The original pattern source (kept for diagnostics +
78 /// equality / hash).
79 pattern: String,
80 /// Bitmask of fs events the server cares about. Filtered at
81 /// match time so a `Change`-only registration ignores
82 /// `Create`s on its paths.
83 kind: WatchKind,
84}
85
86/// Snapshot of every active file-watcher registration for one
87/// server. Built by [`compile_for_server`] from the dynamic
88/// registry; consumed by the host's notify-driven dispatcher.
89///
90/// Each match request walks the per-watcher kind bitmask; the
91/// compiled `GlobSet` runs N patterns in one pass.
92#[derive(Debug, Clone)]
93pub struct WatcherSubscriptions {
94 /// Server-stable id (e.g. `"rust-analyzer"`). Lets the
95 /// dispatcher route the `FileEvent` batch back to the right
96 /// `ServerHandle`. Cheap clone via `Arc<str>`.
97 pub server_id: Arc<str>,
98 /// One compiled glob per (base, pattern) tuple. Indices into
99 /// `compiled` match indices into `watchers`.
100 watchers: Vec<CompiledWatcher>,
101 /// Pre-compiled aho-corasick-backed multi-pattern matcher.
102 /// `globset.matches(path)` returns the indices of watchers
103 /// that fire; we then filter by kind.
104 globset: GlobSet,
105 /// True when no watchers were registered; the dispatcher uses
106 /// this to short-circuit (a server with no watchers needs no
107 /// fan-out work at all).
108 empty: bool,
109}
110
111impl WatcherSubscriptions {
112 /// True when this server has no active watcher registrations.
113 /// Cheap pre-check the dispatcher runs before walking the
114 /// globset on every fs event.
115 pub fn is_empty(&self) -> bool {
116 self.empty
117 }
118
119 /// Number of compiled watcher entries. Each entry was one
120 /// `FileSystemWatcher` in a `DidChangeWatchedFilesRegistrationOptions`
121 /// batch; one registration with two watchers produces two
122 /// entries here.
123 pub fn len(&self) -> usize {
124 self.watchers.len()
125 }
126
127 /// Match an absolute path against the subscription set.
128 /// Returns the list of [`lsp_types::FileEvent`]s the server
129 /// would expect for the given `change` kind -- empty when no
130 /// watcher matches OR every matching watcher has the change
131 /// kind masked out.
132 ///
133 /// Per LSP spec the event uses the server's URI shape; the
134 /// caller converts `path` to a `file://` URI before emitting
135 /// the notification.
136 pub fn matches(&self, absolute_path: &Path, change: FileChangeType) -> Vec<usize> {
137 if self.empty {
138 return Vec::new();
139 }
140 let want = file_change_to_watch_kind(change);
141 let mut out: Vec<usize> = Vec::new();
142 for (idx, w) in self.watchers.iter().enumerate() {
143 // Path must be inside the watcher's declared base for a
144 // relative pattern to make sense. For root-anchored
145 // patterns (`base == workspace_root`) the strip succeeds
146 // for every path under the workspace.
147 let Some(rel) = absolute_path.strip_prefix(&w.base).ok() else {
148 continue;
149 };
150 if !w.kind.contains(want) {
151 continue;
152 }
153 // We can't run the per-watcher pattern through
154 // `globset` (the set is built across watchers); the
155 // set itself does the multi-pattern check below.
156 // We use index-aligned matching: `self.globset` was
157 // built with each `watchers[i].pattern` at index `i`,
158 // so a set-hit at index `i` corresponds to this
159 // watcher.
160 let _ = rel; // see globset.matches() below
161 out.push(idx);
162 }
163 // Now run the actual glob match in one pass. `matches`
164 // returns every set-pattern index that fires for the
165 // relative path -- intersect with `out` (which was
166 // pre-filtered for base + kind) to get the final list.
167 let mut final_indices: Vec<usize> = Vec::new();
168 for idx in out {
169 let w = &self.watchers[idx];
170 if let Ok(rel) = absolute_path.strip_prefix(&w.base) {
171 let matched = self.globset.matches(rel);
172 if matched.contains(&idx) {
173 final_indices.push(idx);
174 }
175 }
176 }
177 final_indices
178 }
179
180 /// Identity fingerprint -- a hash of every (base, pattern,
181 /// kind) tuple. The dispatcher caches this per server so a
182 /// no-op tick (registry unchanged) skips the notify-watcher
183 /// rebuild.
184 pub fn fingerprint(&self) -> u64 {
185 use std::collections::hash_map::DefaultHasher;
186 use std::hash::{Hash, Hasher};
187 let mut hasher = DefaultHasher::new();
188 for w in &self.watchers {
189 w.base.hash(&mut hasher);
190 w.pattern.hash(&mut hasher);
191 w.kind.bits().hash(&mut hasher);
192 }
193 hasher.finish()
194 }
195
196 /// Borrow each watcher's declared base path. Used by the
197 /// dispatcher to compute the union of paths the
198 /// `notify::RecommendedWatcher` must subscribe to.
199 pub fn base_paths(&self) -> impl Iterator<Item = &Path> + '_ {
200 self.watchers.iter().map(|w| w.base.as_path())
201 }
202}
203
204/// Translate one fs event kind into the `WatchKind` flag used
205/// by `FileSystemWatcher.kind`. The match table is the spec's
206/// trivial 1:1 mapping; lifted here so the dispatcher's filter
207/// logic doesn't sprinkle the cast through unrelated code.
208fn file_change_to_watch_kind(change: FileChangeType) -> WatchKind {
209 match change {
210 FileChangeType::CREATED => WatchKind::Create,
211 FileChangeType::CHANGED => WatchKind::Change,
212 FileChangeType::DELETED => WatchKind::Delete,
213 // lsp_types' `FileChangeType` is an open enum
214 // (`#[repr(transparent)] struct FileChangeType(i32)`) for
215 // forward-compat; any unrecognised value falls through to
216 // "interested in all kinds" since the server can't
217 // disambiguate further anyway. In practice the upstream
218 // values are exhaustive.
219 _ => WatchKind::all(),
220 }
221}
222
223/// Compile the dynamic-registration entries on `caps` for the
224/// given `server_id` into a [`WatcherSubscriptions`] snapshot.
225/// `workspace_root` is the absolute path the server was attached
226/// to; plain-string globs (no `RelativePattern` base) anchor here.
227///
228/// Malformed registrations (unparsable JSON, bad globs) are
229/// skipped with a logged warning -- the caller's `logger`
230/// receives one record per skip. Skipping beats failing: a
231/// server registering one bad watcher alongside ten good ones
232/// shouldn't lose the good ten.
233pub fn compile_for_server(
234 caps: &Capabilities,
235 server_id: Arc<str>,
236 workspace_root: &Path,
237) -> WatcherSubscriptions {
238 let mut watchers: Vec<CompiledWatcher> = Vec::new();
239 let mut builder = GlobSetBuilder::new();
240 for reg in caps
241 .dynamic
242 .registrations_for("workspace/didChangeWatchedFiles")
243 {
244 let parsed = parse_registration(reg);
245 match parsed {
246 Ok(items) => {
247 for (pattern, base, kind) in items {
248 let glob_pattern = if base == workspace_root {
249 // Workspace-anchored patterns match the
250 // workspace-relative path; the source
251 // string passes through unchanged.
252 pattern.clone()
253 } else {
254 // Same shape but the matcher runs against
255 // a path relative to the registration's
256 // own base, not the workspace.
257 pattern.clone()
258 };
259 let glob = match Glob::new(&glob_pattern) {
260 Ok(g) => g,
261 Err(_) => {
262 // Bad pattern; skip this watcher.
263 // Other watchers in the same
264 // registration still compile.
265 continue;
266 }
267 };
268 builder.add(glob);
269 watchers.push(CompiledWatcher {
270 base,
271 pattern,
272 kind,
273 });
274 }
275 }
276 Err(_) => {
277 // The whole registration's register_options blob
278 // was malformed -- drop it and move on.
279 continue;
280 }
281 }
282 }
283 let globset = builder.build().unwrap_or_else(|_| GlobSet::empty());
284 let empty = watchers.is_empty();
285 WatcherSubscriptions {
286 server_id,
287 watchers,
288 globset,
289 empty,
290 }
291}
292
293/// Parse one [`DynamicRegistration`] entry for
294/// `workspace/didChangeWatchedFiles` into a list of
295/// `(pattern, base, kind)` tuples. Each `FileSystemWatcher` in
296/// the registration becomes one tuple.
297///
298/// Returns `Err(())` when the registration's options blob can't
299/// deserialise as `DidChangeWatchedFilesRegistrationOptions`;
300/// the caller logs + skips.
301fn parse_registration(
302 reg: &DynamicRegistration,
303) -> Result<Vec<(String, std::path::PathBuf, WatchKind)>, ()> {
304 let Some(opts) = reg.register_options.as_ref() else {
305 // Registration without options -- spec-permitted but
306 // semantically empty (no glob to match). Treat as
307 // zero watchers; not an error.
308 return Ok(Vec::new());
309 };
310 let parsed: DidChangeWatchedFilesRegistrationOptions =
311 serde_json::from_value(opts.clone()).map_err(|_| ())?;
312 let mut out: Vec<(String, std::path::PathBuf, WatchKind)> =
313 Vec::with_capacity(parsed.watchers.len());
314 for w in parsed.watchers {
315 let (pattern, base) = decompose_glob_pattern(&w);
316 let kind = w.kind.unwrap_or(DEFAULT_WATCH_KIND);
317 out.push((pattern, base, kind));
318 }
319 Ok(out)
320}
321
322/// Split a [`FileSystemWatcher`] into `(pattern, base_path)`.
323/// Plain-string globs produce an empty base path (the caller
324/// substitutes the workspace root); relative patterns honour
325/// their declared base URI.
326fn decompose_glob_pattern(w: &FileSystemWatcher) -> (String, std::path::PathBuf) {
327 match &w.glob_pattern {
328 GlobPattern::String(s) => (s.clone(), std::path::PathBuf::new()),
329 GlobPattern::Relative(rel) => {
330 let base_uri = match &rel.base_uri {
331 lsp_types::OneOf::Left(folder) => folder.uri.clone(),
332 lsp_types::OneOf::Right(uri) => uri.clone(),
333 };
334 let base = crate::actor::uri_to_path(&base_uri).unwrap_or_default();
335 (rel.pattern.clone(), base)
336 }
337 }
338}
339
340/// Build a `WatcherSubscriptions` whose plain-string globs all
341/// anchor to `workspace_root`. The bare [`compile_for_server`]
342/// captures the path verbatim because some callers (tests,
343/// future per-folder workspace roots) want a custom anchor.
344/// Public entry-point most call sites use.
345pub fn compile_with_workspace_root(
346 caps: &Capabilities,
347 server_id: Arc<str>,
348 workspace_root: &Path,
349) -> WatcherSubscriptions {
350 let mut subs = compile_for_server(caps, Arc::clone(&server_id), workspace_root);
351 // Substitute the empty base entries with the workspace root
352 // so `matches()` can strip a prefix and the relative path
353 // exists. We do this in-place rather than during compile so
354 // the empty-base sentinel stays meaningful inside the parse
355 // function (kept distinct from "base is literally the
356 // workspace root").
357 for w in subs.watchers.iter_mut() {
358 if w.base.as_os_str().is_empty() {
359 w.base = workspace_root.to_path_buf();
360 }
361 }
362 subs
363}
364
365#[cfg(test)]
366mod tests {
367 use super::*;
368 use crate::DynamicRegistry;
369 use lsp_types::{ClientCapabilities, PositionEncodingKind, ServerCapabilities};
370 use serde_json::json;
371
372 fn caps_with(registrations: Vec<DynamicRegistration>) -> Arc<Capabilities> {
373 let mut dynamic = DynamicRegistry::new();
374 for r in registrations {
375 dynamic.register(r);
376 }
377 Arc::new(Capabilities {
378 client: ClientCapabilities::default(),
379 server: ServerCapabilities::default(),
380 position_encoding: PositionEncodingKind::UTF8,
381 dynamic,
382 })
383 }
384
385 fn rust_watcher_registration(id: &str, pattern: &str) -> DynamicRegistration {
386 DynamicRegistration {
387 id: id.into(),
388 method: "workspace/didChangeWatchedFiles".into(),
389 register_options: Some(json!({
390 "watchers": [{
391 "globPattern": pattern
392 }]
393 })),
394 }
395 }
396
397 #[test]
398 fn empty_registry_compiles_to_empty_subscriptions() {
399 let caps = caps_with(Vec::new());
400 let subs = compile_with_workspace_root(&caps, Arc::from("rust"), Path::new("/ws"));
401 assert!(subs.is_empty());
402 assert_eq!(subs.len(), 0);
403 assert!(
404 subs.matches(Path::new("/ws/src/main.rs"), FileChangeType::CHANGED)
405 .is_empty()
406 );
407 }
408
409 #[test]
410 fn workspace_anchored_glob_matches_change_event() {
411 let caps = caps_with(vec![rust_watcher_registration("rs-source", "**/*.rs")]);
412 let subs = compile_with_workspace_root(&caps, Arc::from("rust"), Path::new("/ws"));
413 assert!(!subs.is_empty());
414 assert_eq!(
415 subs.matches(Path::new("/ws/src/main.rs"), FileChangeType::CHANGED),
416 vec![0],
417 );
418 // Outside the workspace root -> no match.
419 assert!(
420 subs.matches(Path::new("/elsewhere/main.rs"), FileChangeType::CHANGED)
421 .is_empty()
422 );
423 // Wrong extension -> no match.
424 assert!(
425 subs.matches(Path::new("/ws/src/main.py"), FileChangeType::CHANGED)
426 .is_empty()
427 );
428 }
429
430 /// Default `WatchKind` (Create|Change|Delete = 7) when the
431 /// registration omits it. Every change kind matches.
432 #[test]
433 fn watcher_without_kind_matches_all_change_types() {
434 let caps = caps_with(vec![rust_watcher_registration("all", "**/*.rs")]);
435 let subs = compile_with_workspace_root(&caps, Arc::from("rust"), Path::new("/ws"));
436 for kind in [
437 FileChangeType::CREATED,
438 FileChangeType::CHANGED,
439 FileChangeType::DELETED,
440 ] {
441 assert_eq!(
442 subs.matches(Path::new("/ws/lib.rs"), kind),
443 vec![0],
444 "{kind:?} should match",
445 );
446 }
447 }
448
449 /// Explicit `kind = Change` registration filters out create
450 /// + delete events.
451 #[test]
452 fn explicit_change_only_kind_filters_create_and_delete() {
453 let reg = DynamicRegistration {
454 id: "change-only".into(),
455 method: "workspace/didChangeWatchedFiles".into(),
456 register_options: Some(json!({
457 "watchers": [{
458 "globPattern": "**/*.rs",
459 "kind": 2 // WatchKind::Change
460 }]
461 })),
462 };
463 let caps = caps_with(vec![reg]);
464 let subs = compile_with_workspace_root(&caps, Arc::from("rust"), Path::new("/ws"));
465 assert_eq!(
466 subs.matches(Path::new("/ws/lib.rs"), FileChangeType::CHANGED),
467 vec![0]
468 );
469 assert!(
470 subs.matches(Path::new("/ws/lib.rs"), FileChangeType::CREATED)
471 .is_empty()
472 );
473 assert!(
474 subs.matches(Path::new("/ws/lib.rs"), FileChangeType::DELETED)
475 .is_empty()
476 );
477 }
478
479 /// Multiple registrations from the same server fold into one
480 /// subscription set. Distinct patterns each get their own
481 /// index in the match result.
482 #[test]
483 fn multiple_registrations_fold_into_one_subscription_set() {
484 let caps = caps_with(vec![
485 rust_watcher_registration("rs", "**/*.rs"),
486 rust_watcher_registration("toml", "**/*.toml"),
487 ]);
488 let subs = compile_with_workspace_root(&caps, Arc::from("rust"), Path::new("/ws"));
489 assert_eq!(subs.len(), 2);
490 assert_eq!(
491 subs.matches(Path::new("/ws/lib.rs"), FileChangeType::CHANGED),
492 vec![0],
493 );
494 assert_eq!(
495 subs.matches(Path::new("/ws/Cargo.toml"), FileChangeType::CHANGED),
496 vec![1],
497 );
498 }
499
500 /// Malformed registration options drop the registration but
501 /// don't affect the rest.
502 #[test]
503 fn malformed_registration_options_skipped() {
504 let mut dynamic = DynamicRegistry::new();
505 dynamic.register(DynamicRegistration {
506 id: "bad".into(),
507 method: "workspace/didChangeWatchedFiles".into(),
508 register_options: Some(json!({ "not-a-watchers-field": 42 })),
509 });
510 dynamic.register(rust_watcher_registration("good", "**/*.rs"));
511 let caps = Arc::new(Capabilities {
512 client: ClientCapabilities::default(),
513 server: ServerCapabilities::default(),
514 position_encoding: PositionEncodingKind::UTF8,
515 dynamic,
516 });
517 let subs = compile_with_workspace_root(&caps, Arc::from("rust"), Path::new("/ws"));
518 assert_eq!(subs.len(), 1, "bad registration skipped, good one kept");
519 assert!(
520 !subs
521 .matches(Path::new("/ws/lib.rs"), FileChangeType::CHANGED)
522 .is_empty()
523 );
524 }
525
526 /// Bad glob syntax in a registration is skipped (logged at
527 /// the caller); other watchers in the same registration still
528 /// compile.
529 #[test]
530 fn bad_glob_in_registration_skipped() {
531 let reg = DynamicRegistration {
532 id: "mixed".into(),
533 method: "workspace/didChangeWatchedFiles".into(),
534 register_options: Some(json!({
535 "watchers": [
536 { "globPattern": "**/*.rs" },
537 { "globPattern": "[invalid" },
538 { "globPattern": "**/*.toml" }
539 ]
540 })),
541 };
542 let caps = caps_with(vec![reg]);
543 let subs = compile_with_workspace_root(&caps, Arc::from("rust"), Path::new("/ws"));
544 assert_eq!(subs.len(), 2, "two valid globs survive");
545 }
546
547 /// Fingerprint changes when watchers change, stays stable
548 /// otherwise. The dispatcher uses this to decide whether to
549 /// rebuild the notify watcher.
550 #[test]
551 fn fingerprint_is_stable_across_compiles_when_inputs_match() {
552 let caps = caps_with(vec![rust_watcher_registration("rs", "**/*.rs")]);
553 let a = compile_with_workspace_root(&caps, Arc::from("rust"), Path::new("/ws"));
554 let b = compile_with_workspace_root(&caps, Arc::from("rust"), Path::new("/ws"));
555 assert_eq!(a.fingerprint(), b.fingerprint());
556
557 let other = caps_with(vec![rust_watcher_registration("rs", "**/*.toml")]);
558 let c = compile_with_workspace_root(&other, Arc::from("rust"), Path::new("/ws"));
559 assert_ne!(a.fingerprint(), c.fingerprint());
560 }
561}