1use std::collections::{HashMap, HashSet};
6use std::path::{Path, PathBuf};
7use std::sync::mpsc;
8use std::thread::{self, JoinHandle};
9
10use notify::Watcher as _;
11
12#[derive(Clone, Copy, Debug, Eq, PartialEq)]
14pub enum FileChangeKind {
15 Created,
17 Changed,
19 Deleted,
21}
22
23#[derive(Clone, Debug, Eq, PartialEq)]
25pub struct WatchEvent {
26 pub path: PathBuf,
28 pub kind: FileChangeKind,
30}
31
32pub struct FileWatcher {
34 tx: mpsc::Sender<WorkerMessage>,
35
36 worker: Option<JoinHandle<()>>,
45}
46
47impl FileWatcher {
48 pub fn start(
52 on_event: impl FnMut(WatchEvent) + Send + 'static,
53 on_error: impl FnMut(notify::Error) + Send + 'static,
54 ) -> notify::Result<Self> {
55 let (tx, rx) = mpsc::channel();
56 let (startup_tx, startup_rx) = mpsc::sync_channel(1);
57 let worker_tx = tx.clone();
58 let worker = thread::spawn(move || {
59 worker_loop(rx, worker_tx, startup_tx, on_event, on_error);
60 });
61
62 match startup_rx.recv() {
63 Ok(Ok(())) => Ok(Self { tx, worker: Some(worker) }),
64 Ok(Err(err)) => {
65 let _ = worker.join();
66 Err(err)
67 }
68 Err(_) => {
69 let _ = worker.join();
70 Err(worker_stopped_error())
71 }
72 }
73 }
74
75 pub fn update_watched_paths<I>(&mut self, paths: I) -> notify::Result<()>
77 where
78 I: IntoIterator<Item = PathBuf>,
79 {
80 let watched_files = paths
81 .into_iter()
82 .map(|path| i_slint_compiler::pathutils::clean_path(&path))
83 .collect::<HashSet<_>>();
84
85 let (response_tx, response_rx) = mpsc::sync_channel(1);
86 self.tx
87 .send(WorkerMessage::UpdateWatchedPaths { watched_files, response: response_tx })
88 .map_err(|_| worker_stopped_error())?;
89 response_rx.recv().map_err(|_| worker_stopped_error())?
90 }
91}
92
93impl Drop for FileWatcher {
94 fn drop(&mut self) {
95 let _ = self.tx.send(WorkerMessage::Shutdown);
96 if let Some(worker) = self.worker.take() {
97 let _ = worker.join();
98 }
99 }
100}
101
102fn classify_event(event: notify::Event) -> Vec<(PathBuf, FileChangeKind)> {
103 use notify::EventKind;
104 use notify::event::{ModifyKind, RenameMode};
105
106 fn map_event(event: notify::Event, kind: FileChangeKind) -> Vec<(PathBuf, FileChangeKind)> {
107 event
108 .paths
109 .into_iter()
110 .map(|path| (i_slint_compiler::pathutils::clean_path(&path), kind))
111 .collect()
112 }
113
114 match event.kind {
115 EventKind::Create(_) => map_event(event, FileChangeKind::Created),
116 EventKind::Remove(_) => map_event(event, FileChangeKind::Deleted),
117 EventKind::Modify(ModifyKind::Name(RenameMode::From)) => {
118 map_event(event, FileChangeKind::Deleted)
119 }
120 EventKind::Modify(ModifyKind::Name(RenameMode::To)) => {
121 map_event(event, FileChangeKind::Created)
122 }
123 EventKind::Modify(ModifyKind::Name(RenameMode::Both)) => {
124 let mut paths = event.paths.into_iter();
125 [
126 paths.next().map(|path| {
127 (i_slint_compiler::pathutils::clean_path(&path), FileChangeKind::Deleted)
128 }),
129 paths.next().map(|path| {
130 (i_slint_compiler::pathutils::clean_path(&path), FileChangeKind::Created)
131 }),
132 ]
133 .into_iter()
134 .flatten()
135 .collect()
136 }
137 EventKind::Modify(_) => map_event(event, FileChangeKind::Changed),
138 _ => Vec::new(),
139 }
140}
141
142enum WorkerMessage {
143 UpdateWatchedPaths {
144 watched_files: HashSet<PathBuf>,
145 response: mpsc::SyncSender<notify::Result<()>>,
146 },
147 RawEvent(notify::Result<notify::Event>),
148 Shutdown,
149}
150
151#[derive(Clone, Debug, Eq, PartialEq)]
152enum TargetState {
153 Existing { probe_dir: Option<PathBuf> },
154 Missing { probe_dir: Option<PathBuf> },
155}
156
157impl TargetState {
158 fn exists(&self) -> bool {
159 matches!(self, Self::Existing { .. })
160 }
161
162 fn probe_dir(&self) -> Option<&PathBuf> {
163 match self {
164 Self::Existing { probe_dir } | Self::Missing { probe_dir } => probe_dir.as_ref(),
165 }
166 }
167}
168
169#[derive(Default, Debug)]
170struct WorkerState {
171 watched_files: HashSet<PathBuf>,
173 target_states: HashMap<PathBuf, TargetState>,
174 registered_watches: HashSet<PathBuf>,
176}
177
178impl WorkerState {
179 fn update_watched_paths(
180 &mut self,
181 watcher: &mut notify::RecommendedWatcher,
182 watched_files: HashSet<PathBuf>,
183 on_event: &mut impl FnMut(WatchEvent),
184 ) -> notify::Result<()> {
185 let previous_states = watched_files
186 .iter()
187 .map(|path| {
188 let state = self
189 .target_states
190 .get(path)
191 .cloned()
192 .unwrap_or_else(|| scan_target_state(path));
193 (path.clone(), state)
194 })
195 .collect::<HashMap<_, _>>();
196
197 self.watched_files = watched_files;
198 self.target_states = previous_states.clone();
199 self.reconcile(watcher, previous_states, HashSet::new(), on_event)
200 }
201
202 fn handle_raw_event(
203 &mut self,
204 watcher: &mut notify::RecommendedWatcher,
205 event: notify::Event,
206 on_event: &mut impl FnMut(WatchEvent),
207 ) -> notify::Result<()> {
208 if self.watched_files.is_empty() {
209 return Ok(());
210 }
211
212 let previous_states = self.target_states.clone();
213 let changed_paths = classify_event(event)
214 .into_iter()
215 .filter_map(|(path, kind)| {
216 (kind == FileChangeKind::Changed && self.watched_files.contains(&path))
217 .then_some(path)
218 })
219 .collect::<HashSet<_>>();
220
221 self.reconcile(watcher, previous_states, changed_paths, on_event)
222 }
223
224 fn reconcile(
225 &mut self,
226 watcher: &mut notify::RecommendedWatcher,
227 previous_states: HashMap<PathBuf, TargetState>,
228 changed_paths: HashSet<PathBuf>,
229 on_event: &mut impl FnMut(WatchEvent),
230 ) -> notify::Result<()> {
231 const MAX_RECONCILE_PASSES: usize = 8;
232
233 let mut target_states = scan_target_states(&self.watched_files);
234
235 for _ in 0..MAX_RECONCILE_PASSES {
236 let desired_watches = desired_watches_for_states(&target_states);
237 if desired_watches == self.registered_watches {
238 break;
239 }
240
241 self.apply_watch_plan(watcher, &desired_watches)?;
242 target_states = scan_target_states(&self.watched_files);
243 }
244
245 self.target_states = target_states;
246
247 let mut transitioned_paths = HashSet::new();
248 for path in &self.watched_files {
249 let previous = previous_states.get(path).map(TargetState::exists).unwrap_or(false);
250 let current = self.target_states.get(path).map(TargetState::exists).unwrap_or(false);
251
252 match (previous, current) {
253 (false, true) => {
254 transitioned_paths.insert(path.clone());
255 on_event(WatchEvent { path: path.clone(), kind: FileChangeKind::Created });
256 }
257 (true, false) => {
258 transitioned_paths.insert(path.clone());
259 on_event(WatchEvent { path: path.clone(), kind: FileChangeKind::Deleted });
260 }
261 _ => {}
262 }
263 }
264
265 for path in changed_paths {
266 if transitioned_paths.contains(&path) {
267 continue;
268 }
269
270 if self.target_states.get(&path).map(TargetState::exists).unwrap_or(false) {
271 on_event(WatchEvent { path, kind: FileChangeKind::Changed });
272 }
273 }
274
275 Ok(())
276 }
277
278 fn apply_watch_plan(
279 &mut self,
280 watcher: &mut notify::RecommendedWatcher,
281 desired_registrations: &HashSet<PathBuf>,
282 ) -> notify::Result<()> {
283 let current_watches = self.registered_watches.clone();
284
285 for registration in desired_registrations.difference(¤t_watches) {
286 match watcher.watch(registration, notify::RecursiveMode::NonRecursive) {
287 Ok(()) => {
288 self.registered_watches.insert(registration.clone());
289 }
290 Err(err) if is_transient_watch_error(&err) => {}
291 Err(err) => return Err(err),
292 }
293 }
294
295 for registration in current_watches.difference(desired_registrations) {
296 match watcher.unwatch(registration) {
297 Ok(()) => {}
298 Err(err) if is_transient_watch_error(&err) => {}
299 Err(err) => return Err(err),
300 }
301 self.registered_watches.remove(registration);
302 }
303
304 Ok(())
305 }
306}
307
308fn worker_loop(
309 rx: mpsc::Receiver<WorkerMessage>,
310 tx: mpsc::Sender<WorkerMessage>,
311 startup_tx: mpsc::SyncSender<notify::Result<()>>,
312 mut on_event: impl FnMut(WatchEvent) + Send + 'static,
313 mut on_error: impl FnMut(notify::Error) + Send + 'static,
314) {
315 let watcher = notify::recommended_watcher(move |event| {
316 let _ = tx.send(WorkerMessage::RawEvent(event));
321 });
322
323 let mut watcher = match watcher {
324 Ok(watcher) => {
325 let _ = startup_tx.send(Ok(()));
326 watcher
327 }
328 Err(err) => {
329 let _ = startup_tx.send(Err(err));
330 return;
331 }
332 };
333
334 let mut state = WorkerState::default();
335
336 while let Ok(message) = rx.recv() {
337 match message {
338 WorkerMessage::UpdateWatchedPaths { watched_files, response } => {
339 let _ = response.send(state.update_watched_paths(
340 &mut watcher,
341 watched_files,
342 &mut on_event,
343 ));
344 }
345 WorkerMessage::RawEvent(Ok(event)) => {
346 if let Err(err) = state.handle_raw_event(&mut watcher, event, &mut on_event) {
347 on_error(err);
348 }
349 }
350 WorkerMessage::RawEvent(Err(err)) => {
351 if !is_transient_watch_error(&err) {
352 on_error(err);
353 }
354 }
355 WorkerMessage::Shutdown => break,
356 }
357 }
358}
359
360fn scan_target_states(watched_files: &HashSet<PathBuf>) -> HashMap<PathBuf, TargetState> {
361 watched_files.iter().map(|path| (path.clone(), scan_target_state(path))).collect()
362}
363
364fn scan_target_state(path: &Path) -> TargetState {
365 let probe_dir = probe_dir_for_path(path);
366 if path.exists() {
367 TargetState::Existing { probe_dir }
368 } else {
369 TargetState::Missing { probe_dir }
370 }
371}
372
373fn desired_watches_for_states(target_states: &HashMap<PathBuf, TargetState>) -> HashSet<PathBuf> {
374 let mut watches = target_states
375 .values()
376 .filter_map(|state| state.probe_dir().cloned())
377 .collect::<HashSet<_>>();
378
379 if needs_direct_file_watches() {
380 watches.extend(
381 target_states
382 .iter()
383 .filter(|(_path, state)| state.exists())
384 .map(|(path, _state)| path.clone()),
385 );
386 }
387
388 watches
389}
390
391fn probe_dir_for_path(path: &Path) -> Option<PathBuf> {
392 if path.exists() {
393 let parent = path.parent()?;
394 parent.is_dir().then(|| i_slint_compiler::pathutils::clean_path(parent))
395 } else {
396 nearest_existing_ancestor(path)
397 }
398}
399
400fn nearest_existing_ancestor(path: &Path) -> Option<PathBuf> {
401 let mut current = path.parent()?;
402 while !current.is_dir() {
403 current = current.parent()?;
404 }
405
406 Some(i_slint_compiler::pathutils::clean_path(current))
407}
408
409fn is_transient_watch_error(err: ¬ify::Error) -> bool {
410 match &err.kind {
411 notify::ErrorKind::PathNotFound
412 | notify::ErrorKind::WatchNotFound
413 | notify::ErrorKind::Generic(_) => true,
414 notify::ErrorKind::Io(e) => e.kind() == std::io::ErrorKind::NotFound,
415 _ => false,
416 }
417}
418
419fn worker_stopped_error() -> notify::Error {
420 notify::Error::generic("file watcher worker thread stopped")
421}
422
423fn needs_direct_file_watches() -> bool {
424 cfg!(target_os = "macos")
427}
428
429#[cfg(test)]
430mod tests {
431 use super::*;
432
433 use std::fs;
434 use std::sync::atomic::{AtomicUsize, Ordering};
435 use std::sync::mpsc::{self, Receiver};
436 use std::time::{Duration, SystemTime, UNIX_EPOCH};
437
438 const WATCHER_SETTLE_DELAY: Duration = Duration::from_millis(50);
439 const EVENT_TIMEOUT: Duration = Duration::from_millis(100);
440 const QUIET_TIMEOUT: Duration = Duration::from_millis(50);
441
442 struct TestContext {
443 root: PathBuf,
444 watcher: FileWatcher,
445 events: Receiver<WatchEvent>,
446 errors: Receiver<notify::Error>,
447 }
448
449 impl TestContext {
450 fn new() -> Self {
451 static NEXT_ID: AtomicUsize = AtomicUsize::new(0);
452
453 let unique_id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
454 let timestamp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
455 let root = std::env::temp_dir()
456 .join(format!("slint-file-watcher-{timestamp}-{unique_id}-{}", std::process::id()));
457 fs::create_dir_all(&root).unwrap();
458 let (event_tx, events) = mpsc::channel();
459 let (error_tx, errors) = mpsc::channel();
460
461 let watcher = FileWatcher::start(
462 move |event| {
463 event_tx.send(event).unwrap();
464 },
465 move |error| {
466 error_tx.send(error).unwrap();
467 },
468 )
469 .unwrap();
470
471 Self { root, watcher, events, errors }
472 }
473
474 fn path(&self, relative: impl AsRef<Path>) -> PathBuf {
475 self.root.join(relative)
476 }
477
478 fn create_dir_all(&self, relative: impl AsRef<Path>) -> PathBuf {
479 let path = self.path(relative);
480 fs::create_dir_all(&path).unwrap();
481 path
482 }
483
484 fn write(&self, relative: impl AsRef<Path>, contents: &str) -> PathBuf {
485 let path = self.path(relative);
486 if let Some(parent) = path.parent() {
487 fs::create_dir_all(parent).unwrap();
488 }
489 fs::write(&path, contents).unwrap();
490 path
491 }
492
493 fn remove_file(&self, relative: impl AsRef<Path>) {
494 fs::remove_file(self.path(relative)).unwrap();
495 }
496
497 fn remove_dir_all(&self, relative: impl AsRef<Path>) {
498 fs::remove_dir_all(self.path(relative)).unwrap();
499 }
500
501 fn rename(&self, from: impl AsRef<Path>, to: impl AsRef<Path>) {
502 let from = self.path(from);
503 let to = self.path(to);
504 if let Some(parent) = to.parent() {
505 fs::create_dir_all(parent).unwrap();
506 }
507 fs::rename(from, to).unwrap();
508 }
509
510 fn watch(&mut self, relative_paths: &[&str]) {
511 let paths = relative_paths.iter().map(|path| self.path(*path)).collect::<Vec<_>>();
512 self.watcher.update_watched_paths(paths).unwrap();
513 self.settle();
514 self.drain_events();
515 self.assert_no_errors();
516 }
517
518 fn settle(&self) {
519 std::thread::sleep(WATCHER_SETTLE_DELAY);
520 }
521
522 fn drain_events(&self) -> Vec<WatchEvent> {
523 let mut events = Vec::new();
524 while let Ok(event) = self.events.try_recv() {
525 events.push(event);
526 }
527 events
528 }
529
530 fn drain_errors(&self) -> Vec<notify::Error> {
531 let mut errors = Vec::new();
532 while let Ok(error) = self.errors.try_recv() {
533 errors.push(error);
534 }
535 errors
536 }
537
538 fn assert_no_errors(&self) {
539 let errors = self.drain_errors();
540 assert!(errors.is_empty(), "unexpected watcher errors: {errors:?}");
541 }
542
543 fn expect_event(&self, path: &Path, kind: FileChangeKind) {
544 let expected = WatchEvent { path: path.to_path_buf(), kind };
545 let mut seen = Vec::new();
546
547 loop {
548 self.assert_no_errors();
549
550 match self.events.recv_timeout(EVENT_TIMEOUT) {
551 Ok(event) if event == expected => return,
552 Ok(event) => seen.push(event),
553 Err(mpsc::RecvTimeoutError::Timeout) => {
554 panic!("timed out waiting for {expected:?}; saw {seen:?}")
555 }
556 Err(mpsc::RecvTimeoutError::Disconnected) => {
557 panic!("watcher event channel disconnected while waiting for {expected:?}")
558 }
559 }
560 }
561 }
562
563 fn expect_quiet(&self) {
564 match self.events.recv_timeout(QUIET_TIMEOUT) {
565 Ok(event) => panic!("unexpected event during quiet period: {event:?}"),
566 Err(mpsc::RecvTimeoutError::Timeout) => {}
567 Err(mpsc::RecvTimeoutError::Disconnected) => {
568 panic!("watcher event channel disconnected while waiting for quiet period")
569 }
570 }
571
572 self.assert_no_errors();
573 }
574 }
575
576 impl Drop for TestContext {
577 fn drop(&mut self) {
578 let _ = fs::remove_dir_all(&self.root);
579 }
580 }
581
582 #[test]
583 fn reports_changed_for_existing_watched_file() {
584 let mut ctx = TestContext::new();
585 let watched = ctx.write("ui/main.slint", "first");
586
587 ctx.watch(&["ui/main.slint"]);
588 ctx.write("ui/main.slint", "second");
589
590 ctx.expect_event(&watched, FileChangeKind::Changed);
591 }
592
593 #[test]
594 fn reports_deleted_and_created_for_existing_watched_file() {
595 let mut ctx = TestContext::new();
596 let watched = ctx.write("ui/main.slint", "first");
597
598 ctx.watch(&["ui/main.slint"]);
599 ctx.remove_file("ui/main.slint");
600 ctx.expect_event(&watched, FileChangeKind::Deleted);
601
602 ctx.write("ui/main.slint", "second");
603 ctx.expect_event(&watched, FileChangeKind::Created);
604 }
605
606 #[test]
607 fn reports_deleted_when_watched_file_is_renamed_away() {
608 let mut ctx = TestContext::new();
609 let watched = ctx.write("ui/main.slint", "first");
610
611 ctx.watch(&["ui/main.slint"]);
612 ctx.rename("ui/main.slint", "ui/renamed.slint");
613
614 ctx.expect_event(&watched, FileChangeKind::Deleted);
615 }
616
617 #[test]
618 fn reports_created_when_file_is_renamed_into_watched_path() {
619 let mut ctx = TestContext::new();
620 let watched = ctx.path("ui/main.slint");
621
622 ctx.create_dir_all("ui");
623 ctx.write("ui/temp.slint", "temporary");
624 ctx.watch(&["ui/main.slint"]);
625 ctx.drain_events();
626
627 ctx.rename("ui/temp.slint", "ui/main.slint");
628
629 ctx.expect_event(&watched, FileChangeKind::Created);
630 }
631
632 #[test]
633 fn ignores_changes_to_unwatched_sibling_files() {
634 let mut ctx = TestContext::new();
635 ctx.write("ui/main.slint", "main");
636 ctx.write("ui/sibling.slint", "sibling");
637
638 ctx.watch(&["ui/main.slint"]);
639 ctx.write("ui/sibling.slint", "sibling changed");
640
641 ctx.expect_quiet();
642 }
643
644 #[test]
645 fn reports_created_for_missing_file_when_parent_directory_exists() {
646 let mut ctx = TestContext::new();
647 let watched = ctx.path("ui/missing.slint");
648
649 ctx.create_dir_all("ui");
650 ctx.watch(&["ui/missing.slint"]);
651 ctx.write("ui/missing.slint", "created later");
652
653 ctx.expect_event(&watched, FileChangeKind::Created);
654 }
655
656 #[test]
657 fn reports_created_for_missing_file_when_intermediate_directory_is_created_later() {
658 let mut ctx = TestContext::new();
659 let watched = ctx.path("ui/generated/missing.slint");
660
661 ctx.create_dir_all("ui");
662 ctx.watch(&["ui/generated/missing.slint"]);
663 ctx.write("ui/generated/missing.slint", "created with parent later");
664
665 ctx.expect_event(&watched, FileChangeKind::Created);
666 }
667
668 #[test]
669 fn reports_created_for_missing_file_when_directory_chain_is_created_later() {
670 let mut ctx = TestContext::new();
671 let watched = ctx.path("ui/generated/deep/missing.slint");
672
673 ctx.watch(&["ui/generated/deep/missing.slint"]);
674 ctx.write("ui/generated/deep/missing.slint", "created with full chain later");
675
676 ctx.expect_event(&watched, FileChangeKind::Created);
677 }
678
679 #[test]
680 fn refreshing_watch_set_stops_forwarding_old_paths() {
681 let mut ctx = TestContext::new();
682 let first = ctx.write("ui/first.slint", "first");
683 let second = ctx.write("ui/second.slint", "first");
684
685 ctx.watch(&["ui/first.slint"]);
686 ctx.write("ui/first.slint", "first updated");
687 ctx.expect_event(&first, FileChangeKind::Changed);
688 ctx.drain_events();
689
690 ctx.watch(&["ui/second.slint"]);
691 ctx.write("ui/first.slint", "should now be ignored");
692 ctx.expect_quiet();
693
694 ctx.write("ui/second.slint", "second updated");
695 ctx.expect_event(&second, FileChangeKind::Changed);
696 }
697
698 #[test]
699 fn refreshing_after_probe_directory_is_removed_recovers_cleanly() {
700 let mut ctx = TestContext::new();
701 ctx.write("test.slint", "export component Test { }");
702 let watched_nested = ctx.write("thing/thing.slint", "export component Thing { }");
703
704 ctx.watch(&["test.slint", "thing/thing.slint"]);
705 ctx.remove_dir_all("thing");
706 ctx.settle();
707 ctx.expect_event(&watched_nested, FileChangeKind::Deleted);
708 ctx.drain_events();
709 ctx.assert_no_errors();
710
711 ctx.watch(&["test.slint", "thing/thing.slint"]);
712
713 ctx.write("thing/thing.slint", "export component Thing { in property<string> x; }");
714 ctx.expect_event(&watched_nested, FileChangeKind::Created);
715 }
716
717 #[test]
718 fn removing_watched_directory_does_not_report_spurious_errors() {
719 let mut ctx = TestContext::new();
720 let watched = ctx.write("project/src/main.slint", "export component App { }");
721
722 ctx.watch(&["project/src/main.slint"]);
723 ctx.remove_dir_all("project");
724 ctx.expect_event(&watched, FileChangeKind::Deleted);
725 ctx.settle();
726 ctx.assert_no_errors();
727 }
728}