1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
// Copyright © 2025 The µcad authors <info@ucad.xyz>
// SPDX-License-Identifier: AGPL-3.0-or-later
//! µcad CLI watcher. Most parts proudly taken from [typst](https://github.com/typst/typst/blob/main/crates/typst-cli/src/watch.rs)
use std::{iter, path::PathBuf, sync::mpsc::Receiver, time::Duration};
use notify::{Event, RecommendedWatcher};
/// Watches file system activity.
pub struct Watcher {
/// The underlying watcher.
watcher: RecommendedWatcher,
/// Notify event receiver.
rx: Receiver<notify::Result<Event>>,
/// Keeps track of which paths are watched via `watcher`. The boolean is
/// used during updating for mark-and-sweep garbage collection of paths we
/// should unwatch.
watched: std::collections::HashMap<PathBuf, bool>,
/// A set of files that should be watched, but don't exist. We manually poll
/// for those.
missing: std::collections::HashSet<PathBuf>,
}
impl Watcher {
/// How long to wait for a shortly following file system event when
/// watching.
const BATCH_TIMEOUT: Duration = Duration::from_millis(100);
/// The maximum time we spend batching events before quitting wait().
const STARVE_TIMEOUT: Duration = Duration::from_millis(500);
/// The interval in which we poll when falling back to poll watching
/// due to missing files.
const POLL_INTERVAL: Duration = Duration::from_millis(300);
/// Create a new, blank watcher.
pub fn new() -> anyhow::Result<Self> {
// Setup file watching.
let (tx, rx) = std::sync::mpsc::channel();
use notify::Watcher;
// Set the poll interval to something more eager than the default. That
// default seems a bit excessive for our purposes at around 30s.
// Depending on feedback, some tuning might still be in order. Note that
// this only affects a tiny number of systems. Most do not use the
// [`notify::PollWatcher`].
let config = notify::Config::default().with_poll_interval(Self::POLL_INTERVAL);
let watcher = RecommendedWatcher::new(tx, config)?;
Ok(Self {
rx,
watcher,
watched: std::collections::HashMap::new(),
missing: std::collections::HashSet::new(),
})
}
/// Update the watching to watch exactly the listed files.
///
/// Files that are not yet watched will be watched. Files that are already
/// watched, but don't need to be watched anymore, will be unwatched.
pub fn update(&mut self, iter: impl IntoIterator<Item = PathBuf>) -> anyhow::Result<()> {
use notify::{RecursiveMode, Watcher};
// Mark all files as not "seen" so that we may unwatch them if they
// aren't in the dependency list.
for seen in self.watched.values_mut() {
*seen = false;
}
// Reset which files are missing.
self.missing.clear();
// Retrieve the dependencies of the last compilation and watch new paths
// that weren't watched yet.
for path in iter {
// We can't watch paths that don't exist with notify-rs. Instead, we
// add those to a `missing` set and fall back to manual poll
// watching.
if !path.exists() {
self.missing.insert(path);
continue;
}
// Watch the path if it's not already watched.
if !self.watched.contains_key(&path) {
self.watcher.watch(&path, RecursiveMode::NonRecursive)?;
}
// Mark the file as "seen" so that we don't unwatch it.
self.watched.insert(path, true);
}
// Unwatch old paths that don't need to be watched anymore.
self.watched.retain(|path, &mut seen| {
if !seen {
self.watcher.unwatch(path).ok();
}
seen
});
Ok(())
}
/// Wait until there is a change to a watched path.
pub fn wait(&mut self) -> anyhow::Result<()> {
use notify::Watcher;
use std::time::Instant;
loop {
// Wait for an initial event. If there are missing files, we need to
// poll those regularly to check whether they are created, so we
// wait with a smaller timeout.
let first = self.rx.recv_timeout(if self.missing.is_empty() {
Duration::MAX
} else {
Self::POLL_INTERVAL
});
// Watch for file system events. If multiple events happen
// consecutively all within a certain duration, then they are
// bunched up without a recompile in-between. This helps against
// some editors' remove & move behavior. Events are also only
// watched until a certain point, to hinder a barrage of events from
// preventing recompilations.
let mut relevant = false;
let batch_start = Instant::now();
for event in first
.into_iter()
.chain(iter::from_fn(|| {
self.rx.recv_timeout(Self::BATCH_TIMEOUT).ok()
}))
.take_while(|_| batch_start.elapsed() <= Self::STARVE_TIMEOUT)
{
let event = event?;
if !Self::is_relevant_event_kind(&event.kind) {
continue;
}
// Workaround for notify-rs' implicit unwatch on remove/rename
// (triggered by some editors when saving files) with the
// inotify backend. By keeping track of the potentially
// unwatched files, we can allow those we still depend on to be
// watched again later on.
if matches!(
event.kind,
notify::EventKind::Remove(notify::event::RemoveKind::File)
| notify::EventKind::Modify(notify::event::ModifyKind::Name(
notify::event::RenameMode::From
))
) {
for path in &event.paths {
// Remove affected path from the watched map to restart
// watching on it later again.
self.watcher.unwatch(path).ok();
self.watched.remove(path);
}
}
relevant = true;
}
// If we found a relevant event or if any of the missing files now
// exists, stop waiting.
if relevant || self.missing.iter().any(|path| path.exists()) {
return Ok(());
}
}
}
/// Whether a kind of watch event is relevant for compilation.
fn is_relevant_event_kind(kind: ¬ify::EventKind) -> bool {
match kind {
notify::EventKind::Any => true,
notify::EventKind::Access(_) => false,
notify::EventKind::Create(_) => true,
notify::EventKind::Modify(kind) => match kind {
notify::event::ModifyKind::Any => true,
notify::event::ModifyKind::Data(_) => true,
notify::event::ModifyKind::Metadata(_) => false,
notify::event::ModifyKind::Name(_) => true,
notify::event::ModifyKind::Other => false,
},
notify::EventKind::Remove(_) => true,
notify::EventKind::Other => false,
}
}
}