Skip to main content

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 = "![alt text](image.png)";
937        let output = format(input);
938        assert_eq!(output, "![alt text](image.png)\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}