krz/orgo

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

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}