mdlint/formatter/mod.rs
1use std::fmt::Write as _;
2
3use pulldown_cmark::{Alignment, CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
4
5/// Format a Markdown document to canonical style.
6///
7/// Returns the formatted document as a String. The output:
8/// - Always ends with exactly one trailing newline (or is empty for empty input)
9/// - Has exactly one blank line between top-level block elements
10/// - Uses ATX-style headings
11/// - Uses `-` for unordered list markers
12/// - Uses backtick fences for code blocks
13#[must_use]
14pub fn format(input: &str) -> String {
15 if input.trim().is_empty() {
16 return String::new();
17 }
18
19 let mut state = FormatterState::new();
20 let events: Vec<Event<'_>> = Parser::new_ext(input, mk_options()).collect();
21
22 // Precompute per-event lookahead: is the *next* event Start(List(None))?
23 let lookahead: Vec<bool> = (0..events.len())
24 .map(|i| matches!(events.get(i + 1), Some(Event::Start(Tag::List(None)))))
25 .collect();
26
27 // Precompute the first character of the immediately following Text event, if
28 // any. pulldown-cmark splits a run like `Ⓐ~A` into three Text events; the
29 // `_`/`~` flanking check in on_text needs the char *after* the current event
30 // to match its cross-event handling of the char *before* (via self.inline).
31 // Only an adjacent Text event contributes an alphanumeric neighbour; any other
32 // event (emphasis marker, code, break, block end) is a non-alphanumeric
33 // boundary, represented as None.
34 let next_text_char: Vec<Option<char>> = (0..events.len())
35 .map(|i| match events.get(i + 1) {
36 Some(Event::Text(t)) => t.chars().next(),
37 _ => None,
38 })
39 .collect();
40
41 for ((event, next_is_ul), next_char) in events.into_iter().zip(lookahead).zip(next_text_char) {
42 state.next_is_unordered_list = next_is_ul;
43 state.next_text_char = next_char;
44 state.process(event);
45 }
46
47 state.finish()
48}
49
50fn mk_options() -> Options {
51 Options::ENABLE_TABLES
52 | Options::ENABLE_FOOTNOTES
53 | Options::ENABLE_STRIKETHROUGH
54 | Options::ENABLE_TASKLISTS
55 | Options::ENABLE_HEADING_ATTRIBUTES
56}
57
58#[allow(clippy::struct_excessive_bools)] // each bool is a distinct formatting phase flag
59struct FormatterState {
60 out: String,
61 /// Whether the next block element should be preceded by a blank line.
62 needs_blank: bool,
63
64 // List state
65 list_depth: usize,
66 /// Start number for ordered list at each depth; None = unordered.
67 list_starts: Vec<Option<u64>>,
68 /// True when a list item was just opened but no Paragraph started yet (tight list).
69 in_tight_item: bool,
70
71 // Blockquote state
72 bq_depth: usize,
73
74 // Inline content buffer, flushed when a block element closes.
75 inline: String,
76
77 // Code block state
78 in_code_block: bool,
79 code_block_indent: String,
80
81 // Per-depth item marker widths (e.g. 3 for "1. ", 2 for "- "), used to
82 // compute the continuation indent for code blocks inside list items.
83 list_item_widths: Vec<usize>,
84
85 // Link/image stack: stores (dest_url, title) from Start until End.
86 link_stack: Vec<(String, String)>,
87
88 // Set by the outer format() loop before each event: true when the
89 // immediately following event is Start(List(None)). Used to detect
90 // two adjacent unordered lists so we can insert a separator.
91 next_is_unordered_list: bool,
92
93 // First char of the next Text event, or None if the next event is not Text.
94 // Supplies cross-event right-flank context for the `_`/`~` escape check.
95 next_text_char: Option<char>,
96
97 // Table state
98 table_alignments: Vec<Alignment>,
99 table_head_cells: Vec<String>,
100 table_data_rows: Vec<Vec<String>>,
101 current_row_cells: Vec<String>,
102 in_table_head: bool,
103}
104
105impl FormatterState {
106 fn new() -> Self {
107 Self {
108 out: String::new(),
109 needs_blank: false,
110 list_depth: 0,
111 list_starts: Vec::new(),
112 in_tight_item: false,
113 bq_depth: 0,
114 inline: String::new(),
115 in_code_block: false,
116 code_block_indent: String::new(),
117 list_item_widths: Vec::new(),
118 link_stack: Vec::new(),
119 next_is_unordered_list: false,
120 next_text_char: None,
121 table_alignments: Vec::new(),
122 table_head_cells: Vec::new(),
123 table_data_rows: Vec::new(),
124 current_row_cells: Vec::new(),
125 in_table_head: false,
126 }
127 }
128
129 fn process(&mut self, event: Event<'_>) {
130 match event {
131 Event::Start(tag) => self.on_start(tag),
132 Event::End(tag) => self.on_end(tag),
133 Event::Text(t) => self.on_text(&t),
134 Event::Code(c) => self.emit_inline_code(&c),
135 Event::Html(h) => {
136 self.out.push_str(&h);
137 }
138 Event::InlineHtml(h) => {
139 self.inline.push_str(&h);
140 }
141 Event::SoftBreak => {
142 self.inline.push('\n');
143 }
144 Event::HardBreak => {
145 // Backslash + newline = hard line break in CommonMark.
146 // Using backslash style avoids trailing-whitespace stripping.
147 self.inline.push_str("\\\n");
148 }
149 Event::Rule => {
150 self.emit_blank_if_needed();
151 self.write_bq_prefix();
152 self.out.push_str("---\n");
153 self.needs_blank = true;
154 }
155 Event::FootnoteReference(label) => {
156 write!(self.inline, "[^{label}]").expect("writing to String is infallible");
157 }
158 Event::TaskListMarker(checked) => {
159 if checked {
160 self.inline.push_str("[x] ");
161 } else {
162 self.inline.push_str("[ ] ");
163 }
164 }
165 _ => {}
166 }
167 }
168
169 #[allow(clippy::too_many_lines)] // exhaustive match over pulldown-cmark Tag variants
170 fn on_start(&mut self, tag: Tag<'_>) {
171 match tag {
172 Tag::Paragraph => {
173 // Inside a list, don't emit a blank before the paragraph—
174 // the item marker was already written.
175 if self.list_depth == 0 {
176 self.emit_blank_if_needed();
177 }
178 self.in_tight_item = false;
179 }
180 Tag::Heading { .. } => {
181 self.emit_blank_if_needed();
182 // The prefix (hashes) is written at End, when we have the level.
183 }
184 Tag::CodeBlock(kind) => {
185 self.emit_blank_if_needed();
186 let lang = match kind {
187 CodeBlockKind::Fenced(lang) => lang.into_string().replace('\\', "\\\\"),
188 CodeBlockKind::Indented => String::new(),
189 };
190 let fence_indent = self.list_continuation_prefix();
191 // When the fence lands on the same line as the list marker (tight item),
192 // the effective list margin becomes marker_width + fence_indent_width.
193 // Content and closing fence must use this combined width to stay inside the item.
194 let content_indent = if self.in_tight_item {
195 let marker_width = self.list_item_widths.last().copied().unwrap_or(0);
196 " ".repeat(marker_width + fence_indent.len())
197 } else {
198 fence_indent.clone()
199 };
200 let was_tight = self.in_tight_item;
201 self.in_tight_item = false;
202 self.code_block_indent = content_indent;
203 // When the fence is on the same line as the list marker (tight
204 // item), the blockquote prefix was already written by Tag::Item.
205 // Writing it again would insert an extra `>` that the re-parser
206 // interprets as a nested blockquote, breaking idempotency.
207 if !was_tight {
208 self.write_bq_prefix();
209 }
210 self.out.push_str(&fence_indent);
211 self.out.push_str("```");
212 self.out.push_str(&lang);
213 self.out.push('\n');
214 self.in_code_block = true;
215 }
216 Tag::List(start) => {
217 self.list_item_widths.push(0);
218 if self.list_depth == 0 {
219 self.emit_blank_if_needed();
220 } else {
221 // Nested list: suppress any pending blank line.
222 // A sublist follows its parent item text without a blank line.
223 self.needs_blank = false;
224 // Flush any tight-item inline content that preceded this sublist
225 // (e.g. `Text("Item 1")` in `- Item 1\n - Nested`).
226 if self.in_tight_item && !self.inline.is_empty() {
227 let text = std::mem::take(&mut self.inline);
228 let prefix = " ".repeat(self.list_depth);
229 self.flush_inline_text(&text, &prefix);
230 self.in_tight_item = false;
231 } else if self.in_tight_item {
232 // Outer tight item has no inline content before this nested list.
233 // Terminate the outer marker with a newline so inner markers are
234 // on their own lines, preventing markers from merging on re-parse.
235 self.out.push('\n');
236 self.in_tight_item = false;
237 }
238 }
239 self.list_depth += 1;
240 // Ordered lists always start at 1 in canonical form (MD029).
241 self.list_starts.push(start.map(|_| 1u64));
242 }
243 Tag::Item => {
244 // For loose lists, End(Paragraph) sets needs_blank = true.
245 // Emit that blank before the next item marker.
246 if self.list_depth > 0 {
247 self.emit_blank_if_needed();
248 }
249 self.in_tight_item = true;
250 let indent = " ".repeat(self.list_depth.saturating_sub(1));
251 let marker = match self.list_starts.last_mut() {
252 Some(Some(n)) => {
253 let s = format!("{indent}{n}. ");
254 *n += 1;
255 s
256 }
257 _ => format!("{indent}- "),
258 };
259 if let Some(w) = self.list_item_widths.last_mut() {
260 *w = marker.len();
261 }
262 self.write_bq_prefix();
263 self.out.push_str(&marker);
264 }
265 Tag::Emphasis => self.inline.push('*'),
266 Tag::Strong => self.inline.push_str("**"),
267 Tag::Strikethrough => self.inline.push_str("~~"),
268 Tag::Link {
269 dest_url, title, ..
270 } => {
271 self.link_stack
272 .push((dest_url.into_string(), title.into_string()));
273 self.inline.push('[');
274 }
275 Tag::Image {
276 dest_url, title, ..
277 } => {
278 self.link_stack
279 .push((dest_url.into_string(), title.into_string()));
280 self.inline.push_str("![");
281 }
282 Tag::HtmlBlock => {
283 self.emit_blank_if_needed();
284 }
285 Tag::BlockQuote(_) => {
286 self.emit_blank_if_needed();
287 self.bq_depth += 1;
288 }
289 Tag::FootnoteDefinition(label) => {
290 self.emit_blank_if_needed();
291 // Write the label prefix; body will be flushed inline.
292 self.write_bq_prefix();
293 write!(self.out, "[^{label}]: ").expect("writing to String is infallible");
294 }
295 Tag::Table(alignments) => {
296 self.emit_blank_if_needed();
297 self.table_alignments.clone_from(&alignments);
298 self.table_head_cells = Vec::new();
299 self.table_data_rows = Vec::new();
300 self.current_row_cells = Vec::new();
301 self.in_table_head = false;
302 }
303 Tag::TableHead => {
304 self.in_table_head = true;
305 }
306 Tag::TableRow => {
307 self.current_row_cells = Vec::new();
308 }
309 _ => {}
310 }
311 }
312
313 #[allow(clippy::too_many_lines)] // exhaustive match over pulldown-cmark TagEnd variants
314 fn on_end(&mut self, tag: TagEnd) {
315 match tag {
316 TagEnd::Paragraph => {
317 let text = std::mem::take(&mut self.inline);
318 // pulldown-cmark may emit a paragraph containing only Unicode
319 // whitespace (e.g. NEL U+0085) that is not a CommonMark line
320 // ending — finish() strips it via trim_end(), leaving an empty
321 // line that turns a blockquote into an empty one on re-parse.
322 // Skip the emission entirely; invisible content is no content.
323 if !text.trim().is_empty() {
324 if self.list_depth == 0 {
325 self.write_bq_prefix();
326 }
327 let prefix = " ".repeat(self.list_depth);
328 self.flush_inline_text(&text, &prefix);
329 self.needs_blank = true;
330 }
331 self.in_tight_item = false;
332 }
333 TagEnd::Heading(level) => {
334 let text = std::mem::take(&mut self.inline);
335 let hashes = "#".repeat(level as usize);
336 self.write_bq_prefix();
337 // Collapse hard and soft breaks to spaces, then trim. Trim must
338 // come after: a leading break produces a leading space that trim()
339 // removes; trimming first would strip a hard-break marker's `\`,
340 // leaving it unescaped and breaking idempotency on re-parse.
341 let heading_raw = collapse_heading_breaks(&text);
342 let heading_text = heading_raw.trim();
343 writeln!(self.out, "{hashes} {heading_text}").expect("writing to String is infallible");
344 self.needs_blank = true;
345 }
346 TagEnd::CodeBlock => {
347 // Ensure code block content ends with a newline so the closing
348 // fence is never appended to the last content line.
349 if !self.out.ends_with('\n') {
350 self.out.push('\n');
351 }
352 self.write_bq_prefix();
353 self.out.push_str(&self.code_block_indent.clone());
354 self.out.push_str("```\n");
355 self.in_code_block = false;
356 self.code_block_indent = String::new();
357 self.needs_blank = true;
358 }
359 TagEnd::List(_) => {
360 self.list_depth -= 1;
361 self.list_starts.pop();
362 self.list_item_widths.pop();
363 if self.list_depth == 0 {
364 if self.next_is_unordered_list {
365 // Two adjacent unordered lists would merge into one on
366 // re-parse (both normalise to `-`). Insert an invisible
367 // HTML comment to keep them separate.
368 self.needs_blank = false;
369 self.out.push_str("\n<!---->\n");
370 self.needs_blank = true;
371 } else {
372 self.needs_blank = true;
373 }
374 }
375 }
376 TagEnd::Item
377 // Tight list item: the content was never wrapped in Paragraph.
378 if self.in_tight_item => {
379 let text = std::mem::take(&mut self.inline);
380 if text.is_empty() {
381 // Empty tight item: the marker was already written; just terminate the line.
382 self.out.push('\n');
383 } else {
384 let prefix = " ".repeat(self.list_depth);
385 self.flush_inline_text(&text, &prefix);
386 }
387 self.in_tight_item = false;
388 }
389 TagEnd::Emphasis => self.inline.push('*'),
390 TagEnd::Strong => self.inline.push_str("**"),
391 TagEnd::Strikethrough => self.inline.push_str("~~"),
392 TagEnd::Link | TagEnd::Image => {
393 if let Some((dest, title)) = self.link_stack.pop() {
394 if title.is_empty() {
395 write!(self.inline, "]({dest})").expect("writing to String is infallible");
396 } else {
397 write!(self.inline, "]({dest} \"{title}\")").expect("writing to String is infallible");
398 }
399 }
400 }
401 TagEnd::HtmlBlock => {
402 if !self.out.ends_with('\n') {
403 self.out.push('\n');
404 }
405 self.needs_blank = true;
406 }
407 TagEnd::BlockQuote(_) => {
408 self.bq_depth -= 1;
409 self.needs_blank = true;
410 }
411 TagEnd::FootnoteDefinition => {
412 let text = std::mem::take(&mut self.inline);
413 self.flush_inline_text(&text, "");
414 self.needs_blank = true;
415 }
416 TagEnd::TableCell => {
417 let cell = std::mem::take(&mut self.inline);
418 self.current_row_cells.push(cell);
419 }
420 TagEnd::TableHead => {
421 // Cells may have been collected either via End(TableRow) inside the head
422 // or directly (if no TableRow wrapper was emitted).
423 if self.table_head_cells.is_empty() {
424 self.table_head_cells = std::mem::take(&mut self.current_row_cells);
425 }
426 self.in_table_head = false;
427 }
428 TagEnd::TableRow => {
429 let row = std::mem::take(&mut self.current_row_cells);
430 if self.in_table_head {
431 self.table_head_cells = row;
432 } else {
433 self.table_data_rows.push(row);
434 }
435 }
436 TagEnd::Table => {
437 let head = std::mem::take(&mut self.table_head_cells);
438 let rows = std::mem::take(&mut self.table_data_rows);
439 let aligns = std::mem::take(&mut self.table_alignments);
440
441 // Header row
442 self.write_bq_prefix();
443 self.out.push_str("| ");
444 self.out.push_str(&head.join(" | "));
445 self.out.push_str(" |\n");
446
447 // Separator row
448 self.write_bq_prefix();
449 self.out.push_str("| ");
450 let seps: Vec<&str> = aligns
451 .iter()
452 .map(|a| match a {
453 Alignment::Left => ":---",
454 Alignment::Right => "---:",
455 Alignment::Center => ":---:",
456 Alignment::None => "---",
457 })
458 .collect();
459 self.out.push_str(&seps.join(" | "));
460 self.out.push_str(" |\n");
461
462 // Data rows
463 for row in rows {
464 self.write_bq_prefix();
465 self.out.push_str("| ");
466 self.out.push_str(&row.join(" | "));
467 self.out.push_str(" |\n");
468 }
469
470 self.needs_blank = true;
471 }
472 _ => {}
473 }
474 }
475
476 fn on_text(&mut self, text: &str) {
477 if self.in_code_block {
478 // Code block content goes directly to output, with list
479 // continuation indent re-added (pulldown-cmark strips it).
480 // When inside a blockquote, each content line also needs the
481 // `> ` prefix so that the re-parser keeps the content inside
482 // the blockquote (fence lines already get the prefix via
483 // write_bq_prefix, but content lines arrive here as Text events).
484 let bq = "> ".repeat(self.bq_depth);
485 if bq.is_empty() && self.code_block_indent.is_empty() {
486 self.out.push_str(text);
487 } else {
488 for line in text.split_inclusive('\n') {
489 self.out.push_str(&bq);
490 self.out.push_str(&self.code_block_indent);
491 self.out.push_str(line);
492 }
493 }
494 } else {
495 // `\\` and `` ` `` are resolved unconditionally by pulldown-cmark regardless
496 // of context, so always re-escape them.
497 //
498 // For `_` and `~`, escape only at positions where the character is NOT between
499 // two Unicode alphanumeric characters. Intra-word delimiters (e.g. `x86_64`)
500 // can never open or close emphasis per CommonMark Rule 10/17; everything else
501 // must be escaped or it may form emphasis/strikethrough on the next parse.
502 //
503 // pulldown-cmark may split a single logical run into multiple Text events (e.g.
504 // `\_0_` → `Text("_0")` + `Text("_")`). The combined output `_0_` would form
505 // emphasis on re-parse. To catch this, we use the last char already written to
506 // `self.inline` as the "preceding character" for the first char of the event.
507 //
508 // pulldown-cmark occasionally emits bare `\r` characters inside text events
509 // (e.g. from heading content that contains `\r` without a following `\n`).
510 // Emitting a raw `\r` into output causes it to be treated as a line ending on
511 // re-parse (CommonMark spec §2.3), breaking the heading/paragraph structure and
512 // therefore idempotency. Normalise before processing.
513 let text = &*text.replace("\r\n", "\n").replace('\r', "\n");
514 let prev_inline_char = self.inline.chars().next_back();
515 let chars: Vec<char> = text.chars().collect();
516 let mut s = String::with_capacity(text.len() + 4);
517 for (i, &ch) in chars.iter().enumerate() {
518 match ch {
519 '\\' => s.push_str("\\\\"),
520 '`' => s.push_str("\\`"),
521 // A literal `<` in a Text event (pulldown only emits `<` as text
522 // when it does NOT already open a tag). Left bare, adjacent text
523 // can reconstruct an autolink or HTML tag on re-parse (e.g.
524 // `<#@a>` → email autolink), changing meaning. `\<` renders as
525 // `<` and can never start a tag, so escape unconditionally.
526 '<' => s.push_str("\\<"),
527 '_' | '~' => {
528 let prev = if i > 0 {
529 chars.get(i - 1).copied()
530 } else {
531 prev_inline_char
532 };
533 // For the last char of the event, the right neighbour is
534 // the first char of the next Text event (if any) so that a
535 // run split across events (e.g. `Ⓐ~A` → three events) is
536 // judged the same as the merged form on re-parse.
537 let next = chars.get(i + 1).copied().or(if i + 1 == chars.len() {
538 self.next_text_char
539 } else {
540 None
541 });
542 // Only leave bare when flanked by alphanumeric on BOTH sides.
543 if prev.is_some_and(char::is_alphanumeric)
544 && next.is_some_and(char::is_alphanumeric)
545 {
546 s.push(ch);
547 } else {
548 s.push('\\');
549 s.push(ch);
550 }
551 }
552 _ => s.push(ch),
553 }
554 }
555 self.inline.push_str(&s);
556 }
557 }
558
559 fn emit_inline_code(&mut self, code: &str) {
560 // Choose a delimiter longer than any backtick run in the content.
561 let max_run = code.chars().fold((0usize, 0usize), |(max, cur), ch| {
562 if ch == '`' {
563 (max.max(cur + 1), cur + 1)
564 } else {
565 (max, 0)
566 }
567 });
568 let delim = "`".repeat(max_run.0 + 1);
569 let needs_space = code.starts_with('`') || code.ends_with('`');
570 self.inline.push_str(&delim);
571 if needs_space {
572 self.inline.push(' ');
573 }
574 self.inline.push_str(code);
575 if needs_space {
576 self.inline.push(' ');
577 }
578 self.inline.push_str(&delim);
579 }
580
581 /// Returns the continuation indent for the current innermost list item —
582 /// i.e. the number of spaces needed to keep a block element (like a code
583 /// fence) inside that item. Empty string when not inside a list.
584 fn list_continuation_prefix(&self) -> String {
585 " ".repeat(self.list_item_widths.last().copied().unwrap_or(0))
586 }
587
588 fn emit_blank_if_needed(&mut self) {
589 if self.needs_blank && !self.out.is_empty() {
590 if self.bq_depth > 0 {
591 // Inside a blockquote, the separator line must carry the `>`
592 // marker so the parser keeps both paragraphs in the same block.
593 self.out.push_str(&">".repeat(self.bq_depth));
594 }
595 self.out.push('\n');
596 }
597 self.needs_blank = false;
598 }
599
600 fn write_bq_prefix(&mut self) {
601 self.out.push_str(&"> ".repeat(self.bq_depth));
602 }
603
604 /// Flush inline text to output.
605 /// Each line in `text` gets the blockquote prefix prepended (except the first,
606 /// which follows whatever was already written on the current output line).
607 fn flush_inline_text(&mut self, text: &str, continuation_prefix: &str) {
608 // Strip trailing hard-break markers (`\\\n`) preceded by only whitespace.
609 // A `\` before a line ending that is at the end of a block is re-parsed by
610 // pulldown-cmark as a literal `\`, not a hard break — so emitting `\\\n` at
611 // the end of a paragraph breaks idempotency (the formatter doubles the `\`
612 // on the second pass). A trailing hard break is always a no-op: there is
613 // nothing on the "next line" for the break to separate.
614 let text = {
615 let s = text.trim_end_matches(|c: char| c != '\n' && c.is_whitespace());
616 // Strip a trailing hard-break marker only when the backslash run before
617 // `\n` is odd: even runs are content pairs (`\\` = literal `\`) and must
618 // not be removed. An odd run = zero or more content pairs + one marker.
619 if let Some(stripped) = s.strip_suffix('\n') {
620 let run = stripped.chars().rev().take_while(|&c| c == '\\').count();
621 if run % 2 == 1 {
622 &stripped[..stripped.len() - 1]
623 } else {
624 text
625 }
626 } else {
627 text
628 }
629 };
630 let bq = "> ".repeat(self.bq_depth);
631 let mut lines = text.split('\n').peekable();
632
633 if let Some(first) = lines.next() {
634 if self.bq_depth > 0 && (self.out.ends_with('\n') || self.out.is_empty()) {
635 self.out.push_str(&bq);
636 }
637 if needs_line_escape(first, false) {
638 self.out.push_str(&escape_line(first));
639 } else {
640 self.out.push_str(first);
641 }
642 self.out.push('\n');
643 }
644
645 while let Some(line) = lines.next() {
646 if lines.peek().is_none() && line.is_empty() {
647 // Trailing empty string from split: don't emit an extra newline.
648 break;
649 }
650 // Skip blank or whitespace-only continuation lines. Inside a paragraph
651 // a blank line is impossible in real Markdown (it ends the paragraph).
652 // These arise from: (a) consecutive breaks (HardBreak + SoftBreak with
653 // no text) whose combined `\n`s produce an empty slot when split; or (b)
654 // lines consisting entirely of Unicode whitespace, which finish()'s
655 // trim_end() reduces to blank anyway. Both cases strand any preceding
656 // hard-break marker as a literal `\` that on_text doubles on re-parse.
657 if line.trim_end().is_empty() {
658 continue;
659 }
660 self.out.push_str(continuation_prefix);
661 self.out.push_str(&bq);
662 if needs_line_escape(line, true) {
663 self.out.push_str(&escape_line(line));
664 } else {
665 self.out.push_str(line);
666 }
667 self.out.push('\n');
668 }
669 }
670
671 fn finish(mut self) -> String {
672 let s = std::mem::take(&mut self.out);
673 let mut result: Vec<&str> = Vec::new();
674 let mut prev_blank = false;
675 for line in s.lines() {
676 let line = line.trim_end();
677 if line.is_empty() {
678 if !prev_blank {
679 result.push(line);
680 }
681 prev_blank = true;
682 } else {
683 result.push(line);
684 prev_blank = false;
685 }
686 }
687 // Strip leading blank lines (e.g. from Unicode-whitespace-only lines such
688 // as NBSP that trim_end() reduces to empty but aren't caught by the
689 // initial input.trim().is_empty() guard).
690 let start = result
691 .iter()
692 .position(|l| !l.is_empty())
693 .unwrap_or(result.len());
694 let joined = result
695 .get(start..)
696 .expect("start bounded by result.len()")
697 .join("\n");
698 let trimmed = joined.trim_end_matches('\n');
699 if trimmed.is_empty() {
700 return String::new();
701 }
702 format!("{trimmed}\n")
703 }
704}
705
706/// Collapse the break markers inside a heading's inline buffer to single spaces.
707///
708/// A heading cannot span lines, so both soft breaks (`\n`) and hard breaks
709/// (`\` + `\n`, emitted by `on_start`/`HardBreak`) become spaces. The subtlety
710/// is telling a hard-break `\` apart from a literal backslash in the heading
711/// text: `on_text` always doubles content backslashes, so a run of backslashes
712/// originating from content is even-length. A hard break adds exactly one more,
713/// making the run before the newline odd. We therefore keep floor(n/2) escaped
714/// backslashes (the content) and drop the trailing odd one (the marker) before
715/// replacing the newline with a space.
716fn collapse_heading_breaks(text: &str) -> String {
717 let mut out = String::with_capacity(text.len());
718 let mut chars = text.chars().peekable();
719 while let Some(ch) = chars.next() {
720 if ch == '\\' {
721 let mut run = 1usize;
722 while chars.peek() == Some(&'\\') {
723 chars.next();
724 run += 1;
725 }
726 // An odd run immediately before a newline ends in a hard-break marker;
727 // emit the content pairs and drop the marker backslash.
728 let is_hard_break = run % 2 == 1 && chars.peek() == Some(&'\n');
729 let content_backslashes = if is_hard_break { run - 1 } else { run };
730 for _ in 0..content_backslashes {
731 out.push('\\');
732 }
733 } else if ch == '\n' {
734 out.push(' ');
735 } else {
736 out.push(ch);
737 }
738 }
739 out
740}
741
742/// Escape `line` so that it round-trips through pulldown-cmark as plain text.
743///
744/// All block-trigger patterns whose first character is ASCII punctuation are
745/// escaped by prepending `\`. The exception is ordered-list markers (`0.`,
746/// `12.`): digits are not ASCII punctuation, so `\0` is not a valid `CommonMark`
747/// escape and would be doubled on re-parse. Instead we place the backslash
748/// before the `.` or `)` — `0\.` — which is valid and renders identically.
749fn escape_line(line: &str) -> String {
750 let digits_len = line.chars().take_while(char::is_ascii_digit).count();
751 if digits_len > 0 {
752 format!("{}\\{}", &line[..digits_len], &line[digits_len..])
753 } else {
754 format!("\\{line}")
755 }
756}
757
758/// Returns true if `line`, when emitted as the start of a new output line,
759/// would be re-interpreted as a structural block element on re-parse.
760///
761/// Uses pulldown-cmark itself as the oracle: if parsing `line` in isolation
762/// does not produce `Start(Paragraph)` as its first event, the line will be
763/// misread — escape it. This delegates all structural detection to the same
764/// parser that the formatter and linter use, so new `CommonMark` edge cases are
765/// handled automatically without manual pattern maintenance.
766///
767/// The one exception kept as a manual check is the setext heading underline on
768/// a continuation line (`===`, `--` etc.): these parse as plain paragraphs in
769/// isolation but turn the *preceding* output line into a heading when emitted
770/// together. That context-sensitivity cannot be detected by a single-line parse.
771///
772/// On continuation lines (`is_continuation = true`), ordered-list markers other
773/// than `1.`/`1)` do NOT interrupt a paragraph (`CommonMark` spec §5.2) and must
774/// not be escaped — escaping them hides broken-list errors from the linter.
775/// Since cmark parses `2. foo` in isolation as a list item, we suppress the
776/// escape for those cases here.
777fn needs_line_escape(line: &str, is_continuation: bool) -> bool {
778 // finish() strips trailing Unicode whitespace; check the trimmed form so
779 // structural patterns hidden behind trailing Unicode whitespace are caught.
780 let line = line.trim_end();
781 if line.is_empty() {
782 return false;
783 }
784
785 // Setext heading underlines are context-sensitive: `===` / `--` alone parse
786 // as paragraphs, but after a text line they become headings.
787 if is_continuation {
788 let trimmed = line.trim_end_matches([' ', '\t']);
789 if !trimmed.is_empty()
790 && (trimmed.chars().all(|c| c == '=') || trimmed.chars().all(|c| c == '-'))
791 {
792 return true;
793 }
794 }
795
796 // On continuation lines, only `1.`/`1)` can interrupt a paragraph — don't
797 // escape other ordered-list numbers even though cmark would flag them.
798 if is_continuation {
799 let digits_len = line.chars().take_while(char::is_ascii_digit).count();
800 if digits_len > 0 {
801 let rest = &line[digits_len..];
802 if let Some(after) = rest.strip_prefix(['.', ')'])
803 && (after.is_empty() || after.starts_with([' ', '\t']))
804 && &line[..digits_len] != "1"
805 {
806 return false;
807 }
808 }
809 }
810
811 // Delegate all other structural detection to cmark: if the line does not
812 // parse as a paragraph in isolation, it must be escaped.
813 !matches!(
814 Parser::new_ext(line, mk_options()).next(),
815 Some(Event::Start(Tag::Paragraph))
816 )
817}
818
819#[cfg(test)]
820mod tests {
821 use super::*;
822
823 /// Assert that `input` formats to `expected` AND that `expected` is already
824 /// canonical (formatting it again produces no change — the "not-fix" side).
825 fn assert_formats_to(input: &str, expected: &str) {
826 let got = format(input);
827 assert_eq!(
828 got, expected,
829 "format(input) did not match expected.\nInput:\n{input}\nExpected:\n{expected}\nGot:\n{got}"
830 );
831 assert_eq!(
832 format(expected),
833 expected,
834 "format(expected) != expected — already-canonical content must be unchanged.\nExpected:\n{expected}"
835 );
836 }
837
838 #[test]
839 fn test_empty_input() {
840 assert_eq!(format(""), "");
841 assert_eq!(format(" "), "");
842 assert_eq!(format("\n\n"), "");
843 }
844
845 #[test]
846 fn test_simple_paragraph() {
847 assert_eq!(format("Hello, world."), "Hello, world.\n");
848 }
849
850 #[test]
851 fn test_atx_heading() {
852 assert_eq!(format("# Heading 1"), "# Heading 1\n");
853 assert_eq!(format("## Heading 2"), "## Heading 2\n");
854 assert_eq!(format("###### Heading 6"), "###### Heading 6\n");
855 }
856
857 #[test]
858 fn test_heading_and_paragraph() {
859 let input = "# Title\n\nSome text.";
860 let output = format(input);
861 assert_eq!(output, "# Title\n\nSome text.\n");
862 }
863
864 #[test]
865 fn test_multiple_paragraphs() {
866 let input = "First paragraph.\n\nSecond paragraph.";
867 let output = format(input);
868 assert_eq!(output, "First paragraph.\n\nSecond paragraph.\n");
869 }
870
871 #[test]
872 fn test_fenced_code_block() {
873 let input = "```rust\nlet x = 1;\n```";
874 let output = format(input);
875 assert_eq!(output, "```rust\nlet x = 1;\n```\n");
876 }
877
878 #[test]
879 fn test_code_block_no_lang() {
880 let input = "```\ncode here\n```";
881 let output = format(input);
882 assert_eq!(output, "```\ncode here\n```\n");
883 }
884
885 #[test]
886 fn test_unordered_list() {
887 let input = "- Item 1\n- Item 2\n- Item 3";
888 let output = format(input);
889 assert_eq!(output, "- Item 1\n- Item 2\n- Item 3\n");
890 }
891
892 #[test]
893 fn test_ordered_list() {
894 let input = "1. First\n2. Second\n3. Third";
895 let output = format(input);
896 assert_eq!(output, "1. First\n2. Second\n3. Third\n");
897 }
898
899 #[test]
900 fn test_ordered_list_all_ones_renumbered() {
901 // "one" style (1. / 1. / 1.) is canonicalized to sequential.
902 assert_formats_to(
903 "1. First\n1. Second\n1. Third",
904 "1. First\n2. Second\n3. Third\n",
905 );
906 }
907
908 #[test]
909 fn test_ordered_list_non_one_start_renumbered() {
910 // Lists starting at a number other than 1 are renumbered from 1.
911 assert_formats_to(
912 "3. First\n5. Second\n9. Third",
913 "1. First\n2. Second\n3. Third\n",
914 );
915 }
916
917 #[test]
918 fn test_bold_italic_inline() {
919 assert_eq!(format("**bold** and *italic*"), "**bold** and *italic*\n");
920 }
921
922 #[test]
923 fn test_inline_code() {
924 assert_eq!(format("Use `foo()` here."), "Use `foo()` here.\n");
925 }
926
927 #[test]
928 fn test_link() {
929 let input = "[text](https://example.com)";
930 let output = format(input);
931 assert_eq!(output, "[text](https://example.com)\n");
932 }
933
934 #[test]
935 fn test_image() {
936 let input = "";
937 let output = format(input);
938 assert_eq!(output, "\n");
939 }
940
941 #[test]
942 fn test_blank_line_between_heading_and_code() {
943 let input = "# Heading\n\n```\ncode\n```";
944 let output = format(input);
945 assert_eq!(output, "# Heading\n\n```\ncode\n```\n");
946 }
947
948 #[test]
949 fn test_blank_line_between_list_and_paragraph() {
950 let input = "- item\n\nAfter list.";
951 let output = format(input);
952 assert_eq!(output, "- item\n\nAfter list.\n");
953 }
954
955 #[test]
956 fn test_nested_list() {
957 let input = "- Item 1\n - Nested\n- Item 2";
958 let output = format(input);
959 assert_eq!(output, "- Item 1\n - Nested\n- Item 2\n");
960 }
961
962 #[test]
963 fn test_strikethrough() {
964 assert_eq!(format("~~struck~~"), "~~struck~~\n");
965 }
966
967 // --- Canonicalization ---
968
969 // Headings: setext → ATX (both levels)
970 #[test]
971 fn test_setext_headings_to_atx() {
972 assert_formats_to("Heading 1\n=========", "# Heading 1\n");
973 assert_formats_to("Heading 2\n---------", "## Heading 2\n");
974 }
975
976 // Heading with a hard line break in content: the `\` marker must not appear
977 // unescaped in output (proptest regression: input "\\\r¡\r=").
978 #[test]
979 fn test_setext_heading_hard_break_not_leaked() {
980 assert_formats_to("\\\r¡\r=", "# ¡\n");
981 }
982
983 // Headings: closed ATX → open ATX
984 #[test]
985 fn test_closed_atx_stripped() {
986 assert_formats_to("## Heading ##", "## Heading\n");
987 assert_formats_to("# Title #", "# Title\n");
988 }
989
990 // Headings: multiple spaces after `#` collapsed to one
991 #[test]
992 fn test_multiple_spaces_after_hash_collapsed() {
993 assert_formats_to("# Heading", "# Heading\n");
994 assert_formats_to("## Wide", "## Wide\n");
995 }
996
997 // Headings: a literal backslash in heading text stays escaped and idempotent.
998 // Regression: `#\t0\\\ra` — the `\r` softens to a break, and the collapse of
999 // break markers must not consume one of the doubled content backslashes.
1000 #[test]
1001 fn test_heading_literal_backslash_idempotent() {
1002 assert_formats_to("#\t0\\\ra", "# 0\\\\ a\n");
1003 assert_formats_to("# a\\b", "# a\\\\b\n");
1004 }
1005
1006 // A genuine hard break inside a heading collapses to a single space with no
1007 // stray backslash left behind.
1008 #[test]
1009 fn test_heading_hard_break_collapses() {
1010 assert_formats_to("# a\\\nb", "# a\\\\\n\nb\n");
1011 }
1012
1013 // `_`/`~` flanked by alphanumerics across a pulldown-cmark event split must
1014 // stay bare and idempotent. Regression: `Ⓐ~A` splits into three Text events;
1015 // the lone `~` event has no in-event right neighbour, so without cross-event
1016 // lookahead it escaped on pass 1 then un-escaped on pass 2.
1017 #[test]
1018 fn test_intraword_tilde_across_event_split() {
1019 assert_formats_to("Ⓐ~A", "Ⓐ~A\n");
1020 // Not flanked on both sides → still escaped.
1021 assert_formats_to("Ⓐ~", "Ⓐ\\~\n");
1022 assert_formats_to("~A", "\\~A\n");
1023 }
1024
1025 // A literal `<` in text must be escaped so adjacent characters can't
1026 // reconstruct an autolink or HTML tag on re-parse. Regression: `<#\@a>`
1027 // dropped the backslash and re-parsed as an email autolink, changing meaning.
1028 // Genuine autolinks/HTML arrive as Link/Html events, not Text, so they are
1029 // unaffected.
1030 #[test]
1031 fn test_literal_angle_bracket_escaped() {
1032 assert_formats_to("<#\\@a>", "\\<#@a>\n");
1033 assert_formats_to("x<y", "x\\<y\n");
1034 // Real autolink and inline HTML are preserved, not escaped.
1035 assert_formats_to(
1036 "<https://example.com>",
1037 "[https://example.com](https://example.com)\n",
1038 );
1039 assert_formats_to("<div>hi</div>", "<div>hi</div>\n");
1040 }
1041
1042 #[test]
1043 fn test_collapse_heading_breaks_unit() {
1044 // Soft break → space.
1045 assert_eq!(collapse_heading_breaks("a\nb"), "a b");
1046 // Hard-break marker (odd run before newline) dropped; newline → space.
1047 assert_eq!(collapse_heading_breaks("a\\\nb"), "a b");
1048 // Doubled content backslash (even run) before a soft break: keep both, then space.
1049 assert_eq!(collapse_heading_breaks("a\\\\\nb"), "a\\\\ b");
1050 // Literal backslash not before a newline is untouched.
1051 assert_eq!(collapse_heading_breaks("a\\\\b"), "a\\\\b");
1052 }
1053
1054 // Blank lines: multiple consecutive blank lines collapsed to one
1055 #[test]
1056 fn test_multiple_blank_lines_collapsed() {
1057 assert_formats_to("First.\n\n\n\nSecond.", "First.\n\nSecond.\n");
1058 }
1059
1060 // List markers: * and + → -
1061 #[test]
1062 fn test_list_markers_to_dash() {
1063 assert_formats_to("* Item 1\n* Item 2", "- Item 1\n- Item 2\n");
1064 assert_formats_to("+ Item 1\n+ Item 2", "- Item 1\n- Item 2\n");
1065 }
1066
1067 // Emphasis: _ / __ → * / **
1068 #[test]
1069 fn test_emphasis_to_asterisk() {
1070 assert_formats_to("_italic_", "*italic*\n");
1071 assert_formats_to("__bold__", "**bold**\n");
1072 }
1073
1074 // Code fences: ~~~ → ``` (with and without lang tag)
1075 #[test]
1076 fn test_tilde_fence_to_backtick() {
1077 assert_formats_to("~~~rust\ncode\n~~~", "```rust\ncode\n```\n");
1078 assert_formats_to("~~~\ncode\n~~~", "```\ncode\n```\n");
1079 }
1080
1081 // Horizontal rules: all styles → ---
1082 #[test]
1083 fn test_all_hr_styles_to_dashes() {
1084 assert_formats_to("***", "---\n");
1085 assert_formats_to("___", "---\n");
1086 assert_formats_to("* * *", "---\n");
1087 assert_formats_to("- - -", "---\n");
1088 assert_formats_to("_ _ _", "---\n");
1089 }
1090
1091 // Hard line breaks: trailing-space syntax → backslash continuation.
1092 // Two spaces before \n must become \\\n so trailing-whitespace stripping
1093 // doesn't silently drop the line break (CLAUDE.md lessons learned).
1094 #[test]
1095 fn test_hard_line_break_becomes_backslash() {
1096 assert_formats_to("foo \nbar", "foo\\\nbar\n");
1097 }
1098
1099 // Tables
1100 #[test]
1101 fn test_simple_table() {
1102 let input = "| A | B |\n| --- | --- |\n| 1 | 2 |\n| 3 | 4 |\n";
1103 let output = format(input);
1104 assert_eq!(output, "| A | B |\n| --- | --- |\n| 1 | 2 |\n| 3 | 4 |\n");
1105 }
1106
1107 #[test]
1108 fn test_table_no_leading_pipes() {
1109 // GFM allows tables without leading/trailing pipes
1110 assert_formats_to(
1111 "A | B\n--- | ---\n1 | 2\n",
1112 "| A | B |\n| --- | --- |\n| 1 | 2 |\n",
1113 );
1114 }
1115
1116 #[test]
1117 fn test_table_idempotent() {
1118 // Proptest uses random strings and is unlikely to generate valid table
1119 // syntax, so this structural idempotency check is worth keeping explicitly.
1120 let input = "| A | B |\n| --- | --- |\n| 1 | 2 |\n";
1121 let once = format(input);
1122 let twice = format(&once);
1123 assert_eq!(once, twice);
1124 }
1125
1126 #[test]
1127 fn test_table_with_inline_formatting() {
1128 let input = "| **bold** | `code` |\n| --- | --- |\n| *em* | plain |\n";
1129 let output = format(input);
1130 assert_eq!(
1131 output,
1132 "| **bold** | `code` |\n| --- | --- |\n| *em* | plain |\n"
1133 );
1134 }
1135
1136 #[test]
1137 fn test_table_followed_by_paragraph() {
1138 let input = "| A | B |\n| --- | --- |\n| 1 | 2 |\n\nSome text.\n";
1139 let output = format(input);
1140 assert_eq!(
1141 output,
1142 "| A | B |\n| --- | --- |\n| 1 | 2 |\n\nSome text.\n"
1143 );
1144 }
1145
1146 // Structural escape: text that starts with a structural character must be
1147 // escaped so it is not re-interpreted on the next parse pass.
1148 #[test]
1149 fn test_escaped_list_marker_in_paragraph() {
1150 // \* in source resolves to literal *, which must not become a list item
1151 let once = format("\\*");
1152 let twice = format(&once);
1153 assert_eq!(once, twice, "idempotency: escaped asterisk");
1154 // Similarly for - and +
1155 let once = format("\\-");
1156 let twice = format(&once);
1157 assert_eq!(once, twice, "idempotency: escaped dash");
1158 }
1159
1160 #[test]
1161 fn test_setext_heading_with_leading_vt() {
1162 // VT (U+000B) in setext heading body is preserved by pulldown-cmark, but
1163 // stripped from ATX heading content on re-parse — trim before emitting.
1164 let once = format("\u{b}¡\r=");
1165 let twice = format(&once);
1166 assert_eq!(once, twice, "idempotency: setext heading with leading VT");
1167 }
1168
1169 #[test]
1170 fn test_escaped_heading_in_paragraph() {
1171 // \# in source resolves to literal #, which must not become an ATX heading
1172 let once = format("\\# not a heading");
1173 let twice = format(&once);
1174 assert_eq!(once, twice, "idempotency: escaped hash");
1175 }
1176
1177 // Code blocks inside list items: fences and content must be indented to
1178 // keep the block inside the list item (3 spaces for `1. `, 2 for `- `).
1179 #[test]
1180 fn test_ordered_list_with_code_block() {
1181 let canonical = "1. **Enable rule:**\n\n ```toml\n enabled = false\n ```\n\n2. **Another item:**\n\n ```toml\n line_length = 100\n ```\n";
1182 // Starting with `1. / 1.` triggers MD029 renumbering in the formatter.
1183 assert_formats_to(
1184 "1. **Enable rule:**\n\n ```toml\n enabled = false\n ```\n\n1. **Another item:**\n\n ```toml\n line_length = 100\n ```\n",
1185 canonical,
1186 );
1187 }
1188
1189 #[test]
1190 fn test_unordered_list_with_code_block() {
1191 let canonical = "- **Item:**\n\n ```toml\n enabled = false\n ```\n";
1192 assert_formats_to(canonical, canonical);
1193 }
1194
1195 #[test]
1196 fn test_tight_list_item_code_block_only() {
1197 // A list item whose sole content is a code block (no text paragraph).
1198 // The opening fence lands on the same line as the marker ("- ```"),
1199 // making the effective list margin 4. Content and closing fence must
1200 // both use 4-space indent so the closing fence stays inside the item.
1201 let canonical = "- ```\n ¡\n ```\n";
1202 assert_formats_to(canonical, canonical);
1203 }
1204
1205 #[test]
1206 fn test_setext_underline_in_paragraph_continuation() {
1207 // "\t=" is stripped to "=" by pulldown-cmark; the bare "=" on a
1208 // continuation line must be escaped so "a\n=\n" is not re-parsed
1209 // as a setext h1 heading on the next format pass.
1210 let once = format("a\r\t=");
1211 let twice = format(&once);
1212 assert_eq!(
1213 once, twice,
1214 "idempotency: setext-underline-like continuation"
1215 );
1216 // Same for "--" which is a valid setext h2 underline.
1217 let once = format("a\r\t--");
1218 let twice = format(&once);
1219 assert_eq!(once, twice, "idempotency: setext h2 continuation");
1220 }
1221
1222 #[test]
1223 fn test_backtick_in_text_escaped() {
1224 // A lone backtick in paragraph text must be escaped so it cannot pair
1225 // with another backtick on re-parse and form an unintended code span.
1226 let once = format("\\`\r`");
1227 let twice = format(&once);
1228 assert_eq!(once, twice, "idempotency: lone backticks in text");
1229 }
1230
1231 #[test]
1232 fn test_empty_list_items_idempotent() {
1233 // Two consecutive empty tight items (from "*\r*\t" = two asterisk markers
1234 // with no content): the old code omitted the newline after each empty item's
1235 // marker, causing the markers to merge onto one line ("- -") which re-parsed
1236 // as a nested list on the next pass.
1237 let once = format("*\r*\t");
1238 let twice = format(&once);
1239 assert_eq!(once, twice, "idempotency: empty tight list items");
1240 }
1241
1242 #[test]
1243 fn test_html_block_with_cr_content_idempotent() {
1244 // pulldown-cmark splits "<?>\r\" into two Html events within the same
1245 // HtmlBlock: Html("<?>") and Html("\"). The old Html handler set
1246 // needs_blank = true after the first event, inserting a spurious blank
1247 // line before the second, so the second format pass saw "\" as a
1248 // separate paragraph and escaped it to "\\".
1249 let once = format("<?>\r\\");
1250 let twice = format(&once);
1251 assert_eq!(once, twice, "idempotency: HTML block with CR content");
1252 }
1253
1254 #[test]
1255 fn test_list_marker_with_trailing_unicode_whitespace_idempotent() {
1256 // "*\u{85}\u{b}" is paragraph text (NEL and VT are not CommonMark line
1257 // endings, so `*` has no space after it and is not a list item). But
1258 // finish() strips trailing Unicode whitespace via trim_end(), leaving
1259 // bare "*\n" which re-parses as an empty list item on the next pass.
1260 // The fix: needs_line_escape checks against the trimmed form of the line.
1261 assert_formats_to("*\u{85}\u{b}", "\\*\n");
1262 }
1263
1264 #[test]
1265 fn test_blockquote_nel_idempotent() {
1266 // ">\u{85}": cmark emits BlockQuote > Paragraph > Text("\u{85}") — NEL is
1267 // Unicode whitespace that finish() strips, leaving ">" which re-parses as
1268 // an empty blockquote → second pass returns "".
1269 let once = format(">\u{85}");
1270 let twice = format(&once);
1271 assert_eq!(once, twice, "idempotency: blockquote + NEL");
1272 }
1273
1274 #[test]
1275 fn test_trailing_backslash_in_paragraph_not_doubled() {
1276 // Proptest regression: "¡\\\t\r\u{b}" — pulldown emits Text("¡\") + SoftBreak.
1277 // on_text doubles the \ to \\; SoftBreak appends \n → inline = "¡\\\n".
1278 // The trailing-hard-break strip must not fire on an even backslash run (\\
1279 // = one literal \), only on an odd run (the extra \ is the break marker).
1280 assert_formats_to("¡\\\t\r\x0B", "¡\\\\\n");
1281 }
1282
1283 #[test]
1284 fn test_hard_break_followed_by_vt_in_paragraph() {
1285 // "\\\r\u{b}\r¡": cmark emits HardBreak + SoftBreak (VT stripped) + Text("¡").
1286 // The two consecutive breaks produce an empty continuation slot when split on
1287 // '\n', which emits a blank line that breaks the paragraph on re-parse — the
1288 // preceding `\` is then doubled by on_text on the second pass.
1289 let once = format("\\\r\u{b}\r¡");
1290 let twice = format(&once);
1291 assert_eq!(once, twice, "idempotency: hard-break + VT continuation");
1292 }
1293
1294 #[test]
1295 fn test_code_fence_info_backslash_idempotent() {
1296 // pulldown-cmark returns the unescaped info string for fenced code blocks.
1297 // Emitting it verbatim means "\!" round-trips to "!" (pulldown-cmark
1298 // treats "\!" as a backslash escape of "!" on the next parse).
1299 // The fix escapes "\" to "\\" in the info string so the round-trip is stable.
1300 let once = format("```\\\r!");
1301 let twice = format(&once);
1302 assert_eq!(
1303 once, twice,
1304 "idempotency: code fence info string with backslash"
1305 );
1306 }
1307}