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 | |
| 17 | use 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 | |
| 35 | use oxedyne_fe2o3_core::prelude::*; |
| 36 | use oxedyne_fe2o3_graphics::pdf::PdfPage; |
| 37 | use oxedyne_fe2o3_jdat::prelude::*; |
| 38 | |
| 39 | use std::fs::File; |
| 40 | use std::io::BufWriter; |
| 41 | use std::path::PathBuf; |
| 42 | use std::sync::Arc; |
| 43 | use 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. |
| 53 | fn 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. |
| 87 | struct 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). |
| 94 | struct 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. |
| 105 | fn 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. |
| 126 | fn 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. |
| 162 | fn 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. |
| 187 | fn 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. |
| 391 | fn 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. |
| 407 | fn 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. |
| 436 | fn 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. |
| 471 | fn 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 | |
| 481 | fn 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)] |
| 559 | mod 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 | } |