Skip to main content

max / docengine

docengine: tests for the heading injector and the inline-only separators The render chain's first mutation run left 9 of 71 mutants alive (infra bb414371). Four were real: - deleting the Event::Code arm in inject_heading_ids passed the whole suite, so a heading with an inline code span would have anchored without it while extract_toc pointed at the anchor that included it. Every existing TOC test used headings without code spans. - the arithmetic that advances past a heading was unobserved, and off by one in the low direction loops forever on an empty heading. - both inline_only guards were unobserved in both directions. The existing chat test asserted the items do not fuse, which the HTML printer's own newline already guarantees; what the guards add is the second newline, so that is what the new tests pin. The remaining five are equivalent mutants, recorded with their reasons in .cargo/mutants.toml. permissive_builder is the flattest of them: its body already IS ammonia::Builder::default(). 66 mutants, 41 caught, 21 unviable, 4 timeouts, 0 missed.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-27 01:56 UTC
Signed with PGP, not checked
Commit: a41c6a2b1f03ab4e71aa77139f41716175227edd
Parent: 073828f
2 files changed, +119 insertions, -4 deletions
M src/render.rs +88 -4
@@ -416,14 +416,25 @@
416 416
417 417 let inline_only = self.inline_only;
418 418 let filtered = filtered.flat_map(move |event| match event {
419 - // The `</li>` is about to be unwrapped, taking the only thing that
420 - // separated one item's text from the next with it.
419 + // The `</li>` is about to be unwrapped, taking one of the two things
420 + // that separated one item's text from the next with it.
421 + //
422 + // MEASURED 2026-08-26 (infra `bb414371`): the other one holds
423 + // without this, so what the break actually buys is a blank line
424 + // rather than rescue from "onetwo". pulldown-cmark 0.13 writes its
425 + // own newline between `</li>` and the next `<li>`, and ammonia
426 + // leaves that newline behind when it unwraps the tags. Kept because
427 + // the blank line is what the inline presets want, and pinned by
428 + // `inline_only_separates_the_blocks_it_flattens` so a printer that
429 + // stops writing that newline does not quietly take the separator
430 + // with it.
421 431 Event::End(TagEnd::Item) if inline_only => {
422 432 vec![Event::End(TagEnd::Item), Event::SoftBreak]
423 433 }
424 434 // Same for `</p>` wherever the sanitize preset unwraps that too
425 - // (`phrase` does, `chat` does not). Without it two paragraphs fuse
426 - // into one word across the join. Harmless where `p` is kept: it is
435 + // (`phrase` does, `chat` does not), and with the same correction as
436 + // above: a blank line between the two paragraphs, not the only thing
437 + // standing between them. Harmless where `p` is kept: it is
427 438 // whitespace between two block tags.
428 439 Event::End(TagEnd::Paragraph) if inline_only => {
429 440 vec![Event::End(TagEnd::Paragraph), Event::SoftBreak]
@@ -1052,6 +1063,79 @@
1052 1063 assert!(html.contains(r#"<h2 id="section-title">"#), "got: {html}");
1053 1064 }
1054 1065
1066 + #[test]
1067 + fn an_empty_heading_does_not_stall_the_injector() {
1068 + // `##` with nothing after it is a Start immediately followed by an End,
1069 + // so the inner scan stops on the very next event. Mutation found the
1070 + // arithmetic that advances past a heading unobserved (infra
1071 + // `bb414371`), and off by one in the low direction on this input means
1072 + // the same heading is found forever.
1073 + let html = Renderer::permissive()
1074 + .with_heading_ids(true)
1075 + .render("##\n\ntext");
1076 + assert!(html.contains("text"), "got: {html}");
1077 + }
1078 +
1079 + #[test]
1080 + fn a_headings_anchor_includes_its_inline_code() {
1081 + // `extract_toc` reads code spans as part of the heading text, so the
1082 + // injector has to as well or the two disagree exactly where API docs
1083 + // live: `## Use `render_raw` now` would anchor as "use-now" while the
1084 + // TOC pointed at "use-render_raw-now". Found by mutation (infra
1085 + // `bb414371`): deleting the `Event::Code` arm passed the whole suite.
1086 + let md = "## Use `render_raw` now";
1087 + let html = Renderer::permissive().with_heading_ids(true).render(md);
1088 + let toc = crate::extract_toc(md);
1089 + assert_eq!(toc.len(), 1);
1090 + assert!(
1091 + html.contains(&format!(r#"id="{}""#, toc[0].anchor)),
1092 + "TOC points at #{} but the heading carries something else: {html}",
1093 + toc[0].anchor
1094 + );
1095 + assert!(
1096 + html.contains("render_raw"),
1097 + "the code span left the anchor: {html}"
1098 + );
1099 + }
1100 +
1101 + #[test]
1102 + fn inline_only_separates_the_blocks_it_flattens() {
1103 + // `<li>` and `<p>` are unwrapped by the inline presets, and what is
1104 + // left between two of them is whatever the HTML printer wrote. Measured
1105 + // 2026-08-26 against pulldown-cmark 0.13: that is a single newline, and
1106 + // these renderers add the second one. The separator is the point, so
1107 + // pin it rather than the fusion the comment above warns about, which
1108 + // the printer's own newline already prevents at this version.
1109 + assert!(
1110 + Renderer::phrase()
1111 + .render("one\n\ntwo")
1112 + .contains("one\n\ntwo"),
1113 + "paragraphs lost their separator: {:?}",
1114 + Renderer::phrase().render("one\n\ntwo")
1115 + );
1116 + assert!(
1117 + Renderer::chat()
1118 + .render("- one\n- two")
1119 + .contains("one\n\ntwo"),
1120 + "list items lost their separator: {:?}",
1121 + Renderer::chat().render("- one\n- two")
1122 + );
1123 + }
1124 +
1125 + #[test]
1126 + fn a_block_renderer_does_not_get_the_inline_separators() {
1127 + // The other direction: the extra break belongs to the presets that
1128 + // unwrap the tag, and a renderer keeping `<p>` must not collect blank
1129 + // lines it never asked for.
1130 + for md in ["one\n\ntwo", "- one\n- two"] {
1131 + let html = Renderer::permissive().render(md);
1132 + assert!(
1133 + !html.contains("\n\n"),
1134 + "block rendering picked up an inline-only separator: {html:?}"
1135 + );
1136 + }
1137 + }
1138 +
1055 1139 #[test]
1056 1140 fn every_toc_anchor_has_a_matching_heading_id() {
1057 1141 // The bug this fixes: TOC emitted href="#anchor" and nothing on the
@@ -1,0 +1,31 @@
1 + # Mutants no test can kill, with the reason each is equivalent to the code it
2 + # replaces. Recorded from the first mutation run of the render chain (infra
3 + # `bb414371`, 2026-08-26: 9 survivors, 2 timeouts). Excluding them is what keeps
4 + # a later run's "0 missed" worth reading.
5 + #
6 + # The patterns are regexes, so `+`, `*` and `|` need escaping. A bare `||` is an
7 + # empty alternation and silently excludes every mutant in the crate.
8 + #
9 + # Add an entry only with its reason written out, and only after trying to kill
10 + # the mutant with a test: of the nine survivors here, four were coverage gaps.
11 + exclude_re = [
12 + # `inject_heading_ids` scans for a heading's text between its Start and its
13 + # End. Starting that scan at the Start event itself rather than one past it
14 + # changes nothing: `Event::Start` falls to the `_ => {}` arm and contributes
15 + # no text. Same for the outer cursor: landing on the End event instead of
16 + # one past it costs one loop iteration that matches no Start.
17 + "replace \\+ with \\* in inject_heading_ids",
18 +
19 + # The inner scan's bound. pulldown-cmark always closes a heading it opened,
20 + # so the loop leaves on `Event::End(TagEnd::Heading)` and the bound is never
21 + # the thing that stops it. `<=` would only differ on an event stream that
22 + # ends inside a heading, which the parser cannot produce.
23 + "replace < with <= in inject_heading_ids",
24 +
25 + # `permissive_builder` IS `ammonia::Builder::default()`, so replacing its
26 + # body with `Default::default()` is the same code. The function exists to be
27 + # the one named construction point for the permissive preset (UX-S3), not to
28 + # behave differently from the default, and the regression tests in
29 + # `sanitize` pin what that default has to keep doing.
30 + "replace permissive_builder -> ammonia::Builder<'static> with Default::default\\(\\)",
31 + ]