Skip to main content

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}