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