src/watch.rs
310 lines · 12972 bytes
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 ["~", ".swp", ".swx", ".tmp"].iter().any(|s| name.ends_with(s))
159 || (name.starts_with('#') && name.ends_with('#'))
160}
161
162/// The extra asset directories a build will read, as paths that can be watched.
163///
164/// A config that fails to load is not this function's problem — the rebuild reports it
165/// properly — so an unreadable config simply yields no extra roots.
166fn asset_roots(src: &Utf8Path, opts: &BuildOptions) -> Vec<Utf8PathBuf> {
167 let config = match &opts.config_path {
168 Some(path) => crate::config::Config::load_file(path),
169 None => crate::config::Config::load(src),
170 };
171 config
172 .map(|c| {
173 c.build
174 .assets
175 .iter()
176 .map(|root| src.join(root))
177 .filter(|root| root.is_dir())
178 .collect()
179 })
180 .unwrap_or_default()
181}
182
183/// Build once, then rebuild whenever the source changes. Runs until interrupted.
184pub fn run(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result<()> {
185 run_with(src, out, opts, |_| {})
186}
187
188/// As [`run`], calling `on_rebuild` after each rebuild attempt — which is how `serve`
189/// learns that it has something new to tell the browser.
190pub fn run_with(
191 src: &Utf8Path,
192 out: &Utf8Path,
193 opts: &BuildOptions,
194 mut on_rebuild: impl FnMut(&Result<crate::site::SiteReport>),
195) -> Result<()> {
196 if !src.is_dir() {
197 anyhow::bail!("watch requires a source directory: watch <src-dir> -o <out-dir>");
198 }
199
200 let report = build_site(src, out, opts)?;
201 println!(
202 "watching {src} -> {out}: built {} page(s) ({} rendered). Ctrl-C to stop.",
203 report.pages.len(),
204 report.rendered.len()
205 );
206
207 // Asset roots can live outside the source directory, and a stylesheet that does not
208 // rebuild when saved is worse than no watching at all.
209 let asset_roots = asset_roots(src, opts);
210 let filter = ChangeFilter::with_asset_roots(src, out, &asset_roots);
211 let (tx, rx) = mpsc::channel();
212 let mut watcher = make_watcher(tx)?;
213 for root in std::iter::once(src.to_owned()).chain(asset_roots) {
214 watcher
215 .watch(root.as_std_path(), RecursiveMode::Recursive)
216 .with_context(|| format!("watching {root}"))?;
217 }
218
219 loop {
220 // Block until something happens, then keep draining while events keep arriving
221 // inside the debounce window — one save produces several events, and they should
222 // produce one rebuild.
223 let Ok(first) = rx.recv() else {
224 return Ok(()); // watcher dropped
225 };
226 let mut batch = vec![first];
227 while let Ok(next) = rx.recv_timeout(DEBOUNCE) {
228 batch.push(next);
229 }
230
231 let changed = filter.relevant(batch.into_iter().flatten());
232 if changed.is_empty() {
233 continue;
234 }
235
236 let summary = summarize(&changed);
237 let result = build_site(src, out, opts);
238 match &result {
239 Ok(report) => println!(
240 "{summary}: {} rendered, {} cached",
241 report.rendered.len(),
242 report.skipped.len()
243 ),
244 // A rebuild that fails must not end the session — the usual cause is a
245 // half-saved file, and the next keystroke fixes it.
246 Err(e) => eprintln!("{summary}: build failed: {e:#}"),
247 }
248 on_rebuild(&result);
249 }
250}
251
252fn summarize(changed: &[Utf8PathBuf]) -> String {
253 match changed {
254 [one] => format!("{one} changed"),
255 [first, rest @ ..] => format!("{first} and {} more changed", rest.len()),
256 [] => "changed".to_string(),
257 }
258}
259
260/// Does this event report a change to the tree, rather than someone reading it?
261///
262/// inotify's watch mask includes `IN_OPEN`, so reading a file raises an event on it — and
263/// a build reads every source file. Forwarding those makes each rebuild the trigger for
264/// the next, at whatever rate the machine can build. macOS and Windows report no access
265/// events, which is why this only showed up on Linux.
266///
267/// `Access(Close(Write))` is the exception: it follows a write, not a read, and the only
268/// files a build writes are its own output, which [`ChangeFilter`] already drops.
269/// Everything that is not an access is kept, the backends' catch-all `Any` included —
270/// FSEvents and the Windows backend both report changes they cannot classify that way, so
271/// an allow-list of `Create`/`Modify`/`Remove` would drop real edits.
272pub fn is_change(kind: &EventKind) -> bool {
273 match kind {
274 EventKind::Access(access) => matches!(access, AccessKind::Close(AccessMode::Write)),
275 _ => true,
276 }
277}
278
279/// The platform's native watcher, falling back to polling where that is unavailable —
280/// some network and container filesystems have no event API, and `watch` failing outright
281/// there would be worse than being slow.
282fn make_watcher(tx: mpsc::Sender<Vec<Utf8PathBuf>>) -> Result<Box<dyn Watcher>> {
283 let handler = move |result: notify::Result<notify::Event>| {
284 if let Ok(event) = result {
285 if !is_change(&event.kind) {
286 return;
287 }
288 let paths: Vec<Utf8PathBuf> = event
289 .paths
290 .into_iter()
291 .filter_map(|p| Utf8PathBuf::from_path_buf(p).ok())
292 .collect();
293 if !paths.is_empty() {
294 // The receiver going away just means the loop ended.
295 let _ = tx.send(paths);
296 }
297 }
298 };
299
300 match notify::RecommendedWatcher::new(handler.clone(), NotifyConfig::default()) {
301 Ok(watcher) => Ok(Box::new(watcher)),
302 Err(e) => {
303 eprintln!("note: native file watching unavailable ({e}); polling every {POLL_INTERVAL:?}");
304 let config = NotifyConfig::default().with_poll_interval(POLL_INTERVAL);
305 let watcher = notify::PollWatcher::new(handler, config)
306 .context("starting the fallback poll watcher")?;
307 Ok(Box::new(watcher))
308 }
309 }
310}