src/watch.rs
313 lines · 13030 bytes
15 symbols in this file
1//! `watch`: rebuild when the source changes, driven by OS filesystem events.
2//!
3//! This replaced a 500ms poll loop that re-walked the whole tree twice a second to
4//! compare mtimes. Native events cost nothing while nothing happens, and arrive in
5//! milliseconds when something does.
6//!
7//! Three things matter more than the watching itself:
8//!
9//! 1. **Not watching our own output.** `orgo watch . -o _site` puts the output inside
10//! the source. Rebuilding writes files, writing files raises events, and events
11//! trigger a rebuild — a loop that never stops and never idles. [`ChangeFilter`] is
12//! what prevents it, and it is a pure function precisely so it can be tested without
13//! a filesystem.
14//! 2. **Not treating a read as a change.** A build reads every source file, and inotify
15//! reports opens. [`is_change`] is what keeps that from closing the same loop by a
16//! different route.
17//! 3. **Debouncing.** Saving a file in an editor is rarely one event: editors write a
18//! temp file, rename it over the original, and touch the directory. Rebuilding per
19//! event would rebuild several times per save.
20
21use std::sync::mpsc;
22use std::time::Duration;
23
24use anyhow::{Context, Result};
25use camino::{Utf8Path, Utf8PathBuf};
26use notify::event::{AccessKind, AccessMode};
27use notify::{Config as NotifyConfig, EventKind, RecursiveMode, Watcher};
28
29use crate::site::{build_site, BuildOptions};
30
31/// How long the tree must be quiet before a rebuild starts. Long enough to coalesce an
32/// editor's write burst, short enough to feel immediate.
33pub const DEBOUNCE: Duration = Duration::from_millis(120);
34
35/// Poll interval for the fallback watcher, used where native events are unavailable
36/// (some network and container filesystems). Slower than the old poll loop on purpose:
37/// it is a fallback, not the primary path.
38const POLL_INTERVAL: Duration = Duration::from_secs(2);
39
40/// Decides whether a changed path should trigger a rebuild.
41///
42/// Deliberately *not* the same rule as build-time discovery. Discovery skips the config
43/// file and the templates directory because they are not site content — but a change to
44/// either must rebuild, because both change the output. The rule here is "would this
45/// change the site?", not "is this a page?".
46#[derive(Debug, Clone)]
47pub struct ChangeFilter {
48 /// Output directory, relative to the source root, when it lives inside it.
49 output_inside: Option<Utf8PathBuf>,
50 /// Every spelling of the source root an event path might carry, longest first.
51 ///
52 /// One entry is not enough. On macOS the temp directory is `/var/…`, a symlink to
53 /// `/private/var/…`, and FSEvents reports the resolved path — so stripping event
54 /// paths with the root *as the user typed it* silently fails, every event keeps its
55 /// absolute path, and every absolute path looks like a source change. That includes
56 /// the build's own writes, so `watch` rebuilds in a loop forever.
57 roots: Vec<Utf8PathBuf>,
58}
59
60impl ChangeFilter {
61 /// Build a filter for a source and output directory. Paths are canonicalized so
62 /// `.`, `./src`, an absolute path and a symlinked one all compare equal.
63 pub fn new(src: &Utf8Path, out: &Utf8Path) -> Self {
64 ChangeFilter::with_asset_roots(src, out, &[])
65 }
66
67 /// As [`ChangeFilter::new`], plus extra asset roots. Their paths are recognised too,
68 /// so editing a stylesheet that lives outside the source directory still rebuilds.
69 pub fn with_asset_roots(src: &Utf8Path, out: &Utf8Path, asset_roots: &[Utf8PathBuf]) -> Self {
70 let canon = |p: &Utf8Path| -> Option<Utf8PathBuf> {
71 std::fs::canonicalize(p)
72 .ok()
73 .and_then(|p| Utf8PathBuf::from_path_buf(p).ok())
74 };
75 let src_canon = canon(src);
76 let output_inside = match (&src_canon, canon(out)) {
77 (Some(src), Some(out)) => out
78 .strip_prefix(src)
79 .ok()
80 .filter(|rel| !rel.as_str().is_empty())
81 .map(|rel| rel.to_owned()),
82 _ => out
83 .strip_prefix(src)
84 .ok()
85 .filter(|rel| !rel.as_str().is_empty())
86 .map(|rel| rel.to_owned()),
87 };
88
89 let mut roots: Vec<Utf8PathBuf> = src_canon.into_iter().chain([src.to_owned()]).collect();
90 for root in asset_roots {
91 roots.extend(canon(root));
92 roots.push(root.clone());
93 }
94 roots.dedup();
95 // Longest first, so the most specific spelling wins.
96 roots.sort_by_key(|r| std::cmp::Reverse(r.as_str().len()));
97 ChangeFilter {
98 output_inside,
99 roots,
100 }
101 }
102
103 /// Should a change to `rel` (relative to the source root) cause a rebuild?
104 pub fn is_relevant(&self, rel: &Utf8Path) -> bool {
105 if let Some(out) = &self.output_inside {
106 if rel.starts_with(out) {
107 return false;
108 }
109 }
110 // Dot-entries: `.git` churns on every command, and the cache manifest lives in
111 // the output anyway. Emacs' `.#lock` files land here too.
112 if rel
113 .components()
114 .any(|c| c.as_str().starts_with('.') && c.as_str().len() > 1)
115 {
116 return false;
117 }
118 let Some(name) = rel.file_name() else {
119 return false;
120 };
121 !is_editor_scratch(name)
122 }
123
124 /// Filter absolute event paths down to the relevant ones, as source-relative paths.
125 ///
126 /// A path that cannot be made relative to the source root is discarded rather than
127 /// kept: an event from outside the watched tree cannot be a source change, and
128 /// treating unrecognized paths as changes is what turns a path-spelling mismatch
129 /// into an endless rebuild.
130 pub fn relevant(&self, paths: impl IntoIterator<Item = Utf8PathBuf>) -> Vec<Utf8PathBuf> {
131 let mut out: Vec<Utf8PathBuf> = paths
132 .into_iter()
133 .filter_map(|p| self.to_relative(&p))
134 .filter(|rel| self.is_relevant(rel))
135 .collect();
136 out.sort();
137 out.dedup();
138 out
139 }
140
141 /// An event path as a source-relative path, under whichever spelling of the root it
142 /// arrived with. Already-relative paths pass through.
143 fn to_relative(&self, path: &Utf8Path) -> Option<Utf8PathBuf> {
144 if path.is_relative() {
145 return Some(path.to_owned());
146 }
147 self.roots
148 .iter()
149 .find_map(|root| path.strip_prefix(root).ok())
150 .map(Utf8Path::to_owned)
151 }
152}
153
154/// Files an editor writes beside the real one. Emacs is the relevant case: it leaves
155/// `file.org~` backups, which do not start with a dot and would otherwise look like a
156/// content change to a tool aimed squarely at Emacs users.
157fn is_editor_scratch(name: &str) -> bool {
158 name.ends_with('~')
159 || name.ends_with(".swp")
160 || name.ends_with(".swx")
161 || name.ends_with(".tmp")
162 || (name.starts_with('#') && name.ends_with('#'))
163}
164
165/// The extra asset directories a build will read, as paths that can be watched.
166///
167/// A config that fails to load is not this function's problem — the rebuild reports it
168/// properly — so an unreadable config simply yields no extra roots.
169fn asset_roots(src: &Utf8Path, opts: &BuildOptions) -> Vec<Utf8PathBuf> {
170 let config = match &opts.config_path {
171 Some(path) => crate::config::Config::load_file(path),
172 None => crate::config::Config::load(src),
173 };
174 config
175 .map(|c| {
176 c.build
177 .assets
178 .iter()
179 .map(|root| src.join(root))
180 .filter(|root| root.is_dir())
181 .collect()
182 })
183 .unwrap_or_default()
184}
185
186/// Build once, then rebuild whenever the source changes. Runs until interrupted.
187pub fn run(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result<()> {
188 run_with(src, out, opts, |_| {})
189}
190
191/// As [`run`], calling `on_rebuild` after each rebuild attempt — which is how `serve`
192/// learns that it has something new to tell the browser.
193pub fn run_with(
194 src: &Utf8Path,
195 out: &Utf8Path,
196 opts: &BuildOptions,
197 mut on_rebuild: impl FnMut(&Result<crate::site::SiteReport>),
198) -> Result<()> {
199 if !src.is_dir() {
200 anyhow::bail!("watch requires a source directory: watch <src-dir> -o <out-dir>");
201 }
202
203 let report = build_site(src, out, opts)?;
204 println!(
205 "watching {src} -> {out}: built {} page(s) ({} rendered). Ctrl-C to stop.",
206 report.pages.len(),
207 report.rendered.len()
208 );
209
210 // Asset roots can live outside the source directory, and a stylesheet that does not
211 // rebuild when saved is worse than no watching at all.
212 let asset_roots = asset_roots(src, opts);
213 let filter = ChangeFilter::with_asset_roots(src, out, &asset_roots);
214 let (tx, rx) = mpsc::channel();
215 let mut watcher = make_watcher(tx)?;
216 for root in std::iter::once(src.to_owned()).chain(asset_roots) {
217 watcher
218 .watch(root.as_std_path(), RecursiveMode::Recursive)
219 .with_context(|| format!("watching {root}"))?;
220 }
221
222 loop {
223 // Block until something happens, then keep draining while events keep arriving
224 // inside the debounce window — one save produces several events, and they should
225 // produce one rebuild.
226 let Ok(first) = rx.recv() else {
227 return Ok(()); // watcher dropped
228 };
229 let mut batch = vec![first];
230 while let Ok(next) = rx.recv_timeout(DEBOUNCE) {
231 batch.push(next);
232 }
233
234 let changed = filter.relevant(batch.into_iter().flatten());
235 if changed.is_empty() {
236 continue;
237 }
238
239 let summary = summarize(&changed);
240 let result = build_site(src, out, opts);
241 match &result {
242 Ok(report) => println!(
243 "{summary}: {} rendered, {} cached",
244 report.rendered.len(),
245 report.skipped.len()
246 ),
247 // A rebuild that fails must not end the session — the usual cause is a
248 // half-saved file, and the next keystroke fixes it.
249 Err(e) => eprintln!("{summary}: build failed: {e:#}"),
250 }
251 on_rebuild(&result);
252 }
253}
254
255fn summarize(changed: &[Utf8PathBuf]) -> String {
256 match changed {
257 [one] => format!("{one} changed"),
258 [first, rest @ ..] => format!("{first} and {} more changed", rest.len()),
259 [] => "changed".to_string(),
260 }
261}
262
263/// Does this event report a change to the tree, rather than someone reading it?
264///
265/// inotify's watch mask includes `IN_OPEN`, so reading a file raises an event on it — and
266/// a build reads every source file. Forwarding those makes each rebuild the trigger for
267/// the next, at whatever rate the machine can build. macOS and Windows report no access
268/// events, which is why this only showed up on Linux.
269///
270/// `Access(Close(Write))` is the exception: it follows a write, not a read, and the only
271/// files a build writes are its own output, which [`ChangeFilter`] already drops.
272/// Everything that is not an access is kept, the backends' catch-all `Any` included —
273/// FSEvents and the Windows backend both report changes they cannot classify that way, so
274/// an allow-list of `Create`/`Modify`/`Remove` would drop real edits.
275pub fn is_change(kind: &EventKind) -> bool {
276 match kind {
277 EventKind::Access(access) => matches!(access, AccessKind::Close(AccessMode::Write)),
278 _ => true,
279 }
280}
281
282/// The platform's native watcher, falling back to polling where that is unavailable —
283/// some network and container filesystems have no event API, and `watch` failing outright
284/// there would be worse than being slow.
285fn make_watcher(tx: mpsc::Sender<Vec<Utf8PathBuf>>) -> Result<Box<dyn Watcher>> {
286 let handler = move |result: notify::Result<notify::Event>| {
287 if let Ok(event) = result {
288 if !is_change(&event.kind) {
289 return;
290 }
291 let paths: Vec<Utf8PathBuf> = event
292 .paths
293 .into_iter()
294 .filter_map(|p| Utf8PathBuf::from_path_buf(p).ok())
295 .collect();
296 if !paths.is_empty() {
297 // The receiver going away just means the loop ended.
298 let _ = tx.send(paths);
299 }
300 }
301 };
302
303 match notify::RecommendedWatcher::new(handler.clone(), NotifyConfig::default()) {
304 Ok(watcher) => Ok(Box::new(watcher)),
305 Err(e) => {
306 eprintln!("note: native file watching unavailable ({e}); polling every {POLL_INTERVAL:?}");
307 let config = NotifyConfig::default().with_poll_interval(POLL_INTERVAL);
308 let watcher = notify::PollWatcher::new(handler, config)
309 .context("starting the fallback poll watcher")?;
310 Ok(Box::new(watcher))
311 }
312 }
313}