Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_austenite/src/bin/austenite.rs

29.1 KiB, 169 runs

created by r1870400018:37570, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! `austenite` -- compile a Typst document to a set of pages.
2//!
3//! Reads a Typst root, follows its `#include` chain through the [`book`](oxedyne_fe2o3_austenite::book)
4//! assembler (or, for a lone file, straight through the [`lang`](oxedyne_fe2o3_austenite::lang) reader),
5//! authors the block stream through the block layer, runs the two-pass driver to a fixed point, decorates
6//! each page with a running head and a folio, and writes every page as SVG alongside the resolved ledger
7//! and a single PDF of the whole run.
8//!
9//! A construct the reader cannot yet set -- a `#show` rule, a `#columns` wrapper, an unknown `#func` --
10//! is passed over rather than failing the compile, and the lone-file path reports the tally on one terse
11//! line so a dropped construct is visible.
12//!
13//! Usage: `austenite <SOURCE.typ> [OUTPUT_DIR]` (default output `austenite-out`), or
14//! `austenite --watch <SOURCE.typ> [OUTPUT_DIR]` to recompile on every change to the root, its includes,
15//! its `config.typ`, or its assets.
16
17use oxedyne_fe2o3_austenite::{
18 compile,
19 emit::{
20 self,
21 svg,
22 },
23 ir::DrawOp,
24 ledger::Ledger,
25 lang,
26 memo::Memo,
27 page::{
28 Frame,
29 Page,
30 PlacedKind,
31 },
32 watch,
33};
34
35use oxedyne_fe2o3_core::prelude::*;
36use oxedyne_fe2o3_graphics::pdf::PdfPage;
37use oxedyne_fe2o3_jdat::prelude::*;
38
39use std::fs::File;
40use std::io::BufWriter;
41use std::path::PathBuf;
42use std::sync::Arc;
43use std::time::Duration;
44
45/// An estimate of the extra memory rendering this page holds in flight, in bytes -- dominated by the
46/// figure rasters, which [`emit::pdf::render_page`] copies out of the shared frame into the page's own
47/// straight-RGB (and, when translucent, grey) buffers. A text page estimates near zero; a full-page
48/// illustration estimates several megabytes. The chunker sums this across a forming chunk and closes it
49/// before the sum would breach the memory budget, so an illustration-dense book self-limits its window
50/// while a text book packs a chunk full. The glyph outlines are not counted: a chunk holds them until the
51/// writer serialises its pages, but they were shown to stay flat to a wide window, and text is now stored
52/// once per distinct glyph rather than baked per occurrence, so the held bytes are smaller than before.
53fn page_hold_estimate(page: &Page) -> usize {
54 // Rough bytes-per-unit for the SVG text and PDF content a page's ink expands to while it is in flight.
55 // A glyph becomes an outline of a couple of dozen path operators in each of the two serialisations; a
56 // figure's own path is written op for op; a raster is copied sample by sample. The constants are
57 // deliberately generous -- the estimate gates concurrency, so over-counting only narrows a chunk.
58 const PER_GLYPH: usize = 800; // one glyph's outline, in both serialisations (measured)
59 const PER_SEG: usize = 320; // one figure path segment, across every live buffer (measured)
60 const PER_SAMPLE: usize = 8; // RGB copy plus soft mask, with headroom
61
62 let mut bytes = 0usize;
63 for placed in &page.frame.placed {
64 match &placed.kind {
65 PlacedKind::Text(shaped) => {
66 bytes += shaped.run().glyphs.len() * PER_GLYPH;
67 },
68 PlacedKind::Graphic(g) => {
69 for op in &g.ops {
70 match op {
71 DrawOp::Fill { path, .. } => bytes += path.segs().len() * PER_SEG,
72 DrawOp::Stroke { path, .. } => bytes += path.segs().len() * PER_SEG,
73 DrawOp::Image { image, .. } => bytes += image.width * image.height * PER_SAMPLE,
74 }
75 }
76 },
77 _ => {},
78 }
79 }
80 bytes
81}
82
83/// One page's PDF draw list, built off the writer's thread. The worker fetches the page's glyph outlines
84/// (warming the shared cache) and writes the page's SVG straight to its own file, since an SVG page owes
85/// nothing to page order. The content stream itself is serialised on the writer's thread, in page order,
86/// where a glyph's Type-3 code is assigned deterministically; the draw list travels back for that.
87struct Prepared {
88 pdf: PdfPage,
89}
90
91/// The result of a compile, for the caller to report: the page count, the number of driver passes to
92/// the fixed point, the count of anchors in the resolved ledger, and the terse skip line (or `None`
93/// when nothing was skipped).
94struct CompileStats {
95 pages: usize,
96 passes: u32,
97 anchors: usize,
98 skip_line: Option<String>,
99 refusals: lang::Refusals, // every refused site, for `--explain`; the terse `skip_line` stays the default
100}
101
102/// Renders one page to both artefacts, the pure work a chunk runs across the cores. The SVG is written
103/// to its file here and dropped; the PDF draw list is built here -- fetching each glyph's outline, the
104/// bulk of the cost -- and returned for the ordered writer to serialise and frame in page order.
105fn render_page_pair(page: &Page, out_dir: &str, write_svg: bool) -> Outcome<Prepared> {
106 // When the memo drives the run (the `--watch` loop), the SVG is emitted sequentially through the memo
107 // in a pre-pass, so the parallel worker here builds only the PDF; a one-shot compile writes both.
108 if write_svg {
109 let svg = res!(svg::render_page(page));
110 let path = fmt!("{}/page-{:03}.svg", out_dir, page.number);
111 res!(std::fs::write(&path, &svg));
112 drop(svg);
113 }
114
115 let pdf = res!(emit::pdf::render_page(page));
116 Ok(Prepared { pdf })
117}
118
119/// The detailed report `--explain` prints: every refused site, one per line, as `file:line:col: <class>:
120/// skipped <name>` with the source line beneath it and a `^` caret under the column the span starts at.
121/// Each referenced file is read at most once, cached by path, and a file that has since moved or gone
122/// (a rare race, not the common case) yields a one-line note in its place rather than failing the whole
123/// report -- `--explain` is a diagnostic, and a diagnostic that can fail is a worse tool than one that
124/// degrades. Sites are printed in the order the reader met them, which is document order within a file
125/// and file order (root first, then each `#include` as it is read) across a whole book.
126fn explain_refusals(refusals: &lang::Refusals) -> String {
127 let mut cache: std::collections::HashMap<String, Option<String>> = std::collections::HashMap::new();
128 let mut out = String::new();
129 for r in refusals.sites() {
130 let text = cache.entry(r.file.clone())
131 .or_insert_with(|| std::fs::read_to_string(&r.file).ok());
132 match text {
133 Some(src) => {
134 let (line_no, col, line_text) = lang::line_col_of(src, r.span.start);
135 out.push_str(&fmt!("{}:{}:{}: {}: skipped {}\n", r.file, line_no, col, r.class.label(), r.name));
136 out.push_str(line_text);
137 out.push('\n');
138 for _ in 1..col { out.push(' '); }
139 out.push_str("^\n");
140 },
141 None => {
142 out.push_str(&fmt!("{}: {}: skipped {} (source no longer readable for a caret)\n",
143 r.file, r.class.label(), r.name));
144 },
145 }
146 }
147 out
148}
149
150/// Each ledger anchor's kind, label (its content key) and resolved page, as a small JSON array -- the
151/// oracle harness's other half, compared against a Typst `query` dump of the same document's headings
152/// and figures. Kept separate from [`Ledger::to_file`]'s full jdat dump, which also carries the
153/// `reserved`/`realised` widths a Typst comparison has no equivalent for.
154///
155/// Rows are emitted in document order -- (page, y, x), the order Typst's own `query` returns its
156/// elements in -- never [`Ledger::anchors`]'s identity order. Identity order sorts by `AnchorId`, whose
157/// key is `{:02}-{slug}`: a two-digit, zero-padded ordinal that only sorts numerically within the first
158/// ninety-nine, after which `"100-…"` and `"18-…"` interleave lexicographically. The oracle harness zips
159/// this dump against Typst's headings position for position, so a document order and an identity order
160/// past that count agree on the set of headings but not the sequence, which read as page drift that was
161/// never real.
162fn ledger_dump_json(ledger: &Ledger) -> Outcome<String> {
163 let mut anchors: Vec<_> = ledger.anchors().collect();
164 anchors.sort_by_key(|a| (a.pos.page, a.pos.y, a.pos.x));
165 let mut rows = Vec::with_capacity(anchors.len());
166 for a in anchors {
167 rows.push(omapdat!{
168 "kind" => dat!(a.id.kind.name()),
169 "label" => dat!(a.id.key.clone()),
170 "page" => dat!(a.pos.page),
171 // The x of the anchor's left from the page's left edge, in whole points, so the oracle can tell
172 // which column an index slot landed in and gate that the two-column index really uses two.
173 "x" => dat!(a.pos.x.to_pt().round() as i64),
174 // The y of the anchor's top from the page's top edge, in whole points, so the oracle can tell a
175 // top float from a foot one and check a float landed on the same side as Typst.
176 "y" => dat!(a.pos.y.to_pt().round() as i64),
177 });
178 }
179 Dat::List(rows).json()
180}
181
182/// Compiles the Typst root at `source` into `out_dir`, writing every page's SVG, the resolved ledger,
183/// and one PDF of the whole run. `ledger_out`, when given, also writes the terse kind/label/page JSON
184/// dump ([`ledger_dump_json`]) the oracle harness compares against a Typst `query`. Returns the counts
185/// and the terse skip line for the caller to report; prints nothing itself save the phase profile when
186/// `AUS_PROFILE` is set.
187fn compile(
188 source: &str,
189 out_dir: &str,
190 pearl: bool,
191 ledger_out: Option<&str>,
192 mut memo: Option<&mut Memo>,
193)
194 -> Outcome<CompileStats>
195{
196 // Phase timing, gated on AUS_PROFILE so a normal run is untouched. Each phase reports its wall time
197 // to stderr, leaving stdout (and every emitted byte) exactly as it was.
198 let prof = std::env::var("AUS_PROFILE").is_ok();
199 let mark = |label: &str, t: std::time::Instant| {
200 if prof {
201 eprintln!("[profile] {:<22} {:>8.1} ms", label, t.elapsed().as_secs_f64() * 1000.0);
202 }
203 };
204 let t_all = std::time::Instant::now();
205
206 // Assemble the document -- a book or doc root through the whole-book assembler, a lone file through the
207 // reader -- and then author, run, decorate and mirror-shift it. Both stages live in `compile`, shared
208 // verbatim with the wasm surface so the two cannot drift. The lone-file path builds the embedded
209 // Libertinus through the thunk, only when it is in fact a lone file.
210 let t_parse = std::time::Instant::now();
211 let (assembled, refusals, skip_line) = res!(compile::assemble(
212 std::path::Path::new(source),
213 || Ok(Arc::new(res!(oxedyne_fe2o3_austenite::fonts::libertinus()))),
214 ));
215 mark("parse+lower+fonts", t_parse);
216
217 let t_author = std::time::Instant::now();
218 let compile::Rendered { mut out, heads, geom } = res!(compile::author_and_run_memo(assembled, memo.as_deref_mut()));
219 mark("author+run+decorate", t_author);
220
221 res!(std::fs::create_dir_all(out_dir));
222
223 // The incremental SVG emit, when a memo drives the run (the `--watch` loop): each page's body is
224 // content-hashed and an unedited page reuses its rendered SVG, so an edit re-renders only the pages the
225 // cascade reaches. It runs sequentially, ahead of the PDF pass below, because the memo is a single
226 // shared cache; that is exactly the live-view latency this increment targets. The SVG bytes are
227 // identical to the parallel path's, so the emitted files do not depend on which path wrote them.
228 let svg_in_worker = memo.is_none();
229 if let Some(m) = memo.as_deref_mut() {
230 let t_svg = std::time::Instant::now();
231 for page in &out.pages {
232 let svg = res!(svg::render_page_memo(page, m));
233 let path = fmt!("{}/page-{:03}.svg", out_dir, page.number);
234 res!(std::fs::write(&path, &svg));
235 }
236 mark("emit(svg, memo)", t_svg);
237 }
238
239 // The ledger is small and independent of the pages, so it is written first and out of the way.
240 let ledger_path = fmt!("{}/ledger.jdat", out_dir);
241 res!(out.ledger.to_file(&ledger_path));
242 if let Some(path) = ledger_out {
243 let json = res!(ledger_dump_json(&out.ledger));
244 res!(std::fs::write(path, json));
245 }
246
247 // Pearl, when asked: a content-addressed `.prl` accumulated across the streaming emit loop, each page
248 // folded in before its frame is dropped, so it streams exactly as the SVG and PDF arms do.
249 let mut pearl_builder = if pearl {
250 Some(res!(emit::pearl::PearlBuilder::new(&out.ledger, geom)).with_outline(&heads))
251 } else {
252 None
253 };
254
255 // Emit each page and drop its frame before the next. Both writers are streaming: the SVG is one file
256 // per page, and the PDF is written object by object into the file as each page is composed, never
257 // accumulated. Holding a bounded window of pages' glyph outlines -- rather than every page's at once,
258 // as a buffered whole-document PDF would -- is what keeps a whole-book compile flat in memory.
259 //
260 // Almost the whole cost of a compile is here: turning each glyph into a filled outline and serialising
261 // it, page after page. That work is a pure function of the placed frame and is independent between
262 // pages, so a chunk of pages is rendered across the cores at once. The order is preserved exactly: a
263 // chunk's results are written to the SVG files and folded into the single PDF stream in page order,
264 // so the bytes are identical to a sequential emit -- only the wall time differs. The PDF's object
265 // numbering and its running `/ID` hash stay strictly sequential in the writer, on this thread.
266 let t_emit = std::time::Instant::now();
267 let mut t_render_ms = 0.0f64; // wall spent in the parallel render stage
268 let mut t_write_ms = 0.0f64; // wall spent writing results out in order
269 let pdf_file = res!(File::create(fmt!("{}/document.pdf", out_dir)));
270 let outline = compile::build_outline(&heads, &out.ledger);
271 let mut pdf = res!(emit::pdf::open_document_with_outline(
272 BufWriter::new(pdf_file), out.pages.len(), outline));
273
274 // Emit is by far the costliest phase and is embarrassingly parallel: each page's outline transforms
275 // and serialisation are a pure function of its frame, independent of every other page. But a rendered
276 // page is large -- its glyph outlines and figures expand to megabytes of SVG and PDF operators -- so
277 // holding several at once regresses peak memory, which the engine keeps to a page-at-a-time budget.
278 // For an illustration-dense book the per-page ink is heavy enough that even a pair of pages can breach
279 // that budget, so parallelism there is not free.
280 //
281 // The default therefore opens the window to eight pages (capped at the core count), which brings a
282 // text book to roughly Typst's own wall time while peak memory stays a few hundred megabytes -- far
283 // under Typst's gigabytes -- and the shared glyph-outline cache speeds every path besides. The ink
284 // budget below keeps the window honest: AUS_EMIT_BUDGET_MB caps the estimated page ink in flight (see
285 // [`page_hold_estimate`]), so a run of heavy figure pages closes its chunk early and never all
286 // coincide; a chunk always holds at least one page, so a page heavier than the budget still renders --
287 // alone. A caller wanting the strict page-at-a-time floor sets AUS_EMIT_WINDOW=1.
288 let cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
289 let width = std::env::var("AUS_EMIT_WINDOW")
290 .ok()
291 .and_then(|s| s.parse::<usize>().ok())
292 .filter(|n| *n >= 1)
293 .unwrap_or(8)
294 .min(cores);
295 let budget = std::env::var("AUS_EMIT_BUDGET_MB")
296 .ok()
297 .and_then(|s| s.parse::<usize>().ok())
298 .unwrap_or(8)
299 .saturating_mul(1024 * 1024);
300
301 if prof {
302 let ests: Vec<usize> = out.pages.iter().map(page_hold_estimate).collect();
303 let sum: usize = ests.iter().sum();
304 let max = ests.iter().copied().max().unwrap_or(0);
305 eprintln!("[profile] est/page max {:.2} MB, mean {:.2} MB",
306 max as f64 / 1048576.0, sum as f64 / 1048576.0 / out.pages.len().max(1) as f64);
307 }
308
309 let total = out.pages.len();
310 let mut start = 0usize;
311 while start < total {
312 // Grow the chunk to the page-count width, but stop early once the page ink in flight would exceed
313 // the memory budget -- keeping at least the one page so a heavy page still renders.
314 let mut end = start;
315 let mut held = 0usize;
316 while end < total && end - start < width {
317 let cost = page_hold_estimate(&out.pages[end]);
318 if end > start && held + cost > budget {
319 break;
320 }
321 held += cost;
322 end += 1;
323 }
324 let slice = &out.pages[start..end];
325
326 // Render this chunk's pages in parallel: each worker builds its page's SVG string, its PDF draw
327 // list, and that list serialised to content-stream bytes -- all pure, all independent.
328 let tr = std::time::Instant::now();
329 let out_ref = out_dir;
330 let rendered: Vec<Outcome<Prepared>> = std::thread::scope(|scope| {
331 let handles: Vec<_> = slice.iter()
332 .map(|page| scope.spawn(move || render_page_pair(page, out_ref, svg_in_worker)))
333 .collect();
334 handles.into_iter()
335 .map(|h| match h.join() {
336 Ok(r) => r,
337 Err(_) => Err(err!("A page-render worker thread panicked."; Bug, Thread)),
338 })
339 .collect()
340 });
341 if prof { t_render_ms += tr.elapsed().as_secs_f64() * 1000.0; }
342
343 // Fold the chunk into the PDF stream in page order (its `/ID` hashes page by page). The SVG files
344 // were already written by the workers. Then free each page's frame, holding no chunk beyond this.
345 let tw = std::time::Instant::now();
346 for prep in rendered {
347 let prep = res!(prep);
348 res!(emit::pdf::write_built_page(&mut pdf, &prep.pdf));
349 }
350 // Fold this chunk's pages into the Pearl document before their frames are freed below.
351 if let Some(pb) = pearl_builder.as_mut() {
352 for page in &out.pages[start..end] {
353 res!(pb.add_page(page));
354 }
355 }
356 for page in &mut out.pages[start..end] {
357 page.frame = Frame::new();
358 }
359 if prof { t_write_ms += tw.elapsed().as_secs_f64() * 1000.0; }
360
361 start = end;
362 }
363 res!(pdf.finish());
364 if let Some(pb) = pearl_builder {
365 res!(pb.to_file(fmt!("{}/document.prl", out_dir)));
366 }
367 mark("emit(svg+pdf)", t_emit);
368 if prof {
369 eprintln!("[profile] render (parallel){:>8.1} ms", t_render_ms);
370 eprintln!("[profile] write (in order) {:>8.1} ms", t_write_ms);
371 eprintln!("[profile] width/budgetMB {:>8}", width);
372 eprintln!("[profile] {:<22} {:>8.1} ms", "TOTAL", t_all.elapsed().as_secs_f64() * 1000.0);
373 }
374
375 // Close the memo generation now the pages are emitted, dropping entries untouched for two compiles.
376 if let Some(m) = memo.as_deref_mut() {
377 m.sweep();
378 }
379
380 Ok(CompileStats {
381 pages: out.pages.len(),
382 passes: out.passes,
383 anchors: out.ledger.len(),
384 skip_line,
385 refusals,
386 })
387}
388
389/// The first double-quoted run in a slice, its contents without the quotes. Mirrors the book
390/// assembler's include parsing so the watch set follows exactly the files a compile reads.
391fn first_quoted(s: &str) -> Option<String> {
392 let open = match s.find('"') {
393 Some(i) => i,
394 None => return None,
395 };
396 let rest = &s[open + 1..];
397 let close = match rest.find('"') {
398 Some(i) => i,
399 None => return None,
400 };
401 Some(rest[..close].to_string())
402}
403
404/// Adds every file under `dir`, recursively, to `set`, plus `dir` itself so an asset added or removed
405/// changes the watched snapshot. Bounded in depth and count so a large font tree cannot make a tick
406/// expensive; the cap is generous for a book's assets.
407fn collect_files(dir: &std::path::Path, set: &mut Vec<PathBuf>, depth: usize) {
408 const MAX_DEPTH: usize = 6;
409 const MAX_FILES: usize = 4000;
410
411 if depth > MAX_DEPTH || set.len() > MAX_FILES {
412 return;
413 }
414 set.push(dir.to_path_buf());
415 let entries = match std::fs::read_dir(dir) {
416 Ok(e) => e,
417 Err(_) => return,
418 };
419 for entry in entries.flatten() {
420 let path = entry.path();
421 if path.is_dir() {
422 collect_files(&path, set, depth + 1);
423 } else {
424 set.push(path);
425 }
426 if set.len() > MAX_FILES {
427 return;
428 }
429 }
430}
431
432/// The set of files a compile of `source` depends on, for the watch to poll: the root itself, its
433/// `config.typ`, each file it `#include`s, and the assets trees a book resolves against (beside the
434/// root and one level up, per the book assembler). Recomputed each tick, so a newly added include or
435/// asset is watched without a restart.
436fn watch_set(source: &str) -> Vec<PathBuf> {
437 let mut set: Vec<PathBuf> = Vec::new();
438 let src_path = PathBuf::from(source);
439 set.push(src_path.clone());
440
441 let root_dir = src_path.parent()
442 .map(|p| p.to_path_buf())
443 .unwrap_or_else(|| PathBuf::from("."));
444 set.push(root_dir.join("config.typ"));
445
446 // Read the root fresh so an include added mid-session joins the watch; a read failure just leaves the
447 // include set as it was on the previous tick.
448 if let Ok(src) = std::fs::read_to_string(&src_path) {
449 for line in src.lines() {
450 let t = line.trim_start();
451 if let Some(rest) = t.strip_prefix("#include") {
452 if let Some(rel) = first_quoted(rest) {
453 set.push(root_dir.join(rel));
454 }
455 }
456 }
457 }
458
459 // The assets tree sits beside the root and, for a book, one level up at the project root. Watch both,
460 // recursively, so an edited figure or image triggers a rebuild, not only an added or removed file.
461 collect_files(&root_dir.join("assets"), &mut set, 0);
462 if let Some(project_dir) = root_dir.parent() {
463 collect_files(&project_dir.join("assets"), &mut set, 0);
464 }
465 set
466}
467
468/// Prints one terse status line for a compile that produced `stats` of `source` into `out_dir`, taking
469/// `elapsed` wall: the source, the page count, the wall in seconds, and the skip line folded on where
470/// there is one.
471fn print_status(source: &str, out_dir: &str, stats: &CompileStats, elapsed: Duration) {
472 let mut line = fmt!("[austenite] {} -> {} page(s), {:.2}s -> {}/",
473 source, stats.pages, elapsed.as_secs_f64(), out_dir);
474 if let Some(skip) = &stats.skip_line {
475 line.push_str("; ");
476 line.push_str(skip);
477 }
478 println!("{}", line);
479}
480
481fn main() -> Outcome<()> {
482 // Flags may precede or follow the paths; only `--watch` (`-w`), `--pearl`, `--ledger-out <path>` and
483 // `--explain` are recognised, everything else is a positional argument in order: the source root,
484 // then the optional output directory.
485 let mut watching = false;
486 let mut pearl = false;
487 let mut explain = false;
488 let mut ledger_out: Option<String> = None;
489 let mut pos: Vec<String> = Vec::new();
490 let mut args = std::env::args().skip(1);
491 while let Some(a) = args.next() {
492 match a.as_str() {
493 "--watch" | "-w" => watching = true,
494 "--pearl" => pearl = true,
495 "--explain" => explain = true,
496 "--ledger-out" => {
497 ledger_out = Some(match args.next() {
498 Some(p) => p,
499 None => return Err(err!(
500 "--ledger-out needs a path argument."; Input, Invalid, Missing)),
501 });
502 },
503 _ => pos.push(a),
504 }
505 }
506 let source = match pos.first() {
507 Some(s) => s.clone(),
508 None => return Err(err!(
509 "Usage: austenite [--watch] [--pearl] [--explain] [--ledger-out PATH] <SOURCE.typ> [OUTPUT_DIR]";
510 Input, Invalid, Missing)),
511 };
512 let out_dir = match pos.get(1) {
513 Some(s) => s.clone(),
514 None => "austenite-out".to_string(),
515 };
516
517 if watching {
518 // Poll interval: brisk enough to feel live, cheap enough to leave the cores to the compile.
519 let interval = Duration::from_millis(400);
520 let src_files = source.clone(); // the file-set closure borrows this
521 let src_build = source.clone(); // the build closure owns this
522 let out = out_dir.clone();
523 let ledger_out_w = ledger_out.clone(); // the build closure owns this
524 println!("[austenite] watching {} -> {}/ (Ctrl-C to stop)", source, out_dir);
525 // One memo lives across every rebuild of the watch, so an edit re-authors and re-emits only the
526 // blocks and pages it actually changes -- the incremental live-view loop the wasm swap needs.
527 let mut memo = Memo::new();
528 return watch::run(
529 move || watch_set(&src_files),
530 move || {
531 let t = std::time::Instant::now();
532 match compile(&src_build, &out, pearl, ledger_out_w.as_deref(), Some(&mut memo)) {
533 Ok(stats) => {
534 // The skip line is folded into the status line, so the rebuild is one line.
535 print_status(&src_build, &out, &stats, t.elapsed());
536 Ok(())
537 },
538 Err(e) => Err(e),
539 }
540 },
541 interval,
542 );
543 }
544
545 let t = std::time::Instant::now();
546 let stats = res!(compile(&source, &out_dir, pearl, ledger_out.as_deref(), None));
547 if explain {
548 print!("{}", explain_refusals(&stats.refusals));
549 } else if let Some(skip) = &stats.skip_line {
550 eprintln!("[austenite] {}", skip);
551 }
552 println!(
553 "austenite: {} -> {} page(s) in {} pass(es); {} anchor(s) in the ledger; {:.2}s; written to {}/",
554 source, stats.pages, stats.passes, stats.anchors, t.elapsed().as_secs_f64(), out_dir);
555 Ok(())
556}
557
558#[cfg(test)]
559mod tests {
560 use super::*;
561 use oxedyne_fe2o3_austenite::ir::Span;
562 use oxedyne_fe2o3_austenite::lang::{Refusal, RefusalClass, Refusals};
563 use oxedyne_fe2o3_austenite::ledger::{Anchor, AnchorId, AnchorKind, Position};
564 use oxedyne_fe2o3_austenite::ir::Sp;
565
566 /// `ledger_dump_json` must emit rows in document order -- (page, y, x) -- never `Ledger::anchors`'s
567 /// identity order. Past ninety-nine headings, `AnchorId`'s key (`{:02}-{slug}`, unpadded once the
568 /// ordinal reaches three digits) stops sorting numerically -- `"100-…"` sorts before `"18-…"` -- so
569 /// identity order and document order diverge there, and the oracle harness (which zips this dump
570 /// against Typst's own document-order `query`, position for position) used to read that divergence as
571 /// spurious page drift on every heading past the hundredth.
572 ///
573 /// This fixture synthesises a hundred and twenty headings, one per page, inserted in reverse page
574 /// order (so a dump that merely reused insertion or `BTreeMap` order could not pass by accident), and
575 /// checks two things: the dump's pages come out strictly increasing (document order), and -- from the
576 /// very same anchor set, sorted the OLD way, by key -- that order is NOT page order. The second check
577 /// is what makes the fixture non-vacuous: it proves this hundred-and-twenty-heading document really
578 /// does exercise the bug, so the first assertion would have failed before the fix.
579 #[test]
580 fn ledger_dump_json_orders_by_document_position_past_ninety_nine_headings() -> Outcome<()> {
581 const N: u32 = 120;
582 let mut ledger = Ledger::new();
583 for n in (1..=N).rev() {
584 let key = fmt!("{:02}-heading-{}", n, n); // the real key shape: doc.rs's `{:02}-{slug}`
585 let id = AnchorId::new(AnchorKind::Heading, key);
586 let pos = Position::new(n, Sp::ZERO, Sp::ZERO);
587 ledger.record(Anchor::new(id, pos));
588 }
589
590 let json = res!(ledger_dump_json(&ledger));
591 let dat = res!(Dat::decode_string(json));
592 let rows = try_extract_dat!(dat, List);
593 assert_eq!(rows.len(), N as usize, "every heading should reach the dump");
594
595 let mut pages = Vec::with_capacity(rows.len());
596 for mut row in rows {
597 // The JSON round trip picks whichever integer width fits the value, not necessarily the `U32`
598 // `ledger_dump_json` wrote, so every width is accepted here exactly as the oracle harness does.
599 let page = try_extract_dat_as!(res!(row.map_remove_must(&dat!("page"))), u32, U8, U16, U32, U64);
600 pages.push(page);
601 }
602 let mut sorted = pages.clone();
603 sorted.sort_unstable();
604 assert_eq!(pages, sorted,
605 "ledger_dump_json must emit rows in document order (page, y, x), not identity order: {:?}", pages);
606 assert_eq!(pages, (1..=N).collect::<Vec<_>>(),
607 "document order for one-heading-per-page is exactly page order");
608
609 // Non-vacuous: the same anchor set, sorted the OLD way (by the anchor's own key -- exactly what
610 // `BTreeMap<AnchorId, _>` identity order does), is demonstrably NOT in page order past the
611 // hundredth heading.
612 let mut by_key: Vec<u32> = (1..=N).collect();
613 by_key.sort_by_key(|&n| fmt!("{:02}-heading-{}", n, n));
614 assert_ne!(by_key, sorted, "fixture is vacuous: identity order happens to already agree with page order");
615 Ok(())
616 }
617
618 /// `--explain`'s report for a site whose file can no longer be read (moved, deleted -- a rare race,
619 /// not the common case) degrades to a one-line note naming the class and construct, rather than
620 /// failing the whole report or panicking.
621 #[test]
622 fn explain_degrades_when_the_file_cannot_be_read() {
623 let refusals = Refusals::from_sites(vec![Refusal {
624 name: "#query".to_string(),
625 span: Span::new(0, 0),
626 class: RefusalClass::Introspective,
627 file: "/nonexistent/path/for/an/austenite/explain/test.typ".to_string(),
628 }]);
629 let report = explain_refusals(&refusals);
630 assert!(report.contains("introspective"), "class label missing: {:?}", report);
631 assert!(report.contains("#query"), "construct name missing: {:?}", report);
632 assert!(report.contains("no longer readable"), "no degraded-file note: {:?}", report);
633 }
634
635 /// `--explain`'s report for a readable file names it, its 1-based line and column, the refusal's
636 /// class and name, the source line itself, and a caret under the column the span starts at -- the
637 /// full shape a reader relies on to jump straight to the site.
638 #[test]
639 fn explain_reports_file_line_col_class_name_and_a_caret() -> Outcome<()> {
640 let dir = std::env::temp_dir().join(fmt!("austenite-explain-test-{}", std::process::id()));
641 res!(std::fs::create_dir_all(&dir));
642 let path = dir.join("fixture.typ");
643 let src = "= Heading\n\n#context[whatever]\n";
644 res!(std::fs::write(&path, src));
645
646 let offset = res!(src.find("#context")
647 .ok_or_else(|| err!("Fixture text lost its own marker."; Bug, Missing))) as u32;
648 let refusals = Refusals::from_sites(vec![Refusal {
649 name: "#context".to_string(),
650 span: Span::new(offset, offset + "#context[whatever]".len() as u32),
651 class: RefusalClass::Introspective,
652 file: path.display().to_string(),
653 }]);
654 let report = explain_refusals(&refusals);
655
656 let _ = std::fs::remove_file(&path);
657 let _ = std::fs::remove_dir(&dir);
658
659 let expected_head = fmt!("{}:3:1: introspective: skipped #context", path.display());
660 assert!(report.starts_with(&expected_head), "report was {:?}", report);
661 let lines: Vec<&str> = report.lines().collect();
662 assert_eq!(lines.get(1), Some(&"#context[whatever]"), "source line missing: {:?}", report);
663 assert_eq!(lines.get(2), Some(&"^"), "caret missing or misplaced: {:?}", report);
664 Ok(())
665 }
666}