max / pter
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
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 | + | } |