1use std::path::{Path, PathBuf};
2
3const INDEXABLE_EXTENSIONS: &[&str] = &[
4 "rs",
5 "py",
6 "js",
7 "mjs",
8 "ts",
9 "tsx",
10 "jsx",
11 "go",
12 "java",
13 "rb",
14 "c",
15 "cpp",
16 "cc",
17 "cxx",
18 "h",
19 "hpp",
20 "cs",
21 "kt",
22 "kts",
23 "scala",
24 "swift",
25 "php",
26 "sh",
27 "bash",
28 "yaml",
29 "yml",
30 "toml",
31 "json",
32 "xml",
33 "md",
34 "markdown",
35 "rst",
36 "adoc",
37 "sql",
38 "cmake",
39 "make",
40 "dockerfile",
41 "ini",
42 "properties",
43];
44
45const IGNORED_DIRECTORIES: &[&str] = &[
46 ".git",
47 "node_modules",
48 "target",
49 "__pycache__",
50 ".venv",
51 "venv",
52 ".env",
53 "build",
54 "dist",
55 ".tox",
56 ".mypy_cache",
57 ".pytest_cache",
58 ".idea",
59 ".vscode",
60 ".cache",
61];
62
63#[derive(Debug, Clone, Default)]
64pub struct WatcherEventFilter {
65 watched_root: Option<PathBuf>,
66 path_filters: Vec<String>,
67 language_filters: Vec<String>,
68}
69
70impl WatcherEventFilter {
71 pub fn new(
72 watched_root: PathBuf,
73 path_filters: Vec<String>,
74 language_filters: Vec<String>,
75 ) -> Self {
76 Self {
77 watched_root: Some(watched_root),
78 path_filters,
79 language_filters,
80 }
81 }
82
83 pub fn should_process_path(&self, path: &Path) -> bool {
84 let relative = match self.strip_root(path) {
85 Some(rel) => rel,
86 None => return false,
87 };
88
89 if self.is_in_ignored_directory(&relative) {
90 return false;
91 }
92
93 if !self.has_indexable_extension(&relative) {
94 return false;
95 }
96
97 if !self.path_filters.is_empty() && !self.path_matches_scope(&relative) {
98 return false;
99 }
100
101 if !self.language_filters.is_empty() && !self.language_matches_extension(&relative) {
102 return false;
103 }
104
105 true
106 }
107
108 fn strip_root(&self, path: &Path) -> Option<PathBuf> {
109 let root = self.watched_root.as_ref()?;
110 path.strip_prefix(root).ok().map(|p| p.to_path_buf())
111 }
112
113 fn is_in_ignored_directory(&self, relative: &Path) -> bool {
114 for component in relative.ancestors() {
115 if let Some(name) = component.file_name() {
116 if let Some(name_str) = name.to_str() {
117 if IGNORED_DIRECTORIES.contains(&name_str) {
118 return true;
119 }
120 }
121 }
122 }
123 false
124 }
125
126 fn has_indexable_extension(&self, relative: &Path) -> bool {
127 let ext = match relative.extension().and_then(|e| e.to_str()) {
128 Some(e) => e.to_lowercase(),
129 None => {
130 let name = relative.file_name().and_then(|n| n.to_str()).unwrap_or("");
131 return matches!(
132 name.to_lowercase().as_str(),
133 "dockerfile"
134 | "makefile"
135 | "cmakelists.txt"
136 | "cargo.toml"
137 | "package.json"
138 | "go.mod"
139 | "requirements.txt"
140 | "pipfile"
141 | "gemfile"
142 );
143 }
144 };
145 INDEXABLE_EXTENSIONS.contains(&ext.as_str())
146 }
147
148 fn path_matches_scope(&self, relative: &Path) -> bool {
149 let relative_str = relative.to_string_lossy();
150 self.path_filters
151 .iter()
152 .any(|f| relative_str.starts_with(f.as_str()) || relative_str.contains(f.as_str()))
153 }
154
155 fn language_matches_extension(&self, relative: &Path) -> bool {
156 let ext = match relative.extension().and_then(|e| e.to_str()) {
157 Some(e) => e.to_lowercase(),
158 None => return true,
159 };
160 self.language_filters
161 .iter()
162 .any(|lang| extension_matches_language(&ext, lang))
163 }
164}
165
166fn extension_matches_language(ext: &str, language: &str) -> bool {
167 match language.to_ascii_lowercase().as_str() {
168 "rust" => ext == "rs",
169 "python" | "py" => ext == "py" || ext == "pyw",
170 "javascript" | "js" => ext == "js" || ext == "jsx" || ext == "mjs" || ext == "cjs",
171 "jsx" => ext == "jsx",
172 "typescript" | "ts" => ext == "ts" || ext == "mts" || ext == "cts",
173 "tsx" => ext == "tsx",
174 "go" => ext == "go",
175 "java" => ext == "java",
176 "c" => ext == "c" || ext == "h",
177 "cpp" | "c++" => {
178 ext == "cpp"
179 || ext == "hpp"
180 || ext == "cc"
181 || ext == "cxx"
182 || ext == "hh"
183 || ext == "hxx"
184 }
185 "ruby" | "rb" => ext == "rb",
186 "kotlin" | "kt" => ext == "kt" || ext == "kts",
187 "scala" => ext == "scala",
188 "swift" => ext == "swift",
189 "csharp" | "c#" => ext == "cs",
190 "php" => ext == "php",
191 "bash" | "shell" | "sh" => ext == "sh" || ext == "bash" || ext == "bats",
192 "json" => ext == "json",
193 "yaml" | "yml" => ext == "yaml" || ext == "yml",
194 "toml" => ext == "toml",
195 "sql" => ext == "sql",
196 "markdown" | "md" => ext == "md" || ext == "markdown",
197 "xml" => ext == "xml" || ext == "xsd" || ext == "xsl" || ext == "xslt",
198 "ini" => ext == "ini" || ext == "conf" || ext == "cfg",
199 "properties" => ext == "properties",
200 _ => false,
201 }
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207 use std::path::PathBuf;
208
209 fn root() -> PathBuf {
210 PathBuf::from("/project")
211 }
212
213 #[test]
214 fn allows_rust_source_file() {
215 let filter = WatcherEventFilter::new(root(), vec![], vec![]);
216 assert!(filter.should_process_path(&root().join("src/main.rs")));
217 }
218
219 #[test]
220 fn rejects_git_directory() {
221 let filter = WatcherEventFilter::new(root(), vec![], vec![]);
222 assert!(!filter.should_process_path(&root().join(".git/HEAD")));
223 }
224
225 #[test]
226 fn rejects_node_modules() {
227 let filter = WatcherEventFilter::new(root(), vec![], vec![]);
228 assert!(!filter.should_process_path(&root().join("node_modules/foo/index.js")));
229 }
230
231 #[test]
232 fn rejects_binary_file() {
233 let filter = WatcherEventFilter::new(root(), vec![], vec![]);
234 assert!(!filter.should_process_path(&root().join("image.png")));
235 }
236
237 #[test]
238 fn allows_dockerfile_without_extension() {
239 let filter = WatcherEventFilter::new(root(), vec![], vec![]);
240 assert!(filter.should_process_path(&root().join("Dockerfile")));
241 }
242
243 #[test]
244 fn path_filter_accepts_matching_prefix() {
245 let filter = WatcherEventFilter::new(root(), vec!["src/".to_owned()], vec![]);
246 assert!(filter.should_process_path(&root().join("src/lib.rs")));
247 assert!(!filter.should_process_path(&root().join("docs/README.md")));
248 }
249
250 #[test]
251 fn rejects_path_outside_root() {
252 let filter = WatcherEventFilter::new(root(), vec![], vec![]);
253 assert!(!filter.should_process_path(&PathBuf::from("/other/project/main.rs")));
254 }
255
256 #[test]
257 fn rejects_target_directory() {
258 let filter = WatcherEventFilter::new(root(), vec![], vec![]);
259 assert!(!filter.should_process_path(&root().join("target/debug/lib.rs")));
260 }
261
262 #[test]
263 fn default_filter_rejects_empty_path() {
264 let filter = WatcherEventFilter::default();
265 assert!(!filter.should_process_path(&PathBuf::from("main.rs")));
266 }
267
268 #[test]
269 fn allows_python_file() {
270 let filter = WatcherEventFilter::new(root(), vec![], vec![]);
271 assert!(filter.should_process_path(&root().join("app/models.py")));
272 }
273
274 #[test]
275 fn rejects_pycache() {
276 let filter = WatcherEventFilter::new(root(), vec![], vec![]);
277 assert!(!filter.should_process_path(&root().join("__pycache__/foo.cpython-311.pyc")));
278 }
279
280 #[test]
281 fn allows_known_language_alias_extensions() {
282 let filter = WatcherEventFilter::new(root(), vec![], vec![]);
283 assert!(filter.should_process_path(&root().join("src/lib.cc")));
284 assert!(filter.should_process_path(&root().join("web/app.mjs")));
285 assert!(filter.should_process_path(&root().join("build.gradle.kts")));
286 }
287
288 #[test]
289 fn language_filters_accept_config_and_document_languages() {
290 for (language, path) in [
291 ("json", "config/app.json"),
292 ("yaml", "config/app.yaml"),
293 ("toml", "Cargo.toml"),
294 ("sql", "schema/main.sql"),
295 ("markdown", "README.md"),
296 ] {
297 let filter = WatcherEventFilter::new(root(), vec![], vec![language.to_owned()]);
298 assert!(filter.should_process_path(&root().join(path)));
299 }
300 }
301
302 #[test]
303 fn unknown_language_filter_does_not_match_extension_by_name() {
304 let filter = WatcherEventFilter::new(root(), vec![], vec!["rs".to_owned()]);
305 assert!(!filter.should_process_path(&root().join("src/main.rs")));
306 }
307}