Skip to main content

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}