krz/orgo

Lightning fast org-mode static site generator.

clone: git clone https://gitbay.org/krz/orgo.git

v0.19.1: src/watch.rs · raw

  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}