krz/orgo

Lightning fast org-mode static site generator. fast go org-mode static-site-generator

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}