krz/orgo
Lightning fast org-mode static site generator.
clone: git clone https://gitbay.org/krz/orgo.git
v0.20.1: tests/oracle.rs · raw
1//! The `emacs --batch` ground-truth oracle (spec §5, Phase 0).
2//!
3//! Every other test in this suite checks orgo against orgo: a snapshot says our
4//! output has not *changed*, never that it is *right*. Those two questions are different,
5//! and only one of them matters to someone whose site is currently published by Emacs.
6//! This file answers the second by exporting the same fixture with org's own HTML
7//! exporter — the exporter weblorg wraps to publish the target corpus — and diffing the
8//! two.
9//!
10//! **What is compared.** Byte equality is not a useful goal: org wraps every section in
11//! `outline-container` divs keyed by generated ids, and no amount of agreement on
12//! semantics would survive that. Both sides are reduced to a *semantic skeleton* — the
13//! sequence of element opens, closes, and text runs, with `<div>`s and all attributes
14//! except `href`/`src` dropped, whitespace collapsed, and entities decoded. What remains
15//! is the question worth asking: does org think this is a `<blockquote><p>`, and do we?
16//!
17//! **What the result means.** These tests do not assert agreement — they *snapshot the
18//! disagreement*. A divergence report that is checked in and reviewed is worth more than
19//! a red test nobody can act on, and it makes any new divergence show up as a diff in
20//! code review. A few invariants that must never break are asserted outright.
21//!
22//! The suite skips cleanly when Emacs is absent, so it never blocks a machine or CI
23//! runner that has no Emacs.
24
25use std::process::Command;
26
27use camino::Utf8PathBuf;
28
29use orgo::parser::parse;
30use orgo::render::{render, Html, SyntectHighlighter};
31use orgo::resolve::ResolvedDoc;
32
33fn manifest_dir() -> Utf8PathBuf {
34 Utf8PathBuf::from(env!("CARGO_MANIFEST_DIR"))
35}
36
37/// Is a usable Emacs on PATH? The oracle is a development instrument, not a build
38/// dependency, so its absence skips rather than fails.
39fn emacs_available() -> bool {
40 Command::new("emacs")
41 .arg("--version")
42 .output()
43 .map(|o| o.status.success())
44 .unwrap_or(false)
45}
46
47/// Export a fixture with org's own HTML exporter.
48fn org_export(fixture: &str) -> String {
49 let root = manifest_dir();
50 let output = Command::new("emacs")
51 .args(["-Q", "--batch", "-l"])
52 .arg(root.join("tests/oracle.el"))
53 .env("ORG_ORACLE_INPUT", root.join("fixtures").join(fixture))
54 .current_dir(&root)
55 .output()
56 .expect("run emacs");
57 assert!(
58 output.status.success(),
59 "emacs export of {fixture} failed:\n{}",
60 String::from_utf8_lossy(&output.stderr)
61 );
62 String::from_utf8(output.stdout).expect("emacs emits UTF-8")
63}
64
65/// Render a fixture with orgo.
66fn our_export(fixture: &str) -> String {
67 let path = manifest_dir().join("fixtures").join(fixture);
68 let source = std::fs::read_to_string(&path).expect("read fixture");
69 let document = parse(Utf8PathBuf::from(fixture).as_path(), &source).expect("parse");
70 let Html(html) = render(&ResolvedDoc { document }, &SyntectHighlighter::new());
71 html
72}
73
74// ---------------------------------------------------------------------------
75// HTML → semantic skeleton
76// ---------------------------------------------------------------------------
77
78/// Elements dropped from the skeleton entirely, because once attributes are gone they
79/// carry no meaning the two exporters could agree or disagree *about*.
80///
81/// `div` is pure layout: org wraps every section in `outline-container`/`outline-text`
82/// wrappers and we emit none. `span` is the same story at the inline level, and matters
83/// far more than it looks: syntect emits one span per code token, so keeping them made a
84/// source block contribute ~60 skeleton lines of pure noise and dragged the agreement on
85/// `blocks.org` down to 36% — a number that said nothing about whether we render blocks
86/// correctly. Text still carries the signal: a `<span class="todo">` shows up as its
87/// text, `"TODO"`, which is the part worth comparing.
88const IGNORED: &[&str] = &["div", "span"];
89
90/// Attributes kept in the skeleton. Ids and classes are generated (`org6c28c1b`) or
91/// cosmetic (`org-ul`); `href` and `src` are the content.
92const KEPT_ATTRS: &[&str] = &["href", "src"];
93
94/// HTML void elements, which never emit a close event.
95const VOID: &[&str] = &[
96 "br", "hr", "img", "input", "meta", "link", "col", "area", "base", "source", "wbr",
97];
98
99/// Reduce an HTML fragment to its semantic skeleton: one line per element open, element
100/// close, or text run.
101fn skeleton(html: &str) -> Vec<String> {
102 let mut out = Vec::new();
103 let chars: Vec<char> = html.chars().collect();
104 let mut i = 0;
105 let mut text = String::new();
106
107 while i < chars.len() {
108 if chars[i] != '<' {
109 text.push(chars[i]);
110 i += 1;
111 continue;
112 }
113
114 // Comments and doctypes carry nothing.
115 if chars[i..].starts_with(&['<', '!']) {
116 i += match find_from(&chars, i, ">") {
117 Some(end) => end - i + 1,
118 None => break,
119 };
120 continue;
121 }
122 let Some(end) = find_from(&chars, i, ">") else {
123 break;
124 };
125 let raw: String = chars[i + 1..end].iter().collect();
126 i = end + 1;
127
128 let raw = raw.trim().trim_end_matches('/').trim().to_string();
129 // Text is flushed only when a tag is actually *emitted*. Text either side of an
130 // ignored tag therefore merges into one run, which is what makes a highlighted
131 // source block compare as the one string of code it is, rather than as a
132 // token-by-token sequence that has to line up exactly.
133 if let Some(name) = raw.strip_prefix('/') {
134 let name = name.trim().to_ascii_lowercase();
135 if !IGNORED.contains(&name.as_str()) && !VOID.contains(&name.as_str()) {
136 flush_text(&mut text, &mut out);
137 out.push(format!("</{name}>"));
138 }
139 continue;
140 }
141 let mut parts = raw.splitn(2, char::is_whitespace);
142 let name = parts.next().unwrap_or("").to_ascii_lowercase();
143 if name.is_empty() || IGNORED.contains(&name.as_str()) {
144 continue;
145 }
146 let attrs = kept_attributes(parts.next().unwrap_or(""));
147 flush_text(&mut text, &mut out);
148 out.push(format!("<{name}{attrs}>"));
149 }
150 flush_text(&mut text, &mut out);
151 out
152}
153
154fn flush_text(text: &mut String, out: &mut Vec<String>) {
155 let decoded = decode_entities(text);
156 let collapsed = decoded.split_whitespace().collect::<Vec<_>>().join(" ");
157 if !collapsed.is_empty() {
158 out.push(format!("{collapsed:?}"));
159 }
160 text.clear();
161}
162
163fn find_from(chars: &[char], from: usize, needle: &str) -> Option<usize> {
164 let n: Vec<char> = needle.chars().collect();
165 (from..chars.len()).find(|&k| chars[k..].starts_with(&n[..]))
166}
167
168/// Keep only the content-bearing attributes, in a stable order.
169fn kept_attributes(rest: &str) -> String {
170 let mut kept: Vec<(String, String)> = Vec::new();
171 for attr in KEPT_ATTRS {
172 if let Some(value) = attribute_value(rest, attr) {
173 kept.push(((*attr).to_string(), value));
174 }
175 }
176 kept.iter()
177 .map(|(k, v)| format!(" {k}=\"{}\"", decode_entities(v)))
178 .collect()
179}
180
181fn attribute_value(rest: &str, name: &str) -> Option<String> {
182 let mut search = rest;
183 while let Some(pos) = search.find(name) {
184 let before_ok = pos == 0
185 || search[..pos]
186 .chars()
187 .next_back()
188 .is_some_and(char::is_whitespace);
189 let after = &search[pos + name.len()..];
190 let after_trimmed = after.trim_start();
191 if before_ok && after_trimmed.starts_with('=') {
192 let value = after_trimmed[1..].trim_start();
193 let quote = value.chars().next()?;
194 if quote == '"' || quote == '\'' {
195 let end = value[1..].find(quote)? + 1;
196 return Some(value[1..end].to_string());
197 }
198 let end = value.find(char::is_whitespace).unwrap_or(value.len());
199 return Some(value[..end].to_string());
200 }
201 search = &search[pos + name.len()..];
202 }
203 None
204}
205
206/// Decode the entities either exporter is likely to emit, so an encoding difference is
207/// never reported as a semantic one.
208fn decode_entities(s: &str) -> String {
209 let mut out = String::with_capacity(s.len());
210 let mut rest = s;
211 while let Some(amp) = rest.find('&') {
212 out.push_str(&rest[..amp]);
213 let tail = &rest[amp..];
214 let Some(semi) = tail.find(';').filter(|s| *s <= 12) else {
215 out.push('&');
216 rest = &tail[1..];
217 continue;
218 };
219 let entity = &tail[1..semi];
220 let decoded = match entity {
221 "amp" => Some('&'),
222 "lt" => Some('<'),
223 "gt" => Some('>'),
224 "quot" => Some('"'),
225 "apos" => Some('\''),
226 "nbsp" => Some(' '),
227 _ => entity
228 .strip_prefix('#')
229 .and_then(|n| match n.strip_prefix(['x', 'X']) {
230 Some(hex) => u32::from_str_radix(hex, 16).ok(),
231 None => n.parse::<u32>().ok(),
232 })
233 .and_then(char::from_u32),
234 };
235 match decoded {
236 // A non-breaking space is a space for comparison purposes.
237 Some('\u{a0}') => out.push(' '),
238 Some(c) => out.push(c),
239 None => {
240 out.push('&');
241 rest = &tail[1..];
242 continue;
243 }
244 }
245 rest = &tail[semi + 1..];
246 }
247 out.push_str(rest);
248 out
249}
250
251// ---------------------------------------------------------------------------
252// Divergence report
253// ---------------------------------------------------------------------------
254
255/// One divergence orgo makes on purpose, so the report can separate "we chose this"
256/// from "we got this wrong".
257///
258/// Without this split the agreement percentage is noise: the timestamps fixture sat at
259/// 40% while being entirely correct, because org writes `<2024-01-15 Mon>` as text and
260/// orgo writes a `<time datetime>` element. A number that cannot fall when a real
261/// defect appears is not measuring anything.
262struct Deliberate {
263 name: &'static str,
264 /// Does this hunk consist only of the difference described? `ours` are the `-` lines,
265 /// `theirs` the `+` lines.
266 matches: fn(&[String], &[String]) -> bool,
267 /// Only applies once the notes section has started. Two footnote definitions differ
268 /// by `</li><li>` against `</sup><p>`, which is the same shape difference — but a
269 /// list where a paragraph was expected is a real defect anywhere else, so the rule
270 /// is not allowed to explain it anywhere else.
271 in_notes_only: bool,
272}
273
274fn is_tag(line: &str, names: &[&str]) -> bool {
275 names
276 .iter()
277 .any(|n| line == format!("<{n}>") || line == format!("</{n}>"))
278}
279
280fn text_of(line: &str) -> Option<&str> {
281 line.strip_prefix('"')?.strip_suffix('"')
282}
283
284/// Does this text run contain an org timestamp, `<2024-01-15 Mon>` or `[2024-01-15]`?
285fn has_timestamp(line: &str) -> bool {
286 let t = match text_of(line) {
287 Some(t) => t,
288 None => return false,
289 };
290 t.contains('<') && t.contains('-') || t.contains('[') && t.contains('-')
291}
292
293const DELIBERATE: &[Deliberate] = &[
294 // `<time datetime="…">` instead of org's plain text: the date is data, and a reader's
295 // browser can do something with it.
296 Deliberate {
297 name: "semantic-time",
298 matches: |ours, theirs| {
299 !ours.is_empty()
300 && ours
301 .iter()
302 .all(|l| is_tag(l, &["time"]) || text_of(l).is_some())
303 && theirs.iter().all(|l| has_timestamp(l) || text_of(l).is_some())
304 },
305 in_notes_only: false,
306 },
307 // `<figure>`/`<figcaption>` instead of two paragraphs in a div.
308 Deliberate {
309 name: "figure-element",
310 matches: |ours, theirs| {
311 ours.iter().any(|l| is_tag(l, &["figure", "figcaption"]))
312 && ours
313 .iter()
314 .all(|l| is_tag(l, &["figure", "figcaption"]) || text_of(l).is_some())
315 && theirs.iter().all(|l| is_tag(l, &["p"]) || text_of(l).is_some())
316 },
317 in_notes_only: false,
318 },
319 // `<em>`/`<strong>` instead of org's presentational `<i>`/`<b>`.
320 Deliberate {
321 name: "semantic-emphasis",
322 matches: |ours, theirs| {
323 !ours.is_empty()
324 && ours.iter().all(|l| is_tag(l, &["em", "strong", "del"]))
325 && theirs.iter().all(|l| is_tag(l, &["i", "b", "s", "del"]))
326 },
327 in_notes_only: false,
328 },
329 // `<pre><code>` instead of a bare `<pre>`: the nested element is what every syntax
330 // highlighter and every reader's stylesheet expects.
331 Deliberate {
332 name: "pre-code",
333 matches: |ours, theirs| {
334 !ours.is_empty() && ours.iter().all(|l| is_tag(l, &["code"])) && theirs.is_empty()
335 },
336 in_notes_only: false,
337 },
338 // Org emits a `<colgroup>` of empty `<col>`s to carry column alignment; orgo
339 // leaves alignment to the stylesheet.
340 Deliberate {
341 name: "no-colgroup",
342 matches: |ours, theirs| {
343 ours.is_empty()
344 && !theirs.is_empty()
345 && theirs.iter().all(|l| is_tag(l, &["colgroup", "col"]) || l == "<col>")
346 },
347 in_notes_only: false,
348 },
349 // Footnote ids: `fn-1` rather than org's `fn.1`, because a dot in an id is awkward in
350 // a CSS selector.
351 Deliberate {
352 name: "footnote-anchor-naming",
353 matches: |ours, theirs| {
354 ours.len() == 1
355 && theirs.len() == 1
356 && ours[0].replace("fn-", "fn.") == theirs[0]
357 && ours[0].contains("#fn")
358 },
359 in_notes_only: false,
360 },
361 // The notes section itself: an `<ol>` under a rule, rather than org's headed div of
362 // paragraphs, and a `↩` back-link rather than a repeated superscript number. Same
363 // notes, same order, same links, in the shape a screen reader announces as a list.
364 Deliberate {
365 name: "footnote-section-shape",
366 matches: |ours, theirs| {
367 let ours_is_notes = ours.iter().all(|l| {
368 is_tag(l, &["section", "ol", "li", "a", "sup", "p"])
369 || l == "<hr>"
370 || l.starts_with("<a href=\"#fn")
371 || text_of(l) == Some("↩")
372 || text_of(l).is_some()
373 });
374 let theirs_is_notes = theirs.iter().all(|l| {
375 is_tag(l, &["h2", "sup", "p", "a", "div"])
376 || l.starts_with("<a href=\"#fn")
377 || text_of(l).is_some()
378 });
379 let touches_notes = ours.iter().chain(theirs).any(|l| {
380 l.contains("#fn") || text_of(l) == Some("Footnotes:") || is_tag(l, &["section"])
381 });
382 touches_notes && ours_is_notes && theirs_is_notes
383 },
384 in_notes_only: false,
385 },
386 // Two note definitions abutting: `</li><li>` where org writes `</sup><p>`.
387 Deliberate {
388 name: "footnote-section-shape",
389 matches: |ours, theirs| {
390 !ours.is_empty()
391 && ours.iter().all(|l| is_tag(l, &["li", "ol", "section"]))
392 && theirs.iter().all(|l| is_tag(l, &["p", "sup", "div"]))
393 },
394 in_notes_only: true,
395 },
396 // Verse ends without a trailing `<br>`: org emits one for the final newline, which is
397 // a blank line at the end of the stanza and nothing else.
398 Deliberate {
399 name: "verse-trailing-break",
400 matches: |ours, theirs| ours.is_empty() && theirs == ["<br>"],
401 in_notes_only: false,
402 },
403 // `[[id:…]]` resolves here and does not in the oracle: a single-file `emacs --batch`
404 // export has no id database, so org drops the link and keeps its text. This is a
405 // property of the harness, not of either exporter.
406 Deliberate {
407 name: "id-link-resolution",
408 matches: |ours, theirs| {
409 ours.iter().any(|l| l.starts_with("<a href=\"#"))
410 && ours.iter().all(|l| is_tag(l, &["a"]) || l.starts_with("<a href=") || text_of(l).is_some())
411 && theirs.iter().all(|l| text_of(l).is_some())
412 },
413 in_notes_only: false,
414 },
415 // A change of bullet starts a new list. Org instead continues the list — two blank
416 // lines end one, not a switch from `1.` to `-` — so a dash item written under a
417 // numbered list is exported *numbered*. We split, which is what the author drew.
418 // Deliberate, and the one entry here that is arguably worth revisiting.
419 Deliberate {
420 name: "list-per-bullet-type",
421 matches: |ours, theirs| {
422 !ours.is_empty()
423 && ours.iter().all(|l| is_tag(l, &["ol", "ul"]))
424 && theirs.iter().all(|l| is_tag(l, &["ol", "ul"]))
425 },
426 in_notes_only: false,
427 },
428];
429
430/// One run of differing lines: what we wrote, and what Emacs wrote.
431struct Hunk {
432 ours: Vec<String>,
433 theirs: Vec<String>,
434}
435
436impl Hunk {
437 fn len(&self) -> usize {
438 self.ours.len().max(self.theirs.len())
439 }
440
441 fn deliberate(&self, in_notes: bool) -> Option<&'static str> {
442 DELIBERATE
443 .iter()
444 .filter(|d| in_notes || !d.in_notes_only)
445 .find(|d| (d.matches)(&self.ours, &self.theirs))
446 .map(|d| d.name)
447 }
448
449 /// Does this hunk start the footnote section?
450 fn starts_notes(&self) -> bool {
451 self.ours
452 .iter()
453 .chain(&self.theirs)
454 .any(|l| l == "<section>" || text_of(l) == Some("Footnotes:"))
455 }
456}
457
458enum Op {
459 Same(String),
460 Differs(Hunk),
461}
462
463/// Longest-common-subsequence walk, grouped into runs of agreement and disagreement.
464fn align(ours: &[String], theirs: &[String]) -> Vec<Op> {
465 let (n, m) = (ours.len(), theirs.len());
466 let mut lcs = vec![vec![0usize; m + 1]; n + 1];
467 for i in (0..n).rev() {
468 for j in (0..m).rev() {
469 lcs[i][j] = if ours[i] == theirs[j] {
470 lcs[i + 1][j + 1] + 1
471 } else {
472 lcs[i + 1][j].max(lcs[i][j + 1])
473 };
474 }
475 }
476
477 let mut out: Vec<Op> = Vec::new();
478 let push_diff = |out: &mut Vec<Op>, mine: Option<String>, theirs: Option<String>| {
479 if let Some(Op::Differs(h)) = out.last_mut() {
480 h.ours.extend(mine);
481 h.theirs.extend(theirs);
482 return;
483 }
484 out.push(Op::Differs(Hunk {
485 ours: mine.into_iter().collect(),
486 theirs: theirs.into_iter().collect(),
487 }));
488 };
489
490 let (mut i, mut j) = (0, 0);
491 while i < n && j < m {
492 if ours[i] == theirs[j] {
493 out.push(Op::Same(ours[i].clone()));
494 i += 1;
495 j += 1;
496 } else if lcs[i + 1][j] >= lcs[i][j + 1] {
497 push_diff(&mut out, Some(ours[i].clone()), None);
498 i += 1;
499 } else {
500 push_diff(&mut out, None, Some(theirs[j].clone()));
501 j += 1;
502 }
503 }
504 for line in &ours[i..] {
505 push_diff(&mut out, Some(line.clone()), None);
506 }
507 for line in &theirs[j..] {
508 push_diff(&mut out, None, Some(line.clone()));
509 }
510 out
511}
512
513/// A unified diff of the two skeletons, with hunks that are deliberate collapsed to a
514/// named line. `-` is orgo, `+` is Emacs.
515///
516/// The number that matters is the last one: *unexplained* lines. Agreement can be low
517/// while unexplained is zero, and that is a passing state.
518fn divergence(ours: &[String], theirs: &[String]) -> String {
519 let ops = align(ours, theirs);
520 let mut agreed = 0usize;
521 let mut deliberate = 0usize;
522 let mut unexplained = 0usize;
523 let mut by_rule: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
524 let mut body = String::new();
525 let mut in_notes = false;
526
527 for op in &ops {
528 match op {
529 Op::Same(line) => {
530 agreed += 1;
531 body.push_str(&format!(" {line}\n"));
532 }
533 Op::Differs(hunk) => {
534 in_notes |= hunk.starts_notes();
535 match hunk.deliberate(in_notes) {
536 Some(rule) => {
537 deliberate += hunk.len();
538 *by_rule.entry(rule).or_default() += 1;
539 body.push_str(&format!("~ {rule} ({} line(s))\n", hunk.len()));
540 }
541 None => {
542 unexplained += hunk.len();
543 for line in &hunk.ours {
544 body.push_str(&format!("- {line}\n"));
545 }
546 for line in &hunk.theirs {
547 body.push_str(&format!("+ {line}\n"));
548 }
549 }
550 }
551 }
552 }
553 }
554
555 let total = ours.len().max(theirs.len());
556 let pct = if total == 0 {
557 100.0
558 } else {
559 100.0 * agreed as f64 / total as f64
560 };
561 let rules: Vec<String> = by_rule
562 .iter()
563 .map(|(name, n)| format!("{name} ×{n}"))
564 .collect();
565 format!(
566 "agreement: {agreed}/{total} skeleton lines ({pct:.1}%)\n\
567 deliberate: {deliberate} line(s){}\n\
568 unexplained: {unexplained} line(s)\n\
569 (- orgo, + emacs, ~ a difference we mean to have)\n\n{body}",
570 if rules.is_empty() {
571 String::new()
572 } else {
573 format!(" — {}", rules.join(", "))
574 }
575 )
576}
577
578/// Snapshot the divergence between orgo and Emacs for one fixture.
579fn compare(fixture: &str) -> Option<String> {
580 if !emacs_available() {
581 eprintln!("skipping oracle comparison for {fixture}: no emacs on PATH");
582 return None;
583 }
584 let ours = skeleton(&our_export(fixture));
585 let theirs = skeleton(&org_export(fixture));
586 Some(divergence(&ours, &theirs))
587}
588
589macro_rules! oracle_test {
590 ($name:ident, $fixture:literal) => {
591 #[test]
592 fn $name() {
593 if let Some(report) = compare($fixture) {
594 insta::assert_snapshot!(report);
595 }
596 }
597 };
598}
599
600oracle_test!(oracle_minimal, "minimal.org");
601oracle_test!(oracle_core, "core.org");
602oracle_test!(oracle_headings, "headings.org");
603oracle_test!(oracle_lists, "lists.org");
604oracle_test!(oracle_blocks, "blocks.org");
605oracle_test!(oracle_table, "table.org");
606oracle_test!(oracle_footnote, "footnote.org");
607oracle_test!(oracle_timestamps, "timestamps.org");
608oracle_test!(oracle_images, "images.org");
609oracle_test!(oracle_elements, "elements.org");
610
611/// The gate the snapshots cannot be: *every* divergence from Emacs must be one we chose.
612///
613/// The percentages above are context, not a target — the timestamps fixture agrees on
614/// 40% of its lines and is entirely correct, because org writes a date as text where
615/// orgo writes `<time datetime>`. What must hold is that nothing diverges for a
616/// reason nobody has written down. A new unexplained line means either a defect to fix
617/// or a decision to record in `DELIBERATE`.
618#[test]
619fn every_divergence_from_emacs_is_deliberate() {
620 let fixtures = [
621 "minimal.org",
622 "core.org",
623 "headings.org",
624 "lists.org",
625 "blocks.org",
626 "table.org",
627 "footnote.org",
628 "timestamps.org",
629 "images.org",
630 "elements.org",
631 ];
632 let mut offenders = Vec::new();
633 for fixture in fixtures {
634 let Some(report) = compare(fixture) else {
635 return; // no emacs on PATH; the suite skips cleanly
636 };
637 if !report.contains("unexplained: 0 line(s)") {
638 let count = report
639 .lines()
640 .find(|l| l.starts_with("unexplained:"))
641 .unwrap_or("unexplained: ?");
642 offenders.push(format!("{fixture}: {count}"));
643 }
644 }
645 assert!(
646 offenders.is_empty(),
647 "these fixtures diverge from Emacs for unrecorded reasons — fix the defect, or \
648 add a rule to DELIBERATE saying why the difference is wanted:\n {}",
649 offenders.join("\n ")
650 );
651}
652
653// ---------------------------------------------------------------------------
654// Invariants that must hold against the oracle, not merely be snapshotted
655// ---------------------------------------------------------------------------
656
657/// How many headings a document has and at what depth is the shape of the document.
658/// Getting it wrong reorganizes someone's writing, so it is asserted rather than
659/// snapshotted. Heading *decoration* (priority cookies, tag markup) is a policy
660/// difference and is left to the snapshots.
661#[test]
662fn heading_structure_matches_emacs() {
663 if !emacs_available() {
664 eprintln!("skipping: no emacs on PATH");
665 return;
666 }
667 for fixture in ["minimal.org", "core.org", "headings.org", "lists.org"] {
668 let ours = heading_levels(&skeleton(&our_export(fixture)));
669 let theirs = heading_levels(&skeleton(&org_export(fixture)));
670 assert_eq!(
671 ours, theirs,
672 "heading structure diverges from Emacs in {fixture}"
673 );
674 }
675}
676
677/// The sequence of heading open tags, e.g. `["<h1>", "<h2>", "<h1>"]`.
678fn heading_levels(skeleton: &[String]) -> Vec<String> {
679 skeleton
680 .iter()
681 .filter(|l| l.starts_with("<h") && l[2..].starts_with(|c: char| c.is_ascii_digit()))
682 .cloned()
683 .collect()
684}
685
686/// A list is the construct where nesting is easiest to get subtly wrong, and where being
687/// wrong changes the meaning of the document rather than its looks.
688#[test]
689fn list_nesting_matches_emacs() {
690 if !emacs_available() {
691 eprintln!("skipping: no emacs on PATH");
692 return;
693 }
694 let ours = list_shape(&skeleton(&our_export("lists.org")));
695 let theirs = list_shape(&skeleton(&org_export("lists.org")));
696 assert_eq!(ours, theirs, "list nesting diverges from Emacs");
697}
698
699/// The sequence of list opens/closes, ignoring content — the shape of the nesting.
700fn list_shape(skeleton: &[String]) -> Vec<String> {
701 skeleton
702 .iter()
703 .filter(|l| {
704 matches!(
705 l.as_str(),
706 "<ul>" | "</ul>" | "<ol>" | "</ol>" | "<li>" | "</li>" | "<dl>" | "</dl>"
707 | "<dt>" | "</dt>" | "<dd>" | "</dd>"
708 )
709 })
710 .cloned()
711 .collect()
712}
713
714/// Code must survive verbatim. Highlighting markup differs by construction (syntect
715/// spans vs htmlize), but if the *characters of the program* differ, we have corrupted
716/// the author's content.
717#[test]
718fn source_block_text_matches_emacs() {
719 if !emacs_available() {
720 eprintln!("skipping: no emacs on PATH");
721 return;
722 }
723 for fixture in ["blocks.org", "core.org", "elements.org"] {
724 let ours = code_text(&our_export(fixture));
725 let theirs = code_text(&org_export(fixture));
726 assert_eq!(ours, theirs, "source block text diverges from Emacs in {fixture}");
727 }
728}
729
730/// All text inside `<pre>` blocks, with tags stripped and whitespace collapsed.
731fn code_text(html: &str) -> Vec<String> {
732 let mut out = Vec::new();
733 let mut rest = html;
734 while let Some(start) = rest.find("<pre") {
735 let after = &rest[start..];
736 let Some(open_end) = after.find('>') else { break };
737 let Some(close) = after.find("</pre>") else { break };
738 let inner = &after[open_end + 1..close];
739 out.push(strip_tags(inner));
740 rest = &after[close + 6..];
741 }
742 out
743}
744
745/// All text in a fragment with tags removed and entities decoded, then whitespace
746/// collapsed once at the end.
747///
748/// [`skeleton`] cannot do this job: it trims each text run individually, which is
749/// invisible for prose (one run per paragraph) but destructive for highlighted code,
750/// where syntect splits a line into one run per token and the spaces *between* tokens
751/// live at the edges of those runs. Trimming each run turns `def greet` into `defgreet`.
752fn strip_tags(html: &str) -> String {
753 let mut text = String::new();
754 let mut rest = html;
755 while let Some(open) = rest.find('<') {
756 text.push_str(&rest[..open]);
757 match rest[open..].find('>') {
758 Some(close) => rest = &rest[open + close + 1..],
759 None => {
760 rest = "";
761 break;
762 }
763 }
764 }
765 text.push_str(rest);
766 decode_entities(&text)
767 .split_whitespace()
768 .collect::<Vec<_>>()
769 .join(" ")
770}