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