Mountain/Environment/FileWatcherIgnore.rs
1
2//! Server-side ignore filter for file-watcher events.
3//!
4//! Events that match the ignore list never cross the
5//! Mountain→Cocoon gRPC notification boundary. Stops the cargo /
6//! pnpm / git-object churn from drowning the editor in dead
7//! `$fileWatcher:event` traffic.
8//!
9//! Why server-side: the watcher root + glob pattern coming from
10//! extensions (`**/*.md`, `**/package.json`, `**/*.ts`) does not
11//! exclude build directories. Even with the per-event glob filter
12//! a single `cargo check` produces thousands of `.rcgu.o` create /
13//! delete events that all match `**/*.md`'s sibling traversal -
14//! every one of them triggers a notification, which Cocoon then
15//! tries to stat back through Mountain (returning a 404 because
16//! cargo has already deleted the file). One side-effect per call.
17//!
18//! The list is deliberately conservative: only paths whose
19//! contents are never meaningful to user-facing editor state are
20//! excluded. The git work tree (`.git/index`, `.git/HEAD`,
21//! `.git/refs/`) is NOT excluded because the Git extension relies
22//! on those events to refresh branch / staged-files state.
23//!
24//! Override via `WatchIgnore` env var (colon-separated path
25//! segments). Empty value disables the filter entirely. Useful
26//! when debugging an extension that legitimately probes inside a
27//! `Target/` tree.
28
29use std::sync::OnceLock;
30
31/// Default ignore segments. A match anywhere in the path's
32/// component list (case-sensitive) suppresses the event. Tuned
33/// against the segments most likely to host high-frequency build
34/// churn without containing user-edited source.
35const DEFAULT_IGNORE_SEGMENTS:&[&str] = &[
36 // Rust
37 "target",
38 // Node
39 "node_modules",
40 // Git object database. Refs/HEAD/index changes still fire
41 // because they're addressed via separate parent segments.
42 ".git/objects",
43 ".git/lfs",
44 // macOS metadata
45 ".DS_Store",
46 // Build outputs that mirror source one-to-one - watching the
47 // output adds no signal the source watcher doesn't already
48 // give us.
49 "dist",
50 ".next",
51 ".turbo",
52 ".astro",
53 ".parcel-cache",
54 ".vite",
55 ".cache",
56 // Test snapshots / coverage dumps - regenerated by CI, never
57 // hand-edited.
58 "coverage",
59 "__snapshots__",
60];
61
62/// Lazily-resolved active ignore list. `WatchIgnore` overrides
63/// the default; an empty string disables filtering entirely.
64fn IgnoreSegments() -> &'static Vec<String> {
65 static CACHE:OnceLock<Vec<String>> = OnceLock::new();
66
67 CACHE.get_or_init(|| {
68 match std::env::var("WatchIgnore") {
69 Ok(Raw) if Raw.is_empty() => Vec::new(),
70 Ok(Raw) => Raw.split(':').map(|S| S.trim().to_string()).filter(|S| !S.is_empty()).collect(),
71 Err(_) => DEFAULT_IGNORE_SEGMENTS.iter().map(|S| (*S).to_string()).collect(),
72 }
73 })
74}
75
76/// `true` when the path should be silently dropped before any
77/// IPC traffic is emitted. Implementation is a single linear
78/// scan over the string - tested against `git/.git/objects/...`,
79/// `Target/debug/build/.../foo.rcgu.o`, and
80/// `node_modules/.bin/...`. Worst case is on every event so we
81/// keep this allocation-free.
82pub fn Fn(Path:&str) -> bool {
83 let Segments = IgnoreSegments();
84
85 if Segments.is_empty() {
86 return false;
87 }
88
89 for Needle in Segments {
90 if Path_ContainsSegment(Path, Needle) {
91 return true;
92 }
93 }
94
95 false
96}
97
98/// Match a `/seg1/seg2` substring as a complete path segment so
99/// `target` matches `/target/...` but not `/get-target-info/...`.
100/// Slashes are platform-agnostic - matches both `/` and `\`. A
101/// needle that itself contains a slash (`".git/objects"`) is
102/// matched as a literal substring with leading-slash gating.
103fn Path_ContainsSegment(Path:&str, Needle:&str) -> bool {
104 if Needle.contains('/') || Needle.contains('\\') {
105 // Composite needle - look for it as a substring with at
106 // least one path-separator immediately before it (or at
107 // the start of the path).
108 let Bytes = Path.as_bytes();
109
110 let NeedleBytes = Needle.as_bytes();
111
112 let mut Start = 0;
113
114 while let Some(Hit) = Path[Start..].find(Needle) {
115 let Index = Start + Hit;
116
117 let PreviousIsSep = Index == 0 || matches!(Bytes[Index - 1], b'/' | b'\\');
118
119 let NextIsSepOrEnd = match Bytes.get(Index + NeedleBytes.len()) {
120 None => true,
121
122 Some(b) => matches!(*b, b'/' | b'\\'),
123 };
124
125 if PreviousIsSep && NextIsSepOrEnd {
126 return true;
127 }
128
129 Start = Index + 1;
130 }
131
132 return false;
133 }
134
135 Path.split(|C| C == '/' || C == '\\').any(|Segment| Segment == Needle)
136}
137
138#[cfg(test)]
139mod tests {
140
141 use super::*;
142
143 #[test]
144 fn TargetSegmentMatchesCargoBuildPath() {
145 assert!(ShouldIgnore(
146 "/Volumes/CORSAIR/Land/Target/debug/build/foo-abc/build_script.rcgu.o"
147 ));
148 }
149
150 #[test]
151 fn TargetSegmentDoesNotMatchUnrelatedSubstring() {
152 // `Target` only excluded at top level (case-sensitive on
153 // the default list); a directory called `target-info`
154 // should not be swept up.
155 assert!(!ShouldIgnore("/Volumes/CORSAIR/Land/target-info/source.ts"));
156 }
157
158 #[test]
159 fn NodeModulesMatches() {
160 assert!(ShouldIgnore("/repo/node_modules/.bin/eslint"));
161 }
162
163 #[test]
164 fn GitObjectsCompositeMatches() {
165 assert!(ShouldIgnore("/repo/.git/objects/ab/cdef1234"));
166 }
167
168 #[test]
169 fn GitIndexNotIgnored() {
170 // The Git extension needs index / HEAD events; the ignore
171 // list must not swallow those.
172 assert!(!ShouldIgnore("/repo/.git/index"));
173
174 assert!(!ShouldIgnore("/repo/.git/HEAD"));
175 }
176
177 #[test]
178 fn UserSourceFileNotIgnored() {
179 assert!(!ShouldIgnore("/repo/Source/Application/Foo.ts"));
180 }
181}