krz/orgo

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

Commit a34f015d78

a34f015d787b6da2b0a0fec72c702f982f9b0004

parent: 8911dc10db

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-11 05:39 UTC

v0.11: watch on OS filesystem events

Replaces a 500ms poll loop that re-walked the whole tree twice a second to compare
mtimes. Native events (notify) cost nothing while nothing happens and arrive in
milliseconds when something does. Write bursts are debounced over 120ms, because an
editor saving a file writes a temp file, renames it over the original and touches the
directory — one edit, several events.

What counts as a change is deliberately not the rule 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. Dot-directories and editor scratch files are also out —
  including Emacs' file.org~ backups, which do not start with a dot and would otherwise
  look like content to a tool aimed at Emacs users.

Where native watching is unavailable (some network and container filesystems) it falls
back to polling and says so, rather than failing outright.

Two bugs, both found by mutation-testing the end-to-end test rather than by running it:

The first was in the test. `watch . -o _site` puts the output inside the source, so a
rebuild's writes raise events that trigger a rebuild forever, and the test asserted that
index.html's mtime held still. It does hold still during a runaway loop — the incremental
build leaves an unchanged page alone — so the assertion passed with the filter deleted.
It now watches syntax.css, which is rewritten on every build and is therefore a direct
record of how many builds have run.

With the assertion fixed, the unmutated code failed too, which is the second bug. On
macOS the temp directory is /var/…, a symlink to /private/var/…, and FSEvents reports the
resolved path. Stripping event paths with the root as the user typed it silently failed,
every event kept its absolute path, every absolute path looked like a source change, and
watch rebuilt in a loop. The filter now recognizes every spelling of the root, and a path
it cannot place is discarded rather than treated as a change.

Both mutations — deleting the output filter, and dropping the canonical root — are
verified to fail the test.

Layout: unified · split

