krz/orgo
Lightning fast org-mode static site generator.
clone: git clone https://gitbay.org/krz/orgo.git
a34f015d787b6da2b0a0fec72c702f982f9b0004
verified · cmc
author: Christian Cleberg <hello@cleberg.net> · 2026-08-11T05:39:51Z
Cargo.lock | 185 ++++++++++++++++++++++++++++++++++++++++++-- Cargo.toml | 3 +- README.md | 37 +++++++-- src/lib.rs | 1 + src/main.rs | 83 +++++++------------- src/watch.rs | 238 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ tests/watch.rs | 193 ++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 668 insertions(+), 72 deletions(-) @@ -53,7 +53,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -64,7 +64,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -225,7 +225,7 @@ checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" dependencies = [ "encode_unicode", "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -314,7 +314,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -345,6 +345,15 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futures-core" version = "0.3.33" @@ -426,6 +435,26 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "inotify" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8" +dependencies = [ + "bitflags", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + [[package]] name = "insta" version = "1.48.0" @@ -462,6 +491,26 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kqueue" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags", + "libc", +] + [[package]] name = "libc" version = "0.2.189" @@ -518,6 +567,45 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -569,7 +657,7 @@ dependencies = [ [[package]] name = "org-ssg" -version = "0.10.0" +version = "0.11.0" dependencies = [ "anyhow", "blake3", @@ -578,6 +666,7 @@ dependencies = [ "clap", "insta", "minijinja", + "notify", "rayon", "serde", "serde_json", @@ -687,7 +776,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -840,7 +929,7 @@ dependencies = [ "getrandom", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -954,6 +1043,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasm-bindgen" version = "0.2.127" @@ -1005,7 +1100,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1067,6 +1162,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -1076,6 +1180,71 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "1.0.4" @@ -1,6 +1,6 @@ [package] name = "org-ssg" -version = "0.10.0" +version = "0.11.0" edition = "2021" description = "Org-mode static site generator that renders the org element tree straight to HTML" license = "MIT" @@ -32,6 +32,7 @@ anyhow = "1" thiserror = "2" rayon = "1.12.0" toml = "1.1.4" +notify = "8.2.0" [dev-dependencies] insta = { version = "1", features = ["json"] } @@ -295,13 +295,14 @@ all-of-org. Phase 0 checked this line against a real 179-file corpus and found i | 3 | Inline objects — emphasis, links, bare URLs, footnote refs, timestamps | done | | 4 | Rendering to HTML — tree walk, tables, footnote two-pass, minijinja templating, syntect highlighting | done | | 5 | Link resolution + symbol table (INDEX + RESOLVE, used-target list, broken-link reporting) | done | -| 6 | Incremental build layer (hashing, dep graph, invalidation) done; `watch` is a simple poll loop | done | +| 6 | Incremental build layer (hashing, dep graph, invalidation); `watch` on OS filesystem events | done | | **7** | **Hardening: rayon parallelism, error locations in parse diagnostics** | **done** | | **8** | **General use: config file, user templates, nav modes, `init` scaffold, safe discovery** | **done** | | **9** | **Generated listing pages: `[[collections]]`, sorted indexes, feeds via XML templates** | **done** | | **10** | **Grouped collections: one page per tag plus a tag index — full parity with the incumbent** | **done** | | **11** | **Pagination: numbered pages with a `paginator` context, composing with grouping** | **done** | | **12** | **`base_url`: `absolute`/`rfc822` filters, a valid RSS feed in the scaffold, canonical links** | **done** | +| **13** | **`watch` on OS filesystem events, debounced, with the feedback loop closed** | **done** | ### v0.2 in / out @@ -395,8 +396,32 @@ as literal text; drawers other than PROPERTIES are captured and dropped; unmodel types keep their content verbatim. **Still out:** `#+TODO:` per-file keyword sequences; planning lines -(`SCHEDULED:`/`DEADLINE:`), which render as ordinary paragraphs; fixed-width `: ` lines; -and the `watch` fs-notify integration. +(`SCHEDULED:`/`DEADLINE:`), which render as ordinary paragraphs; and fixed-width `: ` +lines. + +## Watching + +```bash +cargo run -- watch my-site -o _site +``` + +Rebuilds on OS filesystem events rather than polling, so it costs nothing while nothing +happens. Write bursts are debounced — an editor saving a file writes a temp file, renames +it over the original and touches the directory, which is one edit and several events. + +Two rules decide what counts as a change, and they are not the same rules the build uses +to find content: + +- **A build input is a change.** Editing `org-ssg.toml` or a template rebuilds, even + though discovery skips both as non-content. The question is "would this change the + site?", not "is this a page?". +- **Our own output is not.** `watch . -o _site` puts the output inside the source, so a + rebuild's writes raise events that would trigger a rebuild, forever. Dot-directories go + the same way — `.git` churns on every command — as do editor scratch files, including + Emacs' `file.org~` backups, which do not start with a dot. + +Where native watching is unavailable (some network and container filesystems), it falls +back to polling and says so, rather than failing. ## Phase 0: the corpus audit and the Emacs oracle @@ -558,20 +583,20 @@ Parser is hand-written recursive descent (not `nom`/`chumsky`/`pest` — org is line-oriented and context-sensitive, not clean CFG). Key crates: `syntect` (syntax highlighting, behind a `Highlighter` trait so tree-sitter can be swapped in later), `minijinja` (runtime templates), `blake3` (content/cache hashing), `rayon` (parallel -PARSE/RESOLVE/RENDER), `chrono`, `camino`, `walkdir`, `clap`, `anyhow`/`thiserror`. +PARSE/RESOLVE/RENDER), `notify` (filesystem events for `watch`), `toml` (config), `chrono`, `camino`, `walkdir`, `clap`, `anyhow`/`thiserror`. `insta` for snapshot tests, and `emacs --batch` — optional, and only for the oracle. ## Build & test ``` cargo build -cargo test # 122 tests +cargo test # 128 tests cargo run -- init my-site # scaffold a new site cargo run -- build fixtures/minimal.org -o minimal.html # single file cargo run -- build fixtures/site -o _site # whole site (incremental) cargo run -- audit fixtures/site # corpus audit (Phase 0) cargo run -- build fixtures/site -o _site --no-cache # force a full rebuild -cargo run -- watch fixtures/site -o _site # poll + rebuild on change +cargo run -- watch fixtures/site -o _site # rebuild on filesystem events cargo run -- clean _site # remove output + cache ``` @@ -19,3 +19,4 @@ pub mod resolve; pub mod site; pub mod template; pub mod util; +pub mod watch; @@ -40,13 +40,23 @@ enum Command { #[arg(long, value_name = "FILE")] config: Option<Utf8PathBuf>, }, - /// Watch a source directory and rebuild incrementally on change (simple poll loop). + /// Watch a source directory and rebuild incrementally on change, driven by OS + /// filesystem events. Watch { /// Source directory to watch. input: Utf8PathBuf, /// Output directory. #[arg(short, long)] output: Utf8PathBuf, + /// Bypass the incremental cache on every rebuild. + #[arg(long)] + no_cache: bool, + /// Treat broken links and parse diagnostics as errors. + #[arg(long)] + strict: bool, + /// Config file to use, overriding `org-ssg.toml` in the source directory. + #[arg(long, value_name = "FILE")] + config: Option<Utf8PathBuf>, }, /// Remove the build output directory (which holds the cache manifest). Clean { @@ -105,10 +115,21 @@ fn main() -> Result<()> { } Ok(()) } - // Watch is intentionally a minimal poll loop, not an OS file-watch (spec §5 Phase - // 6 lists `watch`; the real fs-notify integration is deferred). It rebuilds - // incrementally whenever any source file's mtime advances. - Command::Watch { input, output } => watch(&input, &output), + Command::Watch { + input, + output, + no_cache, + strict, + config, + } => org_ssg::watch::run( + &input, + &output, + &BuildOptions { + no_cache, + strict, + config_path: config, + }, + ), Command::Audit { input } => { let result = org_ssg::audit::audit(&input)?; print!("{}", org_ssg::audit::report(&result)); @@ -194,58 +215,6 @@ fn init(dir: &Utf8Path) -> Result<()> { Ok(()) } -/// Minimal poll-based watch loop: rebuild incrementally whenever a source file changes. -/// Not an OS file-watcher (deferred); it snapshots source mtimes every 500ms. -fn watch(input: &Utf8Path, output: &Utf8Path) -> Result<()> { - use std::time::{Duration, SystemTime}; - - if !input.is_dir() { - anyhow::bail!("watch requires a source directory: watch <src-dir> -o <out-dir>"); - } - let opts = BuildOptions::default(); - - let snapshot = |root: &Utf8Path| -> Vec<(Utf8PathBuf, SystemTime)> { - let mut v = Vec::new(); - for entry in walkdir::WalkDir::new(root).sort_by_file_name() { - let Ok(entry) = entry else { continue }; - if !entry.file_type().is_file() { - continue; - } - if let (Ok(path), Ok(meta)) = ( - Utf8PathBuf::from_path_buf(entry.path().to_owned()), - entry.metadata(), - ) { - let mtime = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH); - v.push((path, mtime)); - } - } - v - }; - - let report = build_site(input, output, &opts)?; - println!( - "watching {input} -> {output}: built {} page(s) ({} rendered). Ctrl-C to stop.", - report.pages.len(), - report.rendered.len() - ); - let mut last = snapshot(input); - loop { - std::thread::sleep(Duration::from_millis(500)); - let now = snapshot(input); - if now != last { - match build_site(input, output, &opts) { - Ok(report) => println!( - "rebuilt: {} rendered, {} cached", - report.rendered.len(), - report.skipped.len() - ), - Err(e) => eprintln!("build error: {e:#}"), - } - last = now; - } - } -} - /// Single-file build: read → PARSE → RENDER → TEMPLATE → write. No cross-file link /// resolution (there is no corpus to resolve against); links keep their best-effort /// URLs. Whole-site link resolution lives in [`build_site`]. The syntax stylesheet is new file mode 100644 @@ -0,0 +1,238 @@ +//! `watch`: rebuild when the source changes, driven by OS filesystem events. +//! +//! This replaced a 500ms poll loop that re-walked the whole tree twice a second to +//! compare mtimes. Native events cost nothing while nothing happens, and arrive in +//! milliseconds when something does. +//! +//! Two things matter more than the watching itself: +//! +//! 1. **Not watching our own output.** `org-ssg watch . -o _site` puts the output inside +//! the source. Rebuilding writes files, writing files raises events, and events +//! trigger a rebuild — a loop that never stops and never idles. [`ChangeFilter`] is +//! what prevents it, and it is a pure function precisely so it can be tested without +//! a filesystem. +//! 2. **Debouncing.** Saving a file in an editor is rarely one event: editors write a +//! temp file, rename it over the original, and touch the directory. Rebuilding per +//! event would rebuild several times per save. + +use std::sync::mpsc; +use std::time::Duration; + +use anyhow::{Context, Result}; +use camino::{Utf8Path, Utf8PathBuf}; +use notify::{Config as NotifyConfig, RecursiveMode, Watcher}; + +use crate::site::{build_site, BuildOptions}; + +/// How long the tree must be quiet before a rebuild starts. Long enough to coalesce an +/// editor's write burst, short enough to feel immediate. +pub const DEBOUNCE: Duration = Duration::from_millis(120); + +/// Poll interval for the fallback watcher, used where native events are unavailable +/// (some network and container filesystems). Slower than the old poll loop on purpose: +/// it is a fallback, not the primary path. +const POLL_INTERVAL: Duration = Duration::from_secs(2); + +/// Decides whether a changed path should trigger a rebuild. +/// +/// Deliberately *not* the same rule as build-time discovery. Discovery skips the config +/// file and the templates directory because they are not site content — but a change to +/// either must rebuild, because both change the output. The rule here is "would this +/// change the site?", not "is this a page?". +#[derive(Debug, Clone)] +pub struct ChangeFilter { + /// Output directory, relative to the source root, when it lives inside it. + output_inside: Option<Utf8PathBuf>, + /// Every spelling of the source root an event path might carry, longest first. + /// + /// One entry is not enough. On macOS the temp directory is `/var/…`, a symlink to + /// `/private/var/…`, and FSEvents reports the resolved path — so stripping event + /// paths with the root *as the user typed it* silently fails, every event keeps its + /// absolute path, and every absolute path looks like a source change. That includes + /// the build's own writes, so `watch` rebuilds in a loop forever. + roots: Vec<Utf8PathBuf>, +} + +impl ChangeFilter { + /// Build a filter for a source and output directory. Paths are canonicalized so + /// `.`, `./src`, an absolute path and a symlinked one all compare equal. + pub fn new(src: &Utf8Path, out: &Utf8Path) -> Self { + let canon = |p: &Utf8Path| -> Option<Utf8PathBuf> { + std::fs::canonicalize(p) + .ok() + .and_then(|p| Utf8PathBuf::from_path_buf(p).ok()) + }; + let src_canon = canon(src); + let output_inside = match (&src_canon, canon(out)) { + (Some(src), Some(out)) => out + .strip_prefix(src) + .ok() + .filter(|rel| !rel.as_str().is_empty()) + .map(|rel| rel.to_owned()), + _ => out + .strip_prefix(src) + .ok() + .filter(|rel| !rel.as_str().is_empty()) + .map(|rel| rel.to_owned()), + }; + + let mut roots: Vec<Utf8PathBuf> = src_canon.into_iter().chain([src.to_owned()]).collect(); + roots.dedup(); + // Longest first, so the most specific spelling wins. + roots.sort_by_key(|r| std::cmp::Reverse(r.as_str().len())); + ChangeFilter { + output_inside, + roots, + } + } + + /// Should a change to `rel` (relative to the source root) cause a rebuild? + pub fn is_relevant(&self, rel: &Utf8Path) -> bool { + if let Some(out) = &self.output_inside { + if rel.starts_with(out) { + return false; + } + } + // Dot-entries: `.git` churns on every command, and the cache manifest lives in + // the output anyway. Emacs' `.#lock` files land here too. + if rel + .components() + .any(|c| c.as_str().starts_with('.') && c.as_str().len() > 1) + { + return false; + } + let Some(name) = rel.file_name() else { + return false; + }; + !is_editor_scratch(name) + } + + /// Filter absolute event paths down to the relevant ones, as source-relative paths. + /// + /// A path that cannot be made relative to the source root is discarded rather than + /// kept: an event from outside the watched tree cannot be a source change, and + /// treating unrecognized paths as changes is what turns a path-spelling mismatch + /// into an endless rebuild. + pub fn relevant(&self, paths: impl IntoIterator<Item = Utf8PathBuf>) -> Vec<Utf8PathBuf> { + let mut out: Vec<Utf8PathBuf> = paths + .into_iter() + .filter_map(|p| self.to_relative(&p)) + .filter(|rel| self.is_relevant(rel)) + .collect(); + out.sort(); + out.dedup(); + out + } + + /// An event path as a source-relative path, under whichever spelling of the root it + /// arrived with. Already-relative paths pass through. + fn to_relative(&self, path: &Utf8Path) -> Option<Utf8PathBuf> { + if path.is_relative() { + return Some(path.to_owned()); + } + self.roots + .iter() + .find_map(|root| path.strip_prefix(root).ok()) + .map(Utf8Path::to_owned) + } +} + +/// Files an editor writes beside the real one. Emacs is the relevant case: it leaves +/// `file.org~` backups, which do not start with a dot and would otherwise look like a +/// content change to a tool aimed squarely at Emacs users. +fn is_editor_scratch(name: &str) -> bool { + name.ends_with('~') + || name.ends_with(".swp") + || name.ends_with(".swx") + || name.ends_with(".tmp") + || (name.starts_with('#') && name.ends_with('#')) +} + +/// Build once, then rebuild whenever the source changes. Runs until interrupted. +pub fn run(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result<()> { + if !src.is_dir() { + anyhow::bail!("watch requires a source directory: watch <src-dir> -o <out-dir>"); + } + + let report = build_site(src, out, opts)?; + println!( + "watching {src} -> {out}: built {} page(s) ({} rendered). Ctrl-C to stop.", + report.pages.len(), + report.rendered.len() + ); + + let filter = ChangeFilter::new(src, out); + let (tx, rx) = mpsc::channel(); + let mut watcher = make_watcher(tx)?; + watcher + .watch(src.as_std_path(), RecursiveMode::Recursive) + .with_context(|| format!("watching {src}"))?; + + loop { + // Block until something happens, then keep draining while events keep arriving + // inside the debounce window — one save produces several events, and they should + // produce one rebuild. + let Ok(first) = rx.recv() else { + return Ok(()); // watcher dropped + }; + let mut batch = vec![first]; + while let Ok(next) = rx.recv_timeout(DEBOUNCE) { + batch.push(next); + } + + let changed = filter.relevant(batch.into_iter().flatten()); + if changed.is_empty() { + continue; + } + + let summary = summarize(&changed); + match build_site(src, out, opts) { + Ok(report) => println!( + "{summary}: {} rendered, {} cached", + report.rendered.len(), + report.skipped.len() + ), + // A rebuild that fails must not end the session — the usual cause is a + // half-saved file, and the next keystroke fixes it. + Err(e) => eprintln!("{summary}: build failed: {e:#}"), + } + } +} + +fn summarize(changed: &[Utf8PathBuf]) -> String { + match changed { + [one] => format!("{one} changed"), + [first, rest @ ..] => format!("{first} and {} more changed", rest.len()), + [] => "changed".to_string(), + } +} + +/// The platform's native watcher, falling back to polling where that is unavailable — +/// some network and container filesystems have no event API, and `watch` failing outright +/// there would be worse than being slow. +fn make_watcher(tx: mpsc::Sender<Vec<Utf8PathBuf>>) -> Result<Box<dyn Watcher>> { + let handler = move |result: notify::Result<notify::Event>| { + if let Ok(event) = result { + let paths: Vec<Utf8PathBuf> = event + .paths + .into_iter() + .filter_map(|p| Utf8PathBuf::from_path_buf(p).ok()) + .collect(); + if !paths.is_empty() { + // The receiver going away just means the loop ended. + let _ = tx.send(paths); + } + } + }; + + match notify::RecommendedWatcher::new(handler.clone(), NotifyConfig::default()) { + Ok(watcher) => Ok(Box::new(watcher)), + Err(e) => { + eprintln!("note: native file watching unavailable ({e}); polling every {POLL_INTERVAL:?}"); + let config = NotifyConfig::default().with_poll_interval(POLL_INTERVAL); + let watcher = notify::PollWatcher::new(handler, config) + .context("starting the fallback poll watcher")?; + Ok(Box::new(watcher)) + } + } +} new file mode 100644 @@ -0,0 +1,193 @@ +//! `watch`: the change filter, and one end-to-end run against real filesystem events. +//! +//! The filter carries the weight here. `org-ssg watch . -o _site` puts the output inside +//! the source, so a rebuild writes files, writing files raises events, and events trigger +//! a rebuild — a loop that never stops. That it is a pure function is what makes the +//! guarantee testable without waiting on a filesystem. + +use std::sync::atomic::{AtomicU32, Ordering}; +use std::time::{Duration, Instant}; + +use camino::{Utf8Path, Utf8PathBuf}; + +use org_ssg::site::{build_site, BuildOptions}; +use org_ssg::watch::ChangeFilter; + +fn tmpdir(tag: &str) -> Utf8PathBuf { + static N: AtomicU32 = AtomicU32::new(0); + let n = N.fetch_add(1, Ordering::Relaxed); + let base = Utf8PathBuf::from_path_buf(std::env::temp_dir()) + .expect("utf-8 temp dir") + .join(format!("org-ssg-watch-{}-{tag}-{n}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + base +} + +// --------------------------------------------------------------------------- +// The change filter +// --------------------------------------------------------------------------- + +/// The one that matters: without it, `watch . -o _site` rebuilds forever. +#[test] +fn changes_under_the_output_directory_are_ignored() { + let root = tmpdir("filterout"); + let src = root.join("src"); + let out = src.join("_site"); + std::fs::create_dir_all(&out).unwrap(); + + let filter = ChangeFilter::new(&src, &out); + assert!(!filter.is_relevant(Utf8Path::new("_site/index.html"))); + assert!(!filter.is_relevant(Utf8Path::new("_site/blog/post.html"))); + assert!(!filter.is_relevant(Utf8Path::new("_site/.org-ssg-cache.json"))); + assert!(filter.is_relevant(Utf8Path::new("index.org")), "real sources still count"); +} + +/// An output directory outside the source cannot cause a loop, and must not accidentally +/// suppress a similarly-named source directory. +#[test] +fn an_external_output_directory_suppresses_nothing() { + let root = tmpdir("filterext"); + let src = root.join("src"); + let out = root.join("out"); + std::fs::create_dir_all(&src).unwrap(); + std::fs::create_dir_all(&out).unwrap(); + + let filter = ChangeFilter::new(&src, &out); + assert!(filter.is_relevant(Utf8Path::new("index.org"))); + assert!(filter.is_relevant(Utf8Path::new("out/notes.org")), "a source dir named `out`"); +} + +/// A change to the config or a template changes the output, so both must rebuild — even +/// though build-time *discovery* skips them as non-content. The watch rule is "would this +/// change the site?", not "is this a page?". +#[test] +fn build_inputs_trigger_a_rebuild_even_though_discovery_skips_them() { + let root = tmpdir("filterinputs"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + let filter = ChangeFilter::new(&src, &root.join("out")); + + assert!(filter.is_relevant(Utf8Path::new("org-ssg.toml"))); + assert!(filter.is_relevant(Utf8Path::new("templates/base.html"))); + assert!(filter.is_relevant(Utf8Path::new("templates/feed.xml"))); + assert!(filter.is_relevant(Utf8Path::new("style.css")), "assets are copied through"); +} + +/// `.git` churns on every command, and rebuilding the site because git wrote an index +/// lock would make watch useless in any repository. +#[test] +fn dot_directories_and_editor_scratch_files_are_ignored() { + let root = tmpdir("filterdots"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + let filter = ChangeFilter::new(&src, &root.join("out")); + + for ignored in [ + ".git/index", + ".git/objects/ab/cdef", + ".DS_Store", + "blog/.#post.org", // Emacs lock + "post.org~", // Emacs backup + ".post.org.swp", // vim + "#post.org#", // Emacs auto-save + "build.tmp", + ] { + assert!( + !filter.is_relevant(Utf8Path::new(ignored)), + "{ignored} should not trigger a rebuild" + ); + } + for relevant in ["post.org", "blog/post.org", "a-file~with-tilde.org"] { + assert!( + filter.is_relevant(Utf8Path::new(relevant)), + "{relevant} should trigger a rebuild" + ); + } +} + +/// Events arrive as absolute paths and in bursts, often naming one file several times. +#[test] +fn absolute_event_paths_are_reduced_to_a_sorted_unique_set() { + let root = tmpdir("filterrel"); + let src = root.join("src"); + let out = src.join("_site"); + std::fs::create_dir_all(&out).unwrap(); + + let filter = ChangeFilter::new(&src, &out); + let events = vec![ + src.join("b.org"), + src.join("a.org"), + src.join("b.org"), + src.join("_site/a.html"), + src.join("a.org~"), + ]; + assert_eq!( + filter.relevant(events), + vec![Utf8PathBuf::from("a.org"), Utf8PathBuf::from("b.org")] + ); +} + +// --------------------------------------------------------------------------- +// End to end +// --------------------------------------------------------------------------- + +/// Drive the real watcher against a real edit. Timing-dependent by nature, so it polls +/// for the expected result with a generous ceiling rather than sleeping a fixed amount. +#[test] +fn watching_rebuilds_the_site_when_a_source_file_changes() { + let root = tmpdir("watchrun"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + std::fs::write(src.join("index.org"), "#+TITLE: Home\n\nFirst version.\n").unwrap(); + let out = src.join("_site"); // deliberately inside the source: the loop case + + build_site(&src, &out, &BuildOptions::default()).unwrap(); + assert!(std::fs::read_to_string(out.join("index.html")) + .unwrap() + .contains("First version.")); + + let (src_t, out_t) = (src.clone(), out.clone()); + let handle = std::thread::spawn(move || { + let _ = org_ssg::watch::run(&src_t, &out_t, &BuildOptions::default()); + }); + + // Give the watcher a moment to register before making the change it should see. + std::thread::sleep(Duration::from_millis(300)); + std::fs::write(src.join("index.org"), "#+TITLE: Home\n\nSecond version.\n").unwrap(); + + let deadline = Instant::now() + Duration::from_secs(20); + let mut rebuilt = false; + while Instant::now() < deadline { + if std::fs::read_to_string(out.join("index.html")) + .map(|h| h.contains("Second version.")) + .unwrap_or(false) + { + rebuilt = true; + break; + } + std::thread::sleep(Duration::from_millis(50)); + } + assert!(rebuilt, "an edit should trigger a rebuild within 20s"); + + // The output lives inside the source, so the rebuild's own writes raised events. If + // those are not filtered out, watch spins forever. + // + // The file to watch for that is `syntax.css`, not `index.html`. The incremental + // build leaves an unchanged page alone, so `index.html` holds still even *during* a + // runaway loop — an assertion on it passes whether or not the filter works, which is + // exactly what it did before this comment existed. `syntax.css` is rewritten on + // every build, so its mtime is a direct record of how many builds have run. + let stylesheet = out.join("syntax.css"); + std::thread::sleep(Duration::from_millis(700)); + let first = std::fs::metadata(&stylesheet).unwrap().modified().unwrap(); + std::thread::sleep(Duration::from_millis(1200)); + let second = std::fs::metadata(&stylesheet).unwrap().modified().unwrap(); + assert_eq!( + first, second, + "the build's own writes must not feed back in as changes — watch is rebuilding \ + in a loop" + ); + + drop(handle); // the watcher thread ends with the process +}