Skip to main content

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: &notify::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}