tests/oracle.rs
672 lines · 25590 bytes
32 symbols in this file
manifest_diremacs_availableoracle_versionVERSIONorg_exportour_exportDeliberateis_tagtext_ofone_textwithout_priorityhas_timestampDELIBERATEHunklendeliberatestarts_notesOpaligndivergencecompareoracle_test$nameevery_divergence_from_emacs_is_deliberateunterminated_named_references_decode_like_a_browserheading_structure_matches_emacsheading_levelslist_nesting_matches_emacslist_shapesource_block_text_matches_emacscode_textstrip_tags
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;
32use orgo::skeleton::{decode_entities, skeleton};
33
34fn manifest_dir() -> Utf8PathBuf {
35 Utf8PathBuf::from(env!("CARGO_MANIFEST_DIR"))
36}
37
38/// Is a usable Emacs on PATH? The oracle is a development instrument, not a build
39/// dependency, so its absence skips rather than fails.
40fn emacs_available() -> bool {
41 Command::new("emacs")
42 .arg("--version")
43 .output()
44 .map(|o| o.status.success())
45 .unwrap_or(false)
46}
47
48/// Emacs and org, as the `+` side of every report below. The ground truth moves: org
49/// 9.8 began wrapping source blocks in `<pre><code>`, which orgo already did, so three
50/// of these snapshots gained several points without orgo changing. Recorded so that
51/// shows in the diff as what it is rather than reading as a regression.
52fn oracle_version() -> &'static str {
53 static VERSION: std::sync::OnceLock<String> = std::sync::OnceLock::new();
54 VERSION.get_or_init(|| {
55 let output = Command::new("emacs")
56 .args([
57 "-Q",
58 "--batch",
59 "--eval",
60 "(progn (require 'org) \
61 (princ (format \"emacs %s, org %s\" emacs-version (org-version))))",
62 ])
63 .output()
64 .expect("run emacs");
65 String::from_utf8_lossy(&output.stdout).trim().to_string()
66 })
67}
68
69/// Export a fixture with org's own HTML exporter.
70fn org_export(fixture: &str) -> String {
71 let root = manifest_dir();
72 let output = Command::new("emacs")
73 .args(["-Q", "--batch", "-l"])
74 .arg(root.join("tests/oracle.el"))
75 .env("ORG_ORACLE_INPUT", root.join("fixtures").join(fixture))
76 .current_dir(&root)
77 .output()
78 .expect("run emacs");
79 assert!(
80 output.status.success(),
81 "emacs export of {fixture} failed:\n{}",
82 String::from_utf8_lossy(&output.stderr)
83 );
84 String::from_utf8(output.stdout).expect("emacs emits UTF-8")
85}
86
87/// Render a fixture with orgo.
88fn our_export(fixture: &str) -> String {
89 let path = manifest_dir().join("fixtures").join(fixture);
90 let source = std::fs::read_to_string(&path).expect("read fixture");
91 let document = parse(Utf8PathBuf::from(fixture).as_path(), &source).expect("parse");
92 let Html(html) = render(&ResolvedDoc { document }, &SyntectHighlighter::new());
93 html
94}
95
96// ---------------------------------------------------------------------------
97// Divergence report
98// ---------------------------------------------------------------------------
99
100/// One divergence orgo makes on purpose, so the report can separate "we chose this"
101/// from "we got this wrong".
102///
103/// Without this split the agreement percentage is noise: the timestamps fixture sat at
104/// 40% while being entirely correct, because org writes `<2024-01-15 Mon>` as text and
105/// orgo writes a `<time datetime>` element. A number that cannot fall when a real
106/// defect appears is not measuring anything.
107struct Deliberate {
108 name: &'static str,
109 /// Does this hunk consist only of the difference described? `ours` are the `-` lines,
110 /// `theirs` the `+` lines.
111 matches: fn(&[String], &[String]) -> bool,
112 /// Only applies once the notes section has started. Two footnote definitions differ
113 /// by `</li><li>` against `</sup><p>`, which is the same shape difference — but a
114 /// list where a paragraph was expected is a real defect anywhere else, so the rule
115 /// is not allowed to explain it anywhere else.
116 in_notes_only: bool,
117}
118
119fn is_tag(line: &str, names: &[&str]) -> bool {
120 names
121 .iter()
122 .any(|n| line == format!("<{n}>") || line == format!("</{n}>"))
123}
124
125fn text_of(line: &str) -> Option<&str> {
126 line.strip_prefix('"')?.strip_suffix('"')
127}
128
129/// The one text run a hunk consists of, if that is all it is.
130fn one_text(lines: &[String]) -> Option<&str> {
131 match lines {
132 [line] => text_of(line),
133 _ => None,
134 }
135}
136
137/// `TODO [#A] Write the parser` → `TODO Write the parser`.
138fn without_priority(text: &str) -> Option<String> {
139 let open = text.find("[#")?;
140 let close = open + text[open..].find(']')?;
141 let joined = format!("{}{}", &text[..open], text[close + 1..].trim_start());
142 Some(joined.trim().to_string())
143}
144
145/// Does this text run contain an org timestamp, `<2024-01-15 Mon>` or `[2024-01-15]`?
146fn has_timestamp(line: &str) -> bool {
147 let t = match text_of(line) {
148 Some(t) => t,
149 None => return false,
150 };
151 t.contains('<') && t.contains('-') || t.contains('[') && t.contains('-')
152}
153
154const DELIBERATE: &[Deliberate] = &[
155 // `<time datetime="…">` instead of org's plain text: the date is data, and a reader's
156 // browser can do something with it.
157 Deliberate {
158 name: "semantic-time",
159 matches: |ours, theirs| {
160 // Both sides are allowed to carry text, so without the `any` guards this
161 // rule matches *any* hunk of differing prose and files it under a rendering
162 // choice nobody made. That is how org 9.8's malformed ` ` in the heading
163 // tag prefix arrived here labelled `semantic-time`.
164 ours.iter().any(|l| is_tag(l, &["time"]))
165 && ours
166 .iter()
167 .all(|l| is_tag(l, &["time"]) || text_of(l).is_some())
168 && theirs.iter().any(|l| has_timestamp(l))
169 && theirs.iter().all(|l| has_timestamp(l) || text_of(l).is_some())
170 },
171 in_notes_only: false,
172 },
173 // The priority cookie stays in the heading: the author wrote it, and a reader
174 // skimming a task list wants to see it. Org drops it, because
175 // `org-export-with-priority` defaults to nil. Heading decoration is a policy
176 // difference, not a structural one.
177 Deliberate {
178 name: "heading-priority-cookie",
179 matches: |ours, theirs| match (one_text(ours), one_text(theirs)) {
180 (Some(ours), Some(theirs)) => without_priority(ours).as_deref() == Some(theirs),
181 _ => false,
182 },
183 in_notes_only: false,
184 },
185 // `<figure>`/`<figcaption>` instead of two paragraphs in a div.
186 Deliberate {
187 name: "figure-element",
188 matches: |ours, theirs| {
189 ours.iter().any(|l| is_tag(l, &["figure", "figcaption"]))
190 && ours
191 .iter()
192 .all(|l| is_tag(l, &["figure", "figcaption"]) || text_of(l).is_some())
193 && theirs.iter().all(|l| is_tag(l, &["p"]) || text_of(l).is_some())
194 },
195 in_notes_only: false,
196 },
197 // `<em>`/`<strong>` instead of org's presentational `<i>`/`<b>`.
198 Deliberate {
199 name: "semantic-emphasis",
200 matches: |ours, theirs| {
201 !ours.is_empty()
202 && ours.iter().all(|l| is_tag(l, &["em", "strong", "del"]))
203 && theirs.iter().all(|l| is_tag(l, &["i", "b", "s", "del"]))
204 },
205 in_notes_only: false,
206 },
207 // `<pre><code>`: the nested element is what every syntax highlighter and every
208 // reader's stylesheet expects. Org 9.8 emits it too, so this no longer fires — kept
209 // because org 9.7 and earlier write a bare `<pre>`, and the oracle is whatever Emacs
210 // is on the machine.
211 Deliberate {
212 name: "pre-code",
213 matches: |ours, theirs| {
214 !ours.is_empty() && ours.iter().all(|l| is_tag(l, &["code"])) && theirs.is_empty()
215 },
216 in_notes_only: false,
217 },
218 // Org emits a `<colgroup>` of empty `<col>`s to carry column alignment; orgo
219 // leaves alignment to the stylesheet.
220 Deliberate {
221 name: "no-colgroup",
222 matches: |ours, theirs| {
223 ours.is_empty()
224 && !theirs.is_empty()
225 && theirs.iter().all(|l| is_tag(l, &["colgroup", "col"]) || l == "<col>")
226 },
227 in_notes_only: false,
228 },
229 // Footnote ids: `fn-1` rather than org's `fn.1`, because a dot in an id is awkward in
230 // a CSS selector.
231 Deliberate {
232 name: "footnote-anchor-naming",
233 matches: |ours, theirs| {
234 ours.len() == 1
235 && theirs.len() == 1
236 && ours[0].replace("fn-", "fn.") == theirs[0]
237 && ours[0].contains("#fn")
238 },
239 in_notes_only: false,
240 },
241 // The notes section itself: an `<ol>` under a rule, rather than org's headed div of
242 // paragraphs, and a `↩` back-link rather than a repeated superscript number. Same
243 // notes, same order, same links, in the shape a screen reader announces as a list.
244 Deliberate {
245 name: "footnote-section-shape",
246 matches: |ours, theirs| {
247 let ours_is_notes = ours.iter().all(|l| {
248 is_tag(l, &["section", "ol", "li", "a", "sup", "p"])
249 || l == "<hr>"
250 || l.starts_with("<a href=\"#fn")
251 || text_of(l) == Some("↩")
252 || text_of(l).is_some()
253 });
254 let theirs_is_notes = theirs.iter().all(|l| {
255 is_tag(l, &["h2", "sup", "p", "a", "div"])
256 || l.starts_with("<a href=\"#fn")
257 || text_of(l).is_some()
258 });
259 let touches_notes = ours.iter().chain(theirs).any(|l| {
260 l.contains("#fn") || text_of(l) == Some("Footnotes:") || is_tag(l, &["section"])
261 });
262 touches_notes && ours_is_notes && theirs_is_notes
263 },
264 in_notes_only: false,
265 },
266 // Two note definitions abutting: `</li><li>` where org writes `</sup><p>`.
267 Deliberate {
268 name: "footnote-section-shape",
269 matches: |ours, theirs| {
270 !ours.is_empty()
271 && ours.iter().all(|l| is_tag(l, &["li", "ol", "section"]))
272 && theirs.iter().all(|l| is_tag(l, &["p", "sup", "div"]))
273 },
274 in_notes_only: true,
275 },
276 // Verse ends without a trailing `<br>`: org emits one for the final newline, which is
277 // a blank line at the end of the stanza and nothing else.
278 Deliberate {
279 name: "verse-trailing-break",
280 matches: |ours, theirs| ours.is_empty() && theirs == ["<br>"],
281 in_notes_only: false,
282 },
283 // `[[id:…]]` resolves here and does not in the oracle: a single-file `emacs --batch`
284 // export has no id database, so org drops the link and keeps its text. This is a
285 // property of the harness, not of either exporter.
286 Deliberate {
287 name: "id-link-resolution",
288 matches: |ours, theirs| {
289 ours.iter().any(|l| l.starts_with("<a href=\"#"))
290 && ours.iter().all(|l| is_tag(l, &["a"]) || l.starts_with("<a href=") || text_of(l).is_some())
291 && theirs.iter().all(|l| text_of(l).is_some())
292 },
293 in_notes_only: false,
294 },
295 // A change of bullet starts a new list. Org instead continues the list — two blank
296 // lines end one, not a switch from `1.` to `-` — so a dash item written under a
297 // numbered list is exported *numbered*. We split, which is what the author drew.
298 // Deliberate, and the one entry here that is arguably worth revisiting.
299 Deliberate {
300 name: "list-per-bullet-type",
301 matches: |ours, theirs| {
302 !ours.is_empty()
303 && ours.iter().all(|l| is_tag(l, &["ol", "ul"]))
304 && theirs.iter().all(|l| is_tag(l, &["ol", "ul"]))
305 },
306 in_notes_only: false,
307 },
308];
309
310/// One run of differing lines: what we wrote, and what Emacs wrote.
311struct Hunk {
312 ours: Vec<String>,
313 theirs: Vec<String>,
314}
315
316impl Hunk {
317 fn len(&self) -> usize {
318 self.ours.len().max(self.theirs.len())
319 }
320
321 fn deliberate(&self, in_notes: bool) -> Option<&'static str> {
322 DELIBERATE
323 .iter()
324 .filter(|d| in_notes || !d.in_notes_only)
325 .find(|d| (d.matches)(&self.ours, &self.theirs))
326 .map(|d| d.name)
327 }
328
329 /// Does this hunk start the footnote section?
330 fn starts_notes(&self) -> bool {
331 self.ours
332 .iter()
333 .chain(&self.theirs)
334 .any(|l| l == "<section>" || text_of(l) == Some("Footnotes:"))
335 }
336}
337
338enum Op {
339 Same(String),
340 Differs(Hunk),
341}
342
343/// Longest-common-subsequence walk, grouped into runs of agreement and disagreement.
344fn align(ours: &[String], theirs: &[String]) -> Vec<Op> {
345 let (n, m) = (ours.len(), theirs.len());
346 let mut lcs = vec![vec![0usize; m + 1]; n + 1];
347 for i in (0..n).rev() {
348 for j in (0..m).rev() {
349 lcs[i][j] = if ours[i] == theirs[j] {
350 lcs[i + 1][j + 1] + 1
351 } else {
352 lcs[i + 1][j].max(lcs[i][j + 1])
353 };
354 }
355 }
356
357 let mut out: Vec<Op> = Vec::new();
358 let push_diff = |out: &mut Vec<Op>, mine: Option<String>, theirs: Option<String>| {
359 if let Some(Op::Differs(h)) = out.last_mut() {
360 h.ours.extend(mine);
361 h.theirs.extend(theirs);
362 return;
363 }
364 out.push(Op::Differs(Hunk {
365 ours: mine.into_iter().collect(),
366 theirs: theirs.into_iter().collect(),
367 }));
368 };
369
370 let (mut i, mut j) = (0, 0);
371 while i < n && j < m {
372 if ours[i] == theirs[j] {
373 out.push(Op::Same(ours[i].clone()));
374 i += 1;
375 j += 1;
376 } else if lcs[i + 1][j] >= lcs[i][j + 1] {
377 push_diff(&mut out, Some(ours[i].clone()), None);
378 i += 1;
379 } else {
380 push_diff(&mut out, None, Some(theirs[j].clone()));
381 j += 1;
382 }
383 }
384 for line in &ours[i..] {
385 push_diff(&mut out, Some(line.clone()), None);
386 }
387 for line in &theirs[j..] {
388 push_diff(&mut out, None, Some(line.clone()));
389 }
390 out
391}
392
393/// A unified diff of the two skeletons, with hunks that are deliberate collapsed to a
394/// named line. `-` is orgo, `+` is Emacs.
395///
396/// The number that matters is the last one: *unexplained* lines. Agreement can be low
397/// while unexplained is zero, and that is a passing state.
398fn divergence(ours: &[String], theirs: &[String]) -> String {
399 let ops = align(ours, theirs);
400 let mut agreed = 0usize;
401 let mut deliberate = 0usize;
402 let mut unexplained = 0usize;
403 let mut by_rule: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
404 let mut body = String::new();
405 let mut in_notes = false;
406
407 for op in &ops {
408 match op {
409 Op::Same(line) => {
410 agreed += 1;
411 body.push_str(&format!(" {line}\n"));
412 }
413 Op::Differs(hunk) => {
414 in_notes |= hunk.starts_notes();
415 match hunk.deliberate(in_notes) {
416 Some(rule) => {
417 deliberate += hunk.len();
418 *by_rule.entry(rule).or_default() += 1;
419 body.push_str(&format!("~ {rule} ({} line(s))\n", hunk.len()));
420 }
421 None => {
422 unexplained += hunk.len();
423 for line in &hunk.ours {
424 body.push_str(&format!("- {line}\n"));
425 }
426 for line in &hunk.theirs {
427 body.push_str(&format!("+ {line}\n"));
428 }
429 }
430 }
431 }
432 }
433 }
434
435 let total = ours.len().max(theirs.len());
436 let pct = if total == 0 {
437 100.0
438 } else {
439 100.0 * agreed as f64 / total as f64
440 };
441 let rules: Vec<String> = by_rule
442 .iter()
443 .map(|(name, n)| format!("{name} ×{n}"))
444 .collect();
445 format!(
446 "agreement: {agreed}/{total} skeleton lines ({pct:.1}%)\n\
447 deliberate: {deliberate} line(s){}\n\
448 unexplained: {unexplained} line(s)\n\
449 (- orgo, + emacs, ~ a difference we mean to have)\n\n{body}",
450 if rules.is_empty() {
451 String::new()
452 } else {
453 format!(" — {}", rules.join(", "))
454 }
455 )
456}
457
458/// Snapshot the divergence between orgo and Emacs for one fixture.
459fn compare(fixture: &str) -> Option<String> {
460 if !emacs_available() {
461 eprintln!("skipping oracle comparison for {fixture}: no emacs on PATH");
462 return None;
463 }
464 let ours = skeleton(&our_export(fixture));
465 let theirs = skeleton(&org_export(fixture));
466 Some(format!(
467 "oracle: {}\n{}",
468 oracle_version(),
469 divergence(&ours, &theirs)
470 ))
471}
472
473macro_rules! oracle_test {
474 ($name:ident, $fixture:literal) => {
475 #[test]
476 fn $name() {
477 if let Some(report) = compare($fixture) {
478 insta::assert_snapshot!(report);
479 }
480 }
481 };
482}
483
484oracle_test!(oracle_minimal, "minimal.org");
485oracle_test!(oracle_core, "core.org");
486oracle_test!(oracle_headings, "headings.org");
487oracle_test!(oracle_lists, "lists.org");
488oracle_test!(oracle_blocks, "blocks.org");
489oracle_test!(oracle_table, "table.org");
490oracle_test!(oracle_footnote, "footnote.org");
491oracle_test!(oracle_timestamps, "timestamps.org");
492oracle_test!(oracle_images, "images.org");
493oracle_test!(oracle_elements, "elements.org");
494
495/// The gate the snapshots cannot be: *every* divergence from Emacs must be one we chose.
496///
497/// The percentages above are context, not a target — the timestamps fixture agrees on
498/// 40% of its lines and is entirely correct, because org writes a date as text where
499/// orgo writes `<time datetime>`. What must hold is that nothing diverges for a
500/// reason nobody has written down. A new unexplained line means either a defect to fix
501/// or a decision to record in `DELIBERATE`.
502#[test]
503fn every_divergence_from_emacs_is_deliberate() {
504 let fixtures = [
505 "minimal.org",
506 "core.org",
507 "headings.org",
508 "lists.org",
509 "blocks.org",
510 "table.org",
511 "footnote.org",
512 "timestamps.org",
513 "images.org",
514 "elements.org",
515 "tblfm.org",
516 "audit-entities.org",
517 ];
518 let mut offenders = Vec::new();
519 for fixture in fixtures {
520 let Some(report) = compare(fixture) else {
521 return; // no emacs on PATH; the suite skips cleanly
522 };
523 if !report.contains("unexplained: 0 line(s)") {
524 let count = report
525 .lines()
526 .find(|l| l.starts_with("unexplained:"))
527 .unwrap_or("unexplained: ?");
528 offenders.push(format!("{fixture}: {count}"));
529 }
530 }
531 assert!(
532 offenders.is_empty(),
533 "these fixtures diverge from Emacs for unrecorded reasons — fix the defect, or \
534 add a rule to DELIBERATE saying why the difference is wanted:\n {}",
535 offenders.join("\n ")
536 );
537}
538
539// ---------------------------------------------------------------------------
540// Invariants that must hold against the oracle, not merely be snapshotted
541// ---------------------------------------------------------------------------
542
543/// org 9.8 writes the heading tag prefix as `  ` — the last reference
544/// unterminated. HTML5 decodes the legacy named set without the semicolon, so a browser
545/// reads that as three non-breaking spaces; the skeleton has to as well, or a heading
546/// carrying tags reads as `A Subheading  tag1 tag2` and the whole report shifts.
547/// Asserted here rather than left to the snapshots because it must hold whichever Emacs
548/// is installed, including none.
549#[test]
550fn unterminated_named_references_decode_like_a_browser() {
551 assert_eq!(
552 decode_entities("A Subheading  tag1 tag2"),
553 "A Subheading tag1 tag2"
554 );
555 assert_eq!(decode_entities("a & b"), "a & b");
556 assert_eq!(decode_entities("Q&A"), "Q&A");
557}
558
559/// How many headings a document has and at what depth is the shape of the document.
560/// Getting it wrong reorganizes someone's writing, so it is asserted rather than
561/// snapshotted. Heading *decoration* (priority cookies, tag markup) is a policy
562/// difference and is left to the snapshots.
563#[test]
564fn heading_structure_matches_emacs() {
565 if !emacs_available() {
566 eprintln!("skipping: no emacs on PATH");
567 return;
568 }
569 for fixture in ["minimal.org", "core.org", "headings.org", "lists.org"] {
570 let ours = heading_levels(&skeleton(&our_export(fixture)));
571 let theirs = heading_levels(&skeleton(&org_export(fixture)));
572 assert_eq!(
573 ours, theirs,
574 "heading structure diverges from Emacs in {fixture}"
575 );
576 }
577}
578
579/// The sequence of heading open tags, e.g. `["<h1>", "<h2>", "<h1>"]`.
580fn heading_levels(skeleton: &[String]) -> Vec<String> {
581 skeleton
582 .iter()
583 .filter(|l| l.starts_with("<h") && l[2..].starts_with(|c: char| c.is_ascii_digit()))
584 .cloned()
585 .collect()
586}
587
588/// A list is the construct where nesting is easiest to get subtly wrong, and where being
589/// wrong changes the meaning of the document rather than its looks.
590#[test]
591fn list_nesting_matches_emacs() {
592 if !emacs_available() {
593 eprintln!("skipping: no emacs on PATH");
594 return;
595 }
596 let ours = list_shape(&skeleton(&our_export("lists.org")));
597 let theirs = list_shape(&skeleton(&org_export("lists.org")));
598 assert_eq!(ours, theirs, "list nesting diverges from Emacs");
599}
600
601/// The sequence of list opens/closes, ignoring content — the shape of the nesting.
602fn list_shape(skeleton: &[String]) -> Vec<String> {
603 skeleton
604 .iter()
605 .filter(|l| {
606 matches!(
607 l.as_str(),
608 "<ul>" | "</ul>" | "<ol>" | "</ol>" | "<li>" | "</li>" | "<dl>" | "</dl>"
609 | "<dt>" | "</dt>" | "<dd>" | "</dd>"
610 )
611 })
612 .cloned()
613 .collect()
614}
615
616/// Code must survive verbatim. Highlighting markup differs by construction (syntect
617/// spans vs htmlize), but if the *characters of the program* differ, we have corrupted
618/// the author's content.
619#[test]
620fn source_block_text_matches_emacs() {
621 if !emacs_available() {
622 eprintln!("skipping: no emacs on PATH");
623 return;
624 }
625 for fixture in ["blocks.org", "core.org", "elements.org"] {
626 let ours = code_text(&our_export(fixture));
627 let theirs = code_text(&org_export(fixture));
628 assert_eq!(ours, theirs, "source block text diverges from Emacs in {fixture}");
629 }
630}
631
632/// All text inside `<pre>` blocks, with tags stripped and whitespace collapsed.
633fn code_text(html: &str) -> Vec<String> {
634 let mut out = Vec::new();
635 let mut rest = html;
636 while let Some(start) = rest.find("<pre") {
637 let after = &rest[start..];
638 let Some(open_end) = after.find('>') else { break };
639 let Some(close) = after.find("</pre>") else { break };
640 let inner = &after[open_end + 1..close];
641 out.push(strip_tags(inner));
642 rest = &after[close + 6..];
643 }
644 out
645}
646
647/// All text in a fragment with tags removed and entities decoded, then whitespace
648/// collapsed once at the end.
649///
650/// [`skeleton`] cannot do this job: it trims each text run individually, which is
651/// invisible for prose (one run per paragraph) but destructive for highlighted code,
652/// where syntect splits a line into one run per token and the spaces *between* tokens
653/// live at the edges of those runs. Trimming each run turns `def greet` into `defgreet`.
654fn strip_tags(html: &str) -> String {
655 let mut text = String::new();
656 let mut rest = html;
657 while let Some(open) = rest.find('<') {
658 text.push_str(&rest[..open]);
659 match rest[open..].find('>') {
660 Some(close) => rest = &rest[open + close + 1..],
661 None => {
662 rest = "";
663 break;
664 }
665 }
666 }
667 text.push_str(rest);
668 decode_entities(&text)
669 .split_whitespace()
670 .collect::<Vec<_>>()
671 .join(" ")
672}