Skip to main content

mdlint/formatter/
mod.rs

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