Cargo.lock +177 −8
@@ -53,7 +53,7 @@ version = "1.1.5"
5353source = "registry+https://github.com/rust-lang/crates.io-index"
5454checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
5555dependencies = [
56 "windows-sys",
56 "windows-sys 0.61.2",
5757]
5858
5959[[package]]
@@ -64,7 +64,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
6464dependencies = [
6565 "anstyle",
6666 "once_cell_polyfill",
67 "windows-sys",
67 "windows-sys 0.61.2",
6868]
6969
7070[[package]]
@@ -225,7 +225,7 @@ checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c"
225225dependencies = [
226226 "encode_unicode",
227227 "libc",
228 "windows-sys",
228 "windows-sys 0.61.2",
229229]
230230
231231[[package]]
@@ -314,7 +314,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
314314checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
315315dependencies = [
316316 "libc",
317 "windows-sys",
317 "windows-sys 0.61.2",
318318]
319319
320320[[package]]
@@ -345,6 +345,15 @@ version = "1.0.7"
345345source = "registry+https://github.com/rust-lang/crates.io-index"
346346checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
347347
348[[package]]
349name = "fsevent-sys"
350version = "4.1.0"
351source = "registry+https://github.com/rust-lang/crates.io-index"
352checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
353dependencies = [
354 "libc",
355]
356
348357[[package]]
349358name = "futures-core"
350359version = "0.3.33"
@@ -426,6 +435,26 @@ dependencies = [
426435 "hashbrown",
427436]
428437
438[[package]]
439name = "inotify"
440version = "0.11.4"
441source = "registry+https://github.com/rust-lang/crates.io-index"
442checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8"
443dependencies = [
444 "bitflags",
445 "inotify-sys",
446 "libc",
447]
448
449[[package]]
450name = "inotify-sys"
451version = "0.1.8"
452source = "registry+https://github.com/rust-lang/crates.io-index"
453checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d"
454dependencies = [
455 "libc",
456]
457
429458[[package]]
430459name = "insta"
431460version = "1.48.0"
@@ -462,6 +491,26 @@ dependencies = [
462491 "wasm-bindgen",
463492]
464493
494[[package]]
495name = "kqueue"
496version = "1.2.1"
497source = "registry+https://github.com/rust-lang/crates.io-index"
498checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea"
499dependencies = [
500 "kqueue-sys",
501 "libc",
502]
503
504[[package]]
505name = "kqueue-sys"
506version = "1.1.2"
507source = "registry+https://github.com/rust-lang/crates.io-index"
508checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087"
509dependencies = [
510 "bitflags",
511 "libc",
512]
513
465514[[package]]
466515name = "libc"
467516version = "0.2.189"
@@ -518,6 +567,45 @@ dependencies = [
518567 "simd-adler32",
519568]
520569
570[[package]]
571name = "mio"
572version = "1.2.2"
573source = "registry+https://github.com/rust-lang/crates.io-index"
574checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
575dependencies = [
576 "libc",
577 "log",
578 "wasi",
579 "windows-sys 0.61.2",
580]
581
582[[package]]
583name = "notify"
584version = "8.2.0"
585source = "registry+https://github.com/rust-lang/crates.io-index"
586checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
587dependencies = [
588 "bitflags",
589 "fsevent-sys",
590 "inotify",
591 "kqueue",
592 "libc",
593 "log",
594 "mio",
595 "notify-types",
596 "walkdir",
597 "windows-sys 0.60.2",
598]
599
600[[package]]
601name = "notify-types"
602version = "2.1.0"
603source = "registry+https://github.com/rust-lang/crates.io-index"
604checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a"
605dependencies = [
606 "bitflags",
607]
608
521609[[package]]
522610name = "num-conv"
523611version = "0.2.2"
@@ -569,7 +657,7 @@ dependencies = [
569657
570658[[package]]
571659name = "org-ssg"
572version = "0.10.0"
660version = "0.11.0"
573661dependencies = [
574662 "anyhow",
575663 "blake3",
@@ -578,6 +666,7 @@ dependencies = [
578666 "clap",
579667 "insta",
580668 "minijinja",
669 "notify",
581670 "rayon",
582671 "serde",
583672 "serde_json",
@@ -687,7 +776,7 @@ dependencies = [
687776 "errno",
688777 "libc",
689778 "linux-raw-sys",
690 "windows-sys",
779 "windows-sys 0.61.2",
691780]
692781
693782[[package]]
@@ -840,7 +929,7 @@ dependencies = [
840929 "getrandom",
841930 "once_cell",
842931 "rustix",
843 "windows-sys",
932 "windows-sys 0.61.2",
844933]
845934
846935[[package]]
@@ -954,6 +1043,12 @@ dependencies = [
9541043 "winapi-util",
9551044]
9561045
1046[[package]]
1047name = "wasi"
1048version = "0.11.1+wasi-snapshot-preview1"
1049source = "registry+https://github.com/rust-lang/crates.io-index"
1050checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
1051
9571052[[package]]
9581053name = "wasm-bindgen"
9591054version = "0.2.127"
@@ -1005,7 +1100,7 @@ version = "0.1.11"
10051100source = "registry+https://github.com/rust-lang/crates.io-index"
10061101checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
10071102dependencies = [
1008 "windows-sys",
1103 "windows-sys 0.61.2",
10091104]
10101105
10111106[[package]]
@@ -1067,6 +1162,15 @@ dependencies = [
10671162 "windows-link",
10681163]
10691164
1165[[package]]
1166name = "windows-sys"
1167version = "0.60.2"
1168source = "registry+https://github.com/rust-lang/crates.io-index"
1169checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
1170dependencies = [
1171 "windows-targets",
1172]
1173
10701174[[package]]
10711175name = "windows-sys"
10721176version = "0.61.2"
@@ -1076,6 +1180,71 @@ dependencies = [
10761180 "windows-link",
10771181]
10781182
1183[[package]]
1184name = "windows-targets"
1185version = "0.53.5"
1186source = "registry+https://github.com/rust-lang/crates.io-index"
1187checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
1188dependencies = [
1189 "windows-link",
1190 "windows_aarch64_gnullvm",
1191 "windows_aarch64_msvc",
1192 "windows_i686_gnu",
1193 "windows_i686_gnullvm",
1194 "windows_i686_msvc",
1195 "windows_x86_64_gnu",
1196 "windows_x86_64_gnullvm",
1197 "windows_x86_64_msvc",
1198]
1199
1200[[package]]
1201name = "windows_aarch64_gnullvm"
1202version = "0.53.1"
1203source = "registry+https://github.com/rust-lang/crates.io-index"
1204checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
1205
1206[[package]]
1207name = "windows_aarch64_msvc"
1208version = "0.53.1"
1209source = "registry+https://github.com/rust-lang/crates.io-index"
1210checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
1211
1212[[package]]
1213name = "windows_i686_gnu"
1214version = "0.53.1"
1215source = "registry+https://github.com/rust-lang/crates.io-index"
1216checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
1217
1218[[package]]
1219name = "windows_i686_gnullvm"
1220version = "0.53.1"
1221source = "registry+https://github.com/rust-lang/crates.io-index"
1222checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
1223
1224[[package]]
1225name = "windows_i686_msvc"
1226version = "0.53.1"
1227source = "registry+https://github.com/rust-lang/crates.io-index"
1228checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
1229
1230[[package]]
1231name = "windows_x86_64_gnu"
1232version = "0.53.1"
1233source = "registry+https://github.com/rust-lang/crates.io-index"
1234checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
1235
1236[[package]]
1237name = "windows_x86_64_gnullvm"
1238version = "0.53.1"
1239source = "registry+https://github.com/rust-lang/crates.io-index"
1240checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
1241
1242[[package]]
1243name = "windows_x86_64_msvc"
1244version = "0.53.1"
1245source = "registry+https://github.com/rust-lang/crates.io-index"
1246checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
1247
10791248[[package]]
10801249name = "winnow"
10811250version = "1.0.4"
Cargo.toml +2 −1
@@ -1,6 +1,6 @@
11[package]
22name = "org-ssg"
3version = "0.10.0"
3version = "0.11.0"
44edition = "2021"
55description = "Org-mode static site generator that renders the org element tree straight to HTML"
66license = "MIT"
@@ -32,6 +32,7 @@ anyhow = "1"
3232thiserror = "2"
3333rayon = "1.12.0"
3434toml = "1.1.4"
35notify = "8.2.0"
3536
3637[dev-dependencies]
3738insta = { version = "1", features = ["json"] }
README.md +31 −6
@@ -295,13 +295,14 @@ all-of-org. Phase 0 checked this line against a real 179-file corpus and found i
295295| 3 | Inline objects — emphasis, links, bare URLs, footnote refs, timestamps | done |
296296| 4 | Rendering to HTML — tree walk, tables, footnote two-pass, minijinja templating, syntect highlighting | done |
297297| 5 | Link resolution + symbol table (INDEX + RESOLVE, used-target list, broken-link reporting) | done |
298| 6 | Incremental build layer (hashing, dep graph, invalidation) done; `watch` is a simple poll loop | done |
298| 6 | Incremental build layer (hashing, dep graph, invalidation); `watch` on OS filesystem events | done |
299299| **7** | **Hardening: rayon parallelism, error locations in parse diagnostics** | **done** |
300300| **8** | **General use: config file, user templates, nav modes, `init` scaffold, safe discovery** | **done** |
301301| **9** | **Generated listing pages: `[[collections]]`, sorted indexes, feeds via XML templates** | **done** |
302302| **10** | **Grouped collections: one page per tag plus a tag index — full parity with the incumbent** | **done** |
303303| **11** | **Pagination: numbered pages with a `paginator` context, composing with grouping** | **done** |
304304| **12** | **`base_url`: `absolute`/`rfc822` filters, a valid RSS feed in the scaffold, canonical links** | **done** |
305| **13** | **`watch` on OS filesystem events, debounced, with the feedback loop closed** | **done** |
305306
306307### v0.2 in / out
307308
@@ -395,8 +396,32 @@ as literal text; drawers other than PROPERTIES are captured and dropped; unmodel
395396types keep their content verbatim.
396397
397398**Still out:** `#+TODO:` per-file keyword sequences; planning lines
398(`SCHEDULED:`/`DEADLINE:`), which render as ordinary paragraphs; fixed-width `: ` lines;
399and the `watch` fs-notify integration.
399(`SCHEDULED:`/`DEADLINE:`), which render as ordinary paragraphs; and fixed-width `: `
400lines.
401
402## Watching
403
404```bash
405cargo run -- watch my-site -o _site
406```
407
408Rebuilds on OS filesystem events rather than polling, so it costs nothing while nothing
409happens. Write bursts are debounced — an editor saving a file writes a temp file, renames
410it over the original and touches the directory, which is one edit and several events.
411
412Two rules decide what counts as a change, and they are not the same rules the build uses
413to find content:
414
415- **A build input is a change.** Editing `org-ssg.toml` or a template rebuilds, even
416 though discovery skips both as non-content. The question is "would this change the
417 site?", not "is this a page?".
418- **Our own output is not.** `watch . -o _site` puts the output inside the source, so a
419 rebuild's writes raise events that would trigger a rebuild, forever. Dot-directories go
420 the same way — `.git` churns on every command — as do editor scratch files, including
421 Emacs' `file.org~` backups, which do not start with a dot.
422
423Where native watching is unavailable (some network and container filesystems), it falls
424back to polling and says so, rather than failing.
400425
401426## Phase 0: the corpus audit and the Emacs oracle
402427
@@ -558,20 +583,20 @@ Parser is hand-written recursive descent (not `nom`/`chumsky`/`pest` — org is
558583line-oriented and context-sensitive, not clean CFG). Key crates: `syntect` (syntax
559584highlighting, behind a `Highlighter` trait so tree-sitter can be swapped in later),
560585`minijinja` (runtime templates), `blake3` (content/cache hashing), `rayon` (parallel
561PARSE/RESOLVE/RENDER), `chrono`, `camino`, `walkdir`, `clap`, `anyhow`/`thiserror`.
586PARSE/RESOLVE/RENDER), `notify` (filesystem events for `watch`), `toml` (config), `chrono`, `camino`, `walkdir`, `clap`, `anyhow`/`thiserror`.
562587`insta` for snapshot tests, and `emacs --batch` — optional, and only for the oracle.
563588
564589## Build & test
565590
566591```
567592cargo build
568cargo test # 122 tests
593cargo test # 128 tests
569594cargo run -- init my-site # scaffold a new site
570595cargo run -- build fixtures/minimal.org -o minimal.html # single file
571596cargo run -- build fixtures/site -o _site # whole site (incremental)
572597cargo run -- audit fixtures/site # corpus audit (Phase 0)
573598cargo run -- build fixtures/site -o _site --no-cache # force a full rebuild
574cargo run -- watch fixtures/site -o _site # poll + rebuild on change
599cargo run -- watch fixtures/site -o _site # rebuild on filesystem events
575600cargo run -- clean _site # remove output + cache
576601```
577602
src/lib.rs +1
@@ -19,3 +19,4 @@ pub mod resolve;
1919pub mod site;
2020pub mod template;
2121pub mod util;
22pub mod watch;
src/main.rs +26 −57
@@ -40,13 +40,23 @@ enum Command {
4040 #[arg(long, value_name = "FILE")]
4141 config: Option<Utf8PathBuf>,
4242 },
43 /// Watch a source directory and rebuild incrementally on change (simple poll loop).
43 /// Watch a source directory and rebuild incrementally on change, driven by OS
44 /// filesystem events.
4445 Watch {
4546 /// Source directory to watch.
4647 input: Utf8PathBuf,
4748 /// Output directory.
4849 #[arg(short, long)]
4950 output: Utf8PathBuf,
51 /// Bypass the incremental cache on every rebuild.
52 #[arg(long)]
53 no_cache: bool,
54 /// Treat broken links and parse diagnostics as errors.
55 #[arg(long)]
56 strict: bool,
57 /// Config file to use, overriding `org-ssg.toml` in the source directory.
58 #[arg(long, value_name = "FILE")]
59 config: Option<Utf8PathBuf>,
5060 },
5161 /// Remove the build output directory (which holds the cache manifest).
5262 Clean {
@@ -105,10 +115,21 @@ fn main() -> Result<()> {
105115 }
106116 Ok(())
107117 }
108 // Watch is intentionally a minimal poll loop, not an OS file-watch (spec §5 Phase
109 // 6 lists `watch`; the real fs-notify integration is deferred). It rebuilds
110 // incrementally whenever any source file's mtime advances.
111 Command::Watch { input, output } => watch(&input, &output),
118 Command::Watch {
119 input,
120 output,
121 no_cache,
122 strict,
123 config,
124 } => org_ssg::watch::run(
125 &input,
126 &output,
127 &BuildOptions {
128 no_cache,
129 strict,
130 config_path: config,
131 },
132 ),
112133 Command::Audit { input } => {
113134 let result = org_ssg::audit::audit(&input)?;
114135 print!("{}", org_ssg::audit::report(&result));
@@ -194,58 +215,6 @@ fn init(dir: &Utf8Path) -> Result<()> {
194215 Ok(())
195216}
196217
197/// Minimal poll-based watch loop: rebuild incrementally whenever a source file changes.
198/// Not an OS file-watcher (deferred); it snapshots source mtimes every 500ms.
199fn watch(input: &Utf8Path, output: &Utf8Path) -> Result<()> {
200 use std::time::{Duration, SystemTime};
201
202 if !input.is_dir() {
203 anyhow::bail!("watch requires a source directory: watch <src-dir> -o <out-dir>");
204 }
205 let opts = BuildOptions::default();
206
207 let snapshot = |root: &Utf8Path| -> Vec<(Utf8PathBuf, SystemTime)> {
208 let mut v = Vec::new();
209 for entry in walkdir::WalkDir::new(root).sort_by_file_name() {
210 let Ok(entry) = entry else { continue };
211 if !entry.file_type().is_file() {
212 continue;
213 }
214 if let (Ok(path), Ok(meta)) = (
215 Utf8PathBuf::from_path_buf(entry.path().to_owned()),
216 entry.metadata(),
217 ) {
218 let mtime = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
219 v.push((path, mtime));
220 }
221 }
222 v
223 };
224
225 let report = build_site(input, output, &opts)?;
226 println!(
227 "watching {input} -> {output}: built {} page(s) ({} rendered). Ctrl-C to stop.",
228 report.pages.len(),
229 report.rendered.len()
230 );
231 let mut last = snapshot(input);
232 loop {
233 std::thread::sleep(Duration::from_millis(500));
234 let now = snapshot(input);
235 if now != last {
236 match build_site(input, output, &opts) {
237 Ok(report) => println!(
238 "rebuilt: {} rendered, {} cached",
239 report.rendered.len(),
240 report.skipped.len()
241 ),
242 Err(e) => eprintln!("build error: {e:#}"),
243 }
244 last = now;
245 }
246 }
247}
248
249218/// Single-file build: read → PARSE → RENDER → TEMPLATE → write. No cross-file link
250219/// resolution (there is no corpus to resolve against); links keep their best-effort
251220/// URLs. Whole-site link resolution lives in [`build_site`]. The syntax stylesheet is
src/watch.rs added +238
@@ -0,0 +1,238 @@
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.** `org-ssg 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 let canon = |p: &Utf8Path| -> Option<Utf8PathBuf> {
61 std::fs::canonicalize(p)
62 .ok()
63 .and_then(|p| Utf8PathBuf::from_path_buf(p).ok())
64 };
65 let src_canon = canon(src);
66 let output_inside = match (&src_canon, canon(out)) {
67 (Some(src), Some(out)) => out
68 .strip_prefix(src)
69 .ok()
70 .filter(|rel| !rel.as_str().is_empty())
71 .map(|rel| rel.to_owned()),
72 _ => out
73 .strip_prefix(src)
74 .ok()
75 .filter(|rel| !rel.as_str().is_empty())
76 .map(|rel| rel.to_owned()),
77 };
78
79 let mut roots: Vec<Utf8PathBuf> = src_canon.into_iter().chain([src.to_owned()]).collect();
80 roots.dedup();
81 // Longest first, so the most specific spelling wins.
82 roots.sort_by_key(|r| std::cmp::Reverse(r.as_str().len()));
83 ChangeFilter {
84 output_inside,
85 roots,
86 }
87 }
88
89 /// Should a change to `rel` (relative to the source root) cause a rebuild?
90 pub fn is_relevant(&self, rel: &Utf8Path) -> bool {
91 if let Some(out) = &self.output_inside {
92 if rel.starts_with(out) {
93 return false;
94 }
95 }
96 // Dot-entries: `.git` churns on every command, and the cache manifest lives in
97 // the output anyway. Emacs' `.#lock` files land here too.
98 if rel
99 .components()
100 .any(|c| c.as_str().starts_with('.') && c.as_str().len() > 1)
101 {
102 return false;
103 }
104 let Some(name) = rel.file_name() else {
105 return false;
106 };
107 !is_editor_scratch(name)
108 }
109
110 /// Filter absolute event paths down to the relevant ones, as source-relative paths.
111 ///
112 /// A path that cannot be made relative to the source root is discarded rather than
113 /// kept: an event from outside the watched tree cannot be a source change, and
114 /// treating unrecognized paths as changes is what turns a path-spelling mismatch
115 /// into an endless rebuild.
116 pub fn relevant(&self, paths: impl IntoIterator<Item = Utf8PathBuf>) -> Vec<Utf8PathBuf> {
117 let mut out: Vec<Utf8PathBuf> = paths
118 .into_iter()
119 .filter_map(|p| self.to_relative(&p))
120 .filter(|rel| self.is_relevant(rel))
121 .collect();
122 out.sort();
123 out.dedup();
124 out
125 }
126
127 /// An event path as a source-relative path, under whichever spelling of the root it
128 /// arrived with. Already-relative paths pass through.
129 fn to_relative(&self, path: &Utf8Path) -> Option<Utf8PathBuf> {
130 if path.is_relative() {
131 return Some(path.to_owned());
132 }
133 self.roots
134 .iter()
135 .find_map(|root| path.strip_prefix(root).ok())
136 .map(Utf8Path::to_owned)
137 }
138}
139
140/// Files an editor writes beside the real one. Emacs is the relevant case: it leaves
141/// `file.org~` backups, which do not start with a dot and would otherwise look like a
142/// content change to a tool aimed squarely at Emacs users.
143fn is_editor_scratch(name: &str) -> bool {
144 name.ends_with('~')
145 || name.ends_with(".swp")
146 || name.ends_with(".swx")
147 || name.ends_with(".tmp")
148 || (name.starts_with('#') && name.ends_with('#'))
149}
150
151/// Build once, then rebuild whenever the source changes. Runs until interrupted.
152pub fn run(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result<()> {
153 if !src.is_dir() {
154 anyhow::bail!("watch requires a source directory: watch <src-dir> -o <out-dir>");
155 }
156
157 let report = build_site(src, out, opts)?;
158 println!(
159 "watching {src} -> {out}: built {} page(s) ({} rendered). Ctrl-C to stop.",
160 report.pages.len(),
161 report.rendered.len()
162 );
163
164 let filter = ChangeFilter::new(src, out);
165 let (tx, rx) = mpsc::channel();
166 let mut watcher = make_watcher(tx)?;
167 watcher
168 .watch(src.as_std_path(), RecursiveMode::Recursive)
169 .with_context(|| format!("watching {src}"))?;
170
171 loop {
172 // Block until something happens, then keep draining while events keep arriving
173 // inside the debounce window — one save produces several events, and they should
174 // produce one rebuild.
175 let Ok(first) = rx.recv() else {
176 return Ok(()); // watcher dropped
177 };
178 let mut batch = vec![first];
179 while let Ok(next) = rx.recv_timeout(DEBOUNCE) {
180 batch.push(next);
181 }
182
183 let changed = filter.relevant(batch.into_iter().flatten());
184 if changed.is_empty() {
185 continue;
186 }
187
188 let summary = summarize(&changed);
189 match build_site(src, out, opts) {
190 Ok(report) => println!(
191 "{summary}: {} rendered, {} cached",
192 report.rendered.len(),
193 report.skipped.len()
194 ),
195 // A rebuild that fails must not end the session — the usual cause is a
196 // half-saved file, and the next keystroke fixes it.
197 Err(e) => eprintln!("{summary}: build failed: {e:#}"),
198 }
199 }
200}
201
202fn summarize(changed: &[Utf8PathBuf]) -> String {
203 match changed {
204 [one] => format!("{one} changed"),
205 [first, rest @ ..] => format!("{first} and {} more changed", rest.len()),
206 [] => "changed".to_string(),
207 }
208}
209
210/// The platform's native watcher, falling back to polling where that is unavailable —
211/// some network and container filesystems have no event API, and `watch` failing outright
212/// there would be worse than being slow.
213fn make_watcher(tx: mpsc::Sender<Vec<Utf8PathBuf>>) -> Result<Box<dyn Watcher>> {
214 let handler = move |result: notify::Result<notify::Event>| {
215 if let Ok(event) = result {
216 let paths: Vec<Utf8PathBuf> = event
217 .paths
218 .into_iter()
219 .filter_map(|p| Utf8PathBuf::from_path_buf(p).ok())
220 .collect();
221 if !paths.is_empty() {
222 // The receiver going away just means the loop ended.
223 let _ = tx.send(paths);
224 }
225 }
226 };
227
228 match notify::RecommendedWatcher::new(handler.clone(), NotifyConfig::default()) {
229 Ok(watcher) => Ok(Box::new(watcher)),
230 Err(e) => {
231 eprintln!("note: native file watching unavailable ({e}); polling every {POLL_INTERVAL:?}");
232 let config = NotifyConfig::default().with_poll_interval(POLL_INTERVAL);
233 let watcher = notify::PollWatcher::new(handler, config)
234 .context("starting the fallback poll watcher")?;
235 Ok(Box::new(watcher))
236 }
237 }
238}
tests/watch.rs added +193
@@ -0,0 +1,193 @@
1//! `watch`: the change filter, and one end-to-end run against real filesystem events.
2//!
3//! The filter carries the weight here. `org-ssg watch . -o _site` puts the output inside
4//! the source, so a rebuild writes files, writing files raises events, and events trigger
5//! a rebuild — a loop that never stops. That it is a pure function is what makes the
6//! guarantee testable without waiting on a filesystem.
7
8use std::sync::atomic::{AtomicU32, Ordering};
9use std::time::{Duration, Instant};
10
11use camino::{Utf8Path, Utf8PathBuf};
12
13use org_ssg::site::{build_site, BuildOptions};
14use org_ssg::watch::ChangeFilter;
15
16fn tmpdir(tag: &str) -> Utf8PathBuf {
17 static N: AtomicU32 = AtomicU32::new(0);
18 let n = N.fetch_add(1, Ordering::Relaxed);
19 let base = Utf8PathBuf::from_path_buf(std::env::temp_dir())
20 .expect("utf-8 temp dir")
21 .join(format!("org-ssg-watch-{}-{tag}-{n}", std::process::id()));
22 let _ = std::fs::remove_dir_all(&base);
23 std::fs::create_dir_all(&base).unwrap();
24 base
25}
26
27// ---------------------------------------------------------------------------
28// The change filter
29// ---------------------------------------------------------------------------
30
31/// The one that matters: without it, `watch . -o _site` rebuilds forever.
32#[test]
33fn changes_under_the_output_directory_are_ignored() {
34 let root = tmpdir("filterout");
35 let src = root.join("src");
36 let out = src.join("_site");
37 std::fs::create_dir_all(&out).unwrap();
38
39 let filter = ChangeFilter::new(&src, &out);
40 assert!(!filter.is_relevant(Utf8Path::new("_site/index.html")));
41 assert!(!filter.is_relevant(Utf8Path::new("_site/blog/post.html")));
42 assert!(!filter.is_relevant(Utf8Path::new("_site/.org-ssg-cache.json")));
43 assert!(filter.is_relevant(Utf8Path::new("index.org")), "real sources still count");
44}
45
46/// An output directory outside the source cannot cause a loop, and must not accidentally
47/// suppress a similarly-named source directory.
48#[test]
49fn an_external_output_directory_suppresses_nothing() {
50 let root = tmpdir("filterext");
51 let src = root.join("src");
52 let out = root.join("out");
53 std::fs::create_dir_all(&src).unwrap();
54 std::fs::create_dir_all(&out).unwrap();
55
56 let filter = ChangeFilter::new(&src, &out);
57 assert!(filter.is_relevant(Utf8Path::new("index.org")));
58 assert!(filter.is_relevant(Utf8Path::new("out/notes.org")), "a source dir named `out`");
59}
60
61/// A change to the config or a template changes the output, so both must rebuild — even
62/// though build-time *discovery* skips them as non-content. The watch rule is "would this
63/// change the site?", not "is this a page?".
64#[test]
65fn build_inputs_trigger_a_rebuild_even_though_discovery_skips_them() {
66 let root = tmpdir("filterinputs");
67 let src = root.join("src");
68 std::fs::create_dir_all(&src).unwrap();
69 let filter = ChangeFilter::new(&src, &root.join("out"));
70
71 assert!(filter.is_relevant(Utf8Path::new("org-ssg.toml")));
72 assert!(filter.is_relevant(Utf8Path::new("templates/base.html")));
73 assert!(filter.is_relevant(Utf8Path::new("templates/feed.xml")));
74 assert!(filter.is_relevant(Utf8Path::new("style.css")), "assets are copied through");
75}
76
77/// `.git` churns on every command, and rebuilding the site because git wrote an index
78/// lock would make watch useless in any repository.
79#[test]
80fn dot_directories_and_editor_scratch_files_are_ignored() {
81 let root = tmpdir("filterdots");
82 let src = root.join("src");
83 std::fs::create_dir_all(&src).unwrap();
84 let filter = ChangeFilter::new(&src, &root.join("out"));
85
86 for ignored in [
87 ".git/index",
88 ".git/objects/ab/cdef",
89 ".DS_Store",
90 "blog/.#post.org", // Emacs lock
91 "post.org~", // Emacs backup
92 ".post.org.swp", // vim
93 "#post.org#", // Emacs auto-save
94 "build.tmp",
95 ] {
96 assert!(
97 !filter.is_relevant(Utf8Path::new(ignored)),
98 "{ignored} should not trigger a rebuild"
99 );
100 }
101 for relevant in ["post.org", "blog/post.org", "a-file~with-tilde.org"] {
102 assert!(
103 filter.is_relevant(Utf8Path::new(relevant)),
104 "{relevant} should trigger a rebuild"
105 );
106 }
107}
108
109/// Events arrive as absolute paths and in bursts, often naming one file several times.
110#[test]
111fn absolute_event_paths_are_reduced_to_a_sorted_unique_set() {
112 let root = tmpdir("filterrel");
113 let src = root.join("src");
114 let out = src.join("_site");
115 std::fs::create_dir_all(&out).unwrap();
116
117 let filter = ChangeFilter::new(&src, &out);
118 let events = vec![
119 src.join("b.org"),
120 src.join("a.org"),
121 src.join("b.org"),
122 src.join("_site/a.html"),
123 src.join("a.org~"),
124 ];
125 assert_eq!(
126 filter.relevant(events),
127 vec![Utf8PathBuf::from("a.org"), Utf8PathBuf::from("b.org")]
128 );
129}
130
131// ---------------------------------------------------------------------------
132// End to end
133// ---------------------------------------------------------------------------
134
135/// Drive the real watcher against a real edit. Timing-dependent by nature, so it polls
136/// for the expected result with a generous ceiling rather than sleeping a fixed amount.
137#[test]
138fn watching_rebuilds_the_site_when_a_source_file_changes() {
139 let root = tmpdir("watchrun");
140 let src = root.join("src");
141 std::fs::create_dir_all(&src).unwrap();
142 std::fs::write(src.join("index.org"), "#+TITLE: Home\n\nFirst version.\n").unwrap();
143 let out = src.join("_site"); // deliberately inside the source: the loop case
144
145 build_site(&src, &out, &BuildOptions::default()).unwrap();
146 assert!(std::fs::read_to_string(out.join("index.html"))
147 .unwrap()
148 .contains("First version."));
149
150 let (src_t, out_t) = (src.clone(), out.clone());
151 let handle = std::thread::spawn(move || {
152 let _ = org_ssg::watch::run(&src_t, &out_t, &BuildOptions::default());
153 });
154
155 // Give the watcher a moment to register before making the change it should see.
156 std::thread::sleep(Duration::from_millis(300));
157 std::fs::write(src.join("index.org"), "#+TITLE: Home\n\nSecond version.\n").unwrap();
158
159 let deadline = Instant::now() + Duration::from_secs(20);
160 let mut rebuilt = false;
161 while Instant::now() < deadline {
162 if std::fs::read_to_string(out.join("index.html"))
163 .map(|h| h.contains("Second version."))
164 .unwrap_or(false)
165 {
166 rebuilt = true;
167 break;
168 }
169 std::thread::sleep(Duration::from_millis(50));
170 }
171 assert!(rebuilt, "an edit should trigger a rebuild within 20s");
172
173 // The output lives inside the source, so the rebuild's own writes raised events. If
174 // those are not filtered out, watch spins forever.
175 //
176 // The file to watch for that is `syntax.css`, not `index.html`. The incremental
177 // build leaves an unchanged page alone, so `index.html` holds still even *during* a
178 // runaway loop — an assertion on it passes whether or not the filter works, which is
179 // exactly what it did before this comment existed. `syntax.css` is rewritten on
180 // every build, so its mtime is a direct record of how many builds have run.
181 let stylesheet = out.join("syntax.css");
182 std::thread::sleep(Duration::from_millis(700));
183 let first = std::fs::metadata(&stylesheet).unwrap().modified().unwrap();
184 std::thread::sleep(Duration::from_millis(1200));
185 let second = std::fs::metadata(&stylesheet).unwrap().modified().unwrap();
186 assert_eq!(
187 first, second,
188 "the build's own writes must not feed back in as changes — watch is rebuilding \
189 in a loop"
190 );
191
192 drop(handle); // the watcher thread ends with the process
193}