Skip to main content

max / pter

Trim line-trailing whitespace before collapsing newline runs, not after normalize() collapsed runs of 3+ newlines and then trimmed each line's trailing whitespace. That order cannot work, because the trim is itself a source of blank lines: a line holding only spaces is not blank until it is trimmed, so "a\n \n\nb" carries no run of three newlines when the collapse inspects it and carries one immediately afterwards, with nothing left to run. The invariant the property test asserts was being broken by the function that exists to establish it. Reordering is the whole fix. Trim first, then collapse, then trim the ends. This is what `<pre/><strong/><h1/><br> </br>` was hitting -- the `<br> </br>` supplies the whitespace-only line, `<strong/>` parses as an open tag so its delimiters wrap the block separators, and the result reached "```\n**\n\n#\n\n\n**\n```". All four counterexamples the amplified proptest cell found on astra are that one shape with different elements supplying the space, which is why three of them looked closed: settle_fence_body had been patching the symptom inside fences since 2026-08-22 while the cause sat one layer down. Two tests at the layer the bug lives in, rather than only through convert(). Closes pter 6ad28ea7.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
Author: Max Johnson <me@maxj.phd> · 2026-08-31 23:39 UTC
Signed with PGP, not checked
Commit: 751ddbf645e220966cf0604079b84901efbadac1
Parent: 55d0db1
2 files changed, +48 insertions, -8 deletions
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "pter"
3 - version = "0.2.1"
3 + version = "0.2.2"
4 4 edition = "2024"
5 5 description = "Plain Text Email Renderer — convert HTML email bodies into readable markdown"
6 6 license = "MIT"
@@ -1,13 +1,30 @@
1 1 /// Normalize whitespace in the final markdown output.
2 2 ///
3 + /// - Remove trailing whitespace from each line
3 4 /// - Collapse runs of 3+ newlines into 2 (one blank line)
4 5 /// - Trim leading/trailing whitespace
5 - /// - Remove trailing whitespace from each line
6 + ///
7 + /// The per-line trim runs FIRST, and the order is the whole point. Trimming
8 + /// turns a line holding only spaces into an empty one, which is to say it
9 + /// *creates* blank lines: `"a\n \n\nb"` has no run of three newlines until the
10 + /// space is gone, and then it has one. Collapsing first and trimming second
11 + /// therefore emits `\n\n\n` from input that looked clean when the collapse
12 + /// examined it, and nothing runs afterwards to catch it.
13 + ///
14 + /// That is not hypothetical. It is how `<pre/><strong/><h1/><br> </br>` reached
15 + /// `"```\n**\n\n#\n\n\n**\n```"` -- the `<br> </br>` contributes the whitespace-only
16 + /// line, and every sibling counterexample the amplified property test found is
17 + /// the same shape with a different element supplying the space.
6 18 pub(crate) fn normalize(input: &str) -> String {
7 - let mut result = String::with_capacity(input.len());
19 + // Trailing whitespace goes first, so the collapse below sees the blank
20 + // lines this creates rather than the whitespace that was hiding them.
21 + let trimmed: Vec<&str> = input.lines().map(str::trim_end).collect();
22 + let trimmed = trimmed.join("\n");
23 +
24 + let mut result = String::with_capacity(trimmed.len());
8 25 let mut consecutive_newlines = 0u32;
9 26
10 - for ch in input.chars() {
27 + for ch in trimmed.chars() {
11 28 if ch == '\n' {
12 29 consecutive_newlines += 1;
13 30 if consecutive_newlines <= 2 {
@@ -19,10 +36,7 @@
19 36 }
20 37 }
21 38
22 - // Trim trailing whitespace from each line
23 - let lines: Vec<&str> = result.lines().map(str::trim_end).collect();
24 - let joined = lines.join("\n");
25 - joined.trim().to_string()
39 + result.trim().to_string()
26 40 }
27 41
28 42 #[cfg(test)]
@@ -53,4 +67,30 @@
53 67 fn empty_input() {
54 68 assert_eq!(normalize(""), "");
55 69 }
70 +
71 + #[test]
72 + fn a_whitespace_only_line_between_blanks_does_not_survive_as_a_third_newline() {
73 + // The line holding one space is not a blank line until it is trimmed,
74 + // so a collapse that runs before the trim never sees the run of three
75 + // this becomes. Trimming first is what makes the collapse honest.
76 + assert_eq!(normalize("a\n \n\nb"), "a\n\nb");
77 + assert_eq!(normalize("a\n\n \nb"), "a\n\nb");
78 + assert_eq!(normalize("a\n \n \n \nb"), "a\n\nb");
79 + }
80 +
81 + #[test]
82 + fn no_output_ever_carries_three_consecutive_newlines() {
83 + for input in [
84 + "a\n \n\nb",
85 + "**\n\n#\n \n\n**",
86 + "x\n\t\n\ny",
87 + "p\n \n \n \nq",
88 + ] {
89 + let out = normalize(input);
90 + assert!(
91 + !out.contains("\n\n\n"),
92 + "triple newline survived for {input:?}: {out:?}"
93 + );
94 + }
95 + }
56 96 }