krz/orgo

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

tests/oracle.rs

672 lines · 25590 bytes

32 symbols in this file
  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 `&nbsp` 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 `&nbsp;&nbsp;&nbsp` — 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 &nbsptag1 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&nbsp;&nbsp;&nbsptag1&nbsp;tag2"),
553        "A Subheading   tag1 tag2"
554    );
555    assert_eq!(decode_entities("a &amp 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}