Skip to main content

max / pter

Settle a fence body so a pre holding block markup cannot close over a blank line convert("<pre><ul> </ul></pre>") returned "```\n\n\n```". A <pre> whose content is block markup gets that markup's separators too, and when the blocks have nothing to say the separators are all that reaches the fence. The no_excessive_blank_lines property forbids exactly that shape. The fence path now drops the body's trailing whitespace and its leading newlines and collapses interior runs to one blank line. A first line's indent survives and an ordinary <pre><code> body has none of these to lose, so it comes out unchanged. no_excessive_blank_lines moves to its own block at 100,000 cases. The default 256 had never once drawn this shape; witchbroom saw 44,991 successes before the failure.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-23 21:09 UTC
Signed with PGP, not checked
Commit: 8b5d2593b92c7dfa857691137c2752cc8b1480fb
Parent: b577c60
3 files changed, +83 insertions, -6 deletions
@@ -93,6 +93,43 @@
93 93 }
94 94 }
95 95
96 + /// Trim the blank lines a fence body picked up from block markup inside it.
97 + ///
98 + /// A `<pre>` holding block elements gets their separators too: each one
99 + /// calls `ensure_blank_line`, and a `<pre><ul> </ul></pre>` whose list has
100 + /// nothing to say leaves only those separators behind. The fence then
101 + /// closes over three consecutive newlines, which is the one shape markdown
102 + /// output is never supposed to carry.
103 + ///
104 + /// The body loses its trailing whitespace and its leading newlines, and
105 + /// interior runs collapse to a single blank line, which is what a blank
106 + /// line inside a code block means anyway. A first line's indent survives,
107 + /// and an ordinary `<pre><code>` body has none of these to lose, so it
108 + /// comes out as it went in.
109 + fn settle_fence_body(&mut self, body_start: usize) {
110 + // Trailing whitespace goes entirely, leading newlines with it: the
111 + // opening fence already ended a line and the closing one starts its
112 + // own. Leading spaces stay, since those are the first line's indent.
113 + let trimmed = self.output[body_start..]
114 + .trim_end()
115 + .trim_start_matches('\n');
116 + let mut settled = String::with_capacity(trimmed.len());
117 + let mut runs = 0usize;
118 + for c in trimmed.chars() {
119 + if c == '\n' {
120 + runs += 1;
121 + if runs <= 2 {
122 + settled.push(c);
123 + }
124 + } else {
125 + runs = 0;
126 + settled.push(c);
127 + }
128 + }
129 + self.output.truncate(body_start);
130 + self.output.push_str(&settled);
131 + }
132 +
96 133 fn list_indent(&self) -> String {
97 134 if self.list_depth <= 1 {
98 135 return String::new();
@@ -260,9 +297,11 @@
260 297 BlockKind::PreFormatted => {
261 298 ctx.ensure_blank_line();
262 299 ctx.push("```\n");
300 + let body_start = ctx.output.len();
263 301 ctx.in_pre = true;
264 302 walk_children(el, ctx);
265 303 ctx.in_pre = false;
304 + ctx.settle_fence_body(body_start);
266 305 ctx.ensure_newline();
267 306 ctx.push("```");
268 307 ctx.ensure_blank_line();
@@ -200,3 +200,33 @@
200 200 assert!(md.contains("> "));
201 201 assert!(md.contains("> Second para"));
202 202 }
203 +
204 + /// Found by the `no_excessive_blank_lines` property after 44,991 draws, which
205 + /// is why the count in `tests/proptest.rs` is no longer proptest's default.
206 + ///
207 + /// The list inside the fence has nothing to say, so all that reaches the body
208 + /// is the blank lines its own block separators asked for, and the fence closes
209 + /// over three consecutive newlines.
210 + #[test]
211 + fn a_pre_holding_only_whitespace_block_markup_closes_an_empty_fence() {
212 + assert_eq!(pter::convert("<pre><ul> </ul></pre>"), "```\n```");
213 + }
214 +
215 + /// The same shape with something to say: the fence keeps its content and the
216 + /// separators around it still collapse.
217 + #[test]
218 + fn a_pre_holding_block_markup_keeps_the_text_without_the_separators() {
219 + let md = pter::convert("<pre><p>one</p><p>two</p></pre>");
220 + assert!(!md.contains("\n\n\n"), "{md:?}");
221 + assert!(md.contains("one"), "{md:?}");
222 + assert!(md.contains("two"), "{md:?}");
223 + }
224 +
225 + /// And an ordinary code block is untouched, indent and all.
226 + #[test]
227 + fn a_pre_code_body_survives_the_fence_settling() {
228 + assert_eq!(
229 + pter::convert("<pre><code>fn main() {\n println!(\"hi\");\n}</code></pre>"),
230 + "```\nfn main() {\n println!(\"hi\");\n}\n```"
231 + );
232 + }
@@ -89,12 +89,6 @@
89 89 assert!(!md.contains('\u{FFFD}'), "replacement char in: {md}");
90 90 }
91 91
92 - #[test]
93 - fn no_excessive_blank_lines(html in html_fragment()) {
94 - let md = pter::convert(&html);
95 - assert!(!md.contains("\n\n\n"), "triple newline in output: {md}");
96 - }
97 -
98 92 #[test]
99 93 fn no_trailing_whitespace_on_lines(html in html_fragment()) {
100 94 let md = pter::convert(&html);
@@ -114,3 +108,17 @@
114 108 assert!(md.trim().is_empty() || !s.trim().is_empty());
115 109 }
116 110 }
111 +
112 + // Drawn far past the default because the shape that broke this property took
113 + // 44,991 draws to appear: a `<pre>` whose block markup has nothing to say, so
114 + // only its separators reach the fence. 256 draws had never once produced it.
115 + // The case lives in tests/edge_cases.rs; this is what would find the next one.
116 + proptest! {
117 + #![proptest_config(ProptestConfig::with_cases(100_000))]
118 +
119 + #[test]
120 + fn no_excessive_blank_lines(html in html_fragment()) {
121 + let md = pter::convert(&html);
122 + assert!(!md.contains("\n\n\n"), "triple newline in output: {md}");
123 + }
124 + }