Skip to main content

snapper_fmt/parser/
markdown.rs

1use regex::Regex;
2use std::sync::LazyLock;
3
4use crate::parser::{
5    ByteSpan, FormatParser, Line, SpannedRegion, flush_prose_spanned, iter_lines, push_prose_line,
6};
7
8static HEADING_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(#{1,6}\s+)(.*)$").unwrap());
9
10static FENCED_CODE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(`{3,}|~{3,})").unwrap());
11
12/// Capture the language token immediately after a fence marker.
13/// `lang` is `[A-Za-z0-9_+.-]+`; anything past it (info string) is ignored.
14static FENCED_LANG_RE: LazyLock<Regex> =
15    LazyLock::new(|| Regex::new(r"^(?:`{3,}|~{3,})\s*([A-Za-z0-9_+.\-]+)").unwrap());
16
17static LIST_ITEM_RE: LazyLock<Regex> =
18    LazyLock::new(|| Regex::new(r"^(\s*(?:[-*+]|\d+[.)]) )(.*)$").unwrap());
19
20/// Markdown blockquote prefix: optional indent plus one or more `> `.
21/// Nested `> > text` keeps the full prefix so reflow can repeat it.
22static QUOTE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(\s*(?:> )+)(.*)$").unwrap());
23
24/// Match a markdown table row: line whose trimmed form starts and ends with `|`.
25/// Also matches separator rows like `|---|---|`.
26static TABLE_ROW_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*\|.*\|\s*$").unwrap());
27
28/// CommonMark setext underline: one or more `=` (level 1) or `-` (level 2),
29/// optional leading indent up to three spaces, optional trailing spaces.
30static SETEXT_UNDERLINE_RE: LazyLock<Regex> =
31    LazyLock::new(|| Regex::new(r"^ {0,3}(?:=+|-+)\s*$").unwrap());
32
33pub struct MarkdownParser;
34
35/// Close an open list item: flush accumulated prose and emit the trailing newline.
36fn close_list_item(
37    in_list_item: &mut bool,
38    current_prose: &mut String,
39    prose_span: &mut Option<ByteSpan>,
40    list_term: &mut Option<ByteSpan>,
41    input: &str,
42    regions: &mut Vec<SpannedRegion>,
43) {
44    if *in_list_item {
45        flush_prose_spanned(current_prose, prose_span, regions);
46        if let Some(span) = list_term.take() {
47            if !span.is_empty() {
48                regions.push(SpannedRegion::structure(input, span));
49            }
50        }
51        *in_list_item = false;
52    }
53}
54
55fn starts_html_comment(line: &str) -> bool {
56    line.trim_start().starts_with("<!--")
57}
58
59fn html_comment_closed(text: &str) -> bool {
60    match text.find("<!--") {
61        Some(i) => text[i + 4..].contains("-->"),
62        None => text.contains("-->"),
63    }
64}
65
66/// Two or more trailing spaces, or an unescaped trailing backslash.
67/// Returns `(content_len, hard_at)` relative to `text`.
68fn hard_break_rel(text: &str) -> Option<(usize, usize)> {
69    let bytes = text.as_bytes();
70    if !bytes.is_empty() && *bytes.last().unwrap() == b'\\' {
71        let mut n = 0usize;
72        let mut i = bytes.len();
73        while i > 0 && bytes[i - 1] == b'\\' {
74            n += 1;
75            i -= 1;
76        }
77        if n % 2 == 1 {
78            return Some((text.len() - 1, text.len() - 1));
79        }
80        return None;
81    }
82    let stripped = text.trim_end_matches(' ');
83    if text.len() - stripped.len() >= 2 {
84        return Some((stripped.len(), stripped.len()));
85    }
86    None
87}
88
89struct ProseAcc<'a> {
90    text: &'a mut String,
91    span: &'a mut Option<ByteSpan>,
92    term: &'a mut Option<ByteSpan>,
93}
94
95/// Append `line.text[piece_from..]` to the running prose buffer.
96///
97/// A hard break flushes prose and emits the break (spaces or `\`, plus the
98/// line terminator) as Structure so splice copies those source bytes.
99fn append_piece(
100    acc: &mut ProseAcc<'_>,
101    line: &Line<'_>,
102    piece_from: usize,
103    join_space: bool,
104    include_term_if_soft: bool,
105    input: &str,
106    regions: &mut Vec<SpannedRegion>,
107) {
108    let piece = &line.text[piece_from..];
109    if let Some((content_end, hard_at)) = hard_break_rel(piece) {
110        let raw = &piece[..content_end];
111        let trimmed = raw.trim_start();
112        let left = raw.len() - trimmed.len();
113        if !trimmed.is_empty() {
114            if !acc.text.is_empty() && join_space {
115                acc.text.push(' ');
116            }
117            acc.text.push_str(trimmed);
118            let start = line.start + piece_from + left;
119            let end = line.start + piece_from + content_end;
120            match acc.span {
121                None => *acc.span = Some(ByteSpan::new(start, end)),
122                Some(s) => s.end = end,
123            }
124        }
125        flush_prose_spanned(acc.text, acc.span, regions);
126        let hard = ByteSpan::new(line.start + piece_from + hard_at, line.end);
127        if !hard.is_empty() {
128            regions.push(SpannedRegion::structure(input, hard));
129        }
130        *acc.term = None;
131        return;
132    }
133    if piece_from == 0 {
134        push_prose_line(acc.text, acc.span, line, join_space, include_term_if_soft);
135        *acc.term = if include_term_if_soft {
136            None
137        } else {
138            Some(line.terminator_span())
139        };
140        return;
141    }
142    if !piece.is_empty() {
143        if !acc.text.is_empty() && join_space {
144            acc.text.push(' ');
145        }
146        acc.text.push_str(piece);
147        let start = line.start + piece_from;
148        let end = line.start + line.text.len();
149        match acc.span {
150            None => *acc.span = Some(ByteSpan::new(start, end)),
151            Some(s) => s.end = end,
152        }
153    }
154    *acc.term = Some(line.terminator_span());
155}
156
157/// True when `line` is a CommonMark setext underline (`===` or `---`).
158fn is_setext_underline(line: &str) -> bool {
159    let trimmed = line.trim_end();
160    if trimmed.is_empty() {
161        return false;
162    }
163    SETEXT_UNDERLINE_RE.is_match(trimmed)
164}
165
166/// True when `line` may be the text of a setext heading (non-empty, not an ATX
167/// marker line, not a table row, not a list item, not a fence opener).
168fn is_setext_title_line(line: &str) -> bool {
169    let trimmed = line.trim();
170    if trimmed.is_empty() {
171        return false;
172    }
173    if HEADING_RE.is_match(line) {
174        return false;
175    }
176    if TABLE_ROW_RE.is_match(line) {
177        return false;
178    }
179    if LIST_ITEM_RE.is_match(line) || QUOTE_RE.is_match(line) {
180        return false;
181    }
182    if FENCED_CODE_RE.is_match(line.trim_start()) {
183        return false;
184    }
185    true
186}
187
188impl FormatParser for MarkdownParser {
189    fn parse_full(&self, input: &str) -> Vec<SpannedRegion> {
190        let mut regions: Vec<SpannedRegion> = Vec::new();
191        let mut current_prose = String::new();
192        let mut prose_span: Option<ByteSpan> = None;
193        let mut in_fenced_code = false;
194        let mut fence_marker = String::new();
195        let mut code_header = ByteSpan::default();
196        let mut code_body_start = 0usize;
197        let mut code_lang: Option<String> = None;
198        let mut in_frontmatter = false;
199        let mut frontmatter_fence = String::new();
200        let mut in_list_item = false;
201        let mut list_term: Option<ByteSpan> = None;
202        let mut pragma_off = false;
203
204        let lines = iter_lines(input);
205        let total = lines.len();
206        let mut i = 0;
207
208        while i < total {
209            let line: &Line<'_> = &lines[i];
210            let line_text = line.text;
211            let line_number = i + 1;
212
213            // Check for snapper:off/on pragmas. Inside a fenced code block,
214            // the markdown parser does NOT short-circuit on pragmas; the
215            // code-block reflow handles them per-language (the markers
216            // `#`, `//`, `--`, `;` are all valid pragma prefixes inside
217            // their respective languages).
218            if !in_fenced_code {
219                if let Some(on) = super::check_pragma(line_text) {
220                    close_list_item(
221                        &mut in_list_item,
222                        &mut current_prose,
223                        &mut prose_span,
224                        &mut list_term,
225                        input,
226                        &mut regions,
227                    );
228                    flush_prose_spanned(&mut current_prose, &mut prose_span, &mut regions);
229                    pragma_off = !on;
230                    regions.push(SpannedRegion::structure(input, line.span()));
231                    i += 1;
232                    continue;
233                }
234
235                if pragma_off {
236                    close_list_item(
237                        &mut in_list_item,
238                        &mut current_prose,
239                        &mut prose_span,
240                        &mut list_term,
241                        input,
242                        &mut regions,
243                    );
244                    flush_prose_spanned(&mut current_prose, &mut prose_span, &mut regions);
245                    regions.push(SpannedRegion::structure(input, line.span()));
246                    i += 1;
247                    continue;
248                }
249            }
250
251            // Front matter detection (only at start of file)
252            if line_number == 1 && (line_text.trim() == "---" || line_text.trim() == "+++") {
253                in_frontmatter = true;
254                frontmatter_fence = line_text.trim().to_string();
255                regions.push(SpannedRegion::structure(input, line.span()));
256                i += 1;
257                continue;
258            }
259
260            if in_frontmatter {
261                if line_text.trim() == frontmatter_fence {
262                    in_frontmatter = false;
263                }
264                regions.push(SpannedRegion::structure(input, line.span()));
265                i += 1;
266                continue;
267            }
268
269            // Inside fenced code block
270            if in_fenced_code {
271                close_list_item(
272                    &mut in_list_item,
273                    &mut current_prose,
274                    &mut prose_span,
275                    &mut list_term,
276                    input,
277                    &mut regions,
278                );
279                flush_prose_spanned(&mut current_prose, &mut prose_span, &mut regions);
280                let mut closed = false;
281                if let Some(caps) = FENCED_CODE_RE.captures(line_text.trim_start()) {
282                    let marker = caps.get(1).unwrap().as_str();
283                    if marker.chars().next() == fence_marker.chars().next()
284                        && marker.len() >= fence_marker.len()
285                    {
286                        closed = true;
287                    }
288                }
289                if closed {
290                    in_fenced_code = false;
291                    regions.push(SpannedRegion::code(
292                        input,
293                        code_lang.take(),
294                        code_header,
295                        ByteSpan::new(code_body_start, line.start),
296                        line.span(),
297                    ));
298                }
299                i += 1;
300                continue;
301            }
302
303            // Fenced code block start
304            if let Some(caps) = FENCED_CODE_RE.captures(line_text.trim_start()) {
305                close_list_item(
306                    &mut in_list_item,
307                    &mut current_prose,
308                    &mut prose_span,
309                    &mut list_term,
310                    input,
311                    &mut regions,
312                );
313                flush_prose_spanned(&mut current_prose, &mut prose_span, &mut regions);
314                fence_marker = caps.get(1).unwrap().as_str().to_string();
315                in_fenced_code = true;
316                code_lang = FENCED_LANG_RE
317                    .captures(line_text.trim_start())
318                    .map(|c| c.get(1).unwrap().as_str().to_string());
319                code_header = line.span();
320                code_body_start = line.end;
321                i += 1;
322                continue;
323            }
324
325            // Blank line
326            if line_text.trim().is_empty() {
327                close_list_item(
328                    &mut in_list_item,
329                    &mut current_prose,
330                    &mut prose_span,
331                    &mut list_term,
332                    input,
333                    &mut regions,
334                );
335                flush_prose_spanned(&mut current_prose, &mut prose_span, &mut regions);
336                regions.push(SpannedRegion::blank(input, line.span()));
337                i += 1;
338                continue;
339            }
340
341            // Heading — keep the entire ATX line as Structure.
342            // Splitting into Structure("### ") + Prose(title) let the sentence
343            // reflow engine break titles after "1." or mid-phrase, producing
344            // orphan headings like:
345            //   ### 1.
346            //   `cargo binstall` (preferred binary install)
347            // CommonMark ATX headings are single-line; do not reflow them.
348            if HEADING_RE.is_match(line_text) {
349                close_list_item(
350                    &mut in_list_item,
351                    &mut current_prose,
352                    &mut prose_span,
353                    &mut list_term,
354                    input,
355                    &mut regions,
356                );
357                flush_prose_spanned(&mut current_prose, &mut prose_span, &mut regions);
358                regions.push(SpannedRegion::structure(input, line.span()));
359                i += 1;
360                continue;
361            }
362
363            // Setext heading: title line + underline of `=` or `-`.
364            // Without this, title text is Prose and the underline is glued on
365            // (or mid-title periods reflow), collapsing the heading.
366            if i + 1 < total
367                && is_setext_title_line(line_text)
368                && is_setext_underline(lines[i + 1].text)
369            {
370                close_list_item(
371                    &mut in_list_item,
372                    &mut current_prose,
373                    &mut prose_span,
374                    &mut list_term,
375                    input,
376                    &mut regions,
377                );
378                flush_prose_spanned(&mut current_prose, &mut prose_span, &mut regions);
379                regions.push(SpannedRegion::structure(input, line.span()));
380                regions.push(SpannedRegion::structure(input, lines[i + 1].span()));
381                i += 2;
382                continue;
383            }
384
385            // Table row (pipe-delimited)
386            if TABLE_ROW_RE.is_match(line_text) {
387                close_list_item(
388                    &mut in_list_item,
389                    &mut current_prose,
390                    &mut prose_span,
391                    &mut list_term,
392                    input,
393                    &mut regions,
394                );
395                flush_prose_spanned(&mut current_prose, &mut prose_span, &mut regions);
396                regions.push(SpannedRegion::structure(input, line.span()));
397                i += 1;
398                continue;
399            }
400
401            // HTML comment block (not a snapper pragma — those are handled above).
402            if starts_html_comment(line_text) {
403                close_list_item(
404                    &mut in_list_item,
405                    &mut current_prose,
406                    &mut prose_span,
407                    &mut list_term,
408                    input,
409                    &mut regions,
410                );
411                flush_prose_spanned(&mut current_prose, &mut prose_span, &mut regions);
412                if html_comment_closed(line_text) {
413                    regions.push(SpannedRegion::structure(input, line.span()));
414                    i += 1;
415                    continue;
416                }
417                let start = line.start;
418                i += 1;
419                while i < total {
420                    let done = lines[i].text.contains("-->");
421                    i += 1;
422                    if done {
423                        break;
424                    }
425                }
426                let end = lines
427                    .get(i.saturating_sub(1))
428                    .map(|l| l.end)
429                    .unwrap_or(input.len());
430                regions.push(SpannedRegion::structure(input, ByteSpan::new(start, end)));
431                continue;
432            }
433
434            // Blockquote: emit the full `> ` / `> > ` prefix as Structure.
435            // Checked before list items so nested `> >` is not flattened.
436            // Each source quote line is its own item so splice ranges stay
437            // contiguous. A hard break is Structure; the next line supplies
438            // its own `>` (no pre-emitted resume marker).
439            if let Some(caps) = QUOTE_RE.captures(line_text) {
440                close_list_item(
441                    &mut in_list_item,
442                    &mut current_prose,
443                    &mut prose_span,
444                    &mut list_term,
445                    input,
446                    &mut regions,
447                );
448                flush_prose_spanned(&mut current_prose, &mut prose_span, &mut regions);
449                let marker = caps.get(1).unwrap().as_str();
450                let text = caps.get(2).unwrap().as_str();
451                if text.trim().is_empty() {
452                    regions.push(SpannedRegion::structure(input, line.span()));
453                    i += 1;
454                    continue;
455                }
456                if HEADING_RE.is_match(text)
457                    || TABLE_ROW_RE.is_match(text)
458                    || FENCED_CODE_RE.is_match(text.trim_start())
459                {
460                    regions.push(SpannedRegion::structure(input, line.span()));
461                    i += 1;
462                    continue;
463                }
464                let marker_span = ByteSpan::new(line.start, line.start + marker.len());
465                regions.push(SpannedRegion::structure(input, marker_span));
466                in_list_item = true;
467                append_piece(
468                    &mut ProseAcc {
469                        text: &mut current_prose,
470                        span: &mut prose_span,
471                        term: &mut list_term,
472                    },
473                    line,
474                    marker.len(),
475                    false,
476                    false,
477                    input,
478                    &mut regions,
479                );
480                i += 1;
481                continue;
482            }
483
484            // List item: emit marker as Structure, start accumulating text as prose.
485            // Continuation lines are appended until a block boundary.
486            if let Some(caps) = LIST_ITEM_RE.captures(line_text) {
487                close_list_item(
488                    &mut in_list_item,
489                    &mut current_prose,
490                    &mut prose_span,
491                    &mut list_term,
492                    input,
493                    &mut regions,
494                );
495                flush_prose_spanned(&mut current_prose, &mut prose_span, &mut regions);
496                let marker = caps.get(1).unwrap().as_str();
497                let marker_span = ByteSpan::new(line.start, line.start + marker.len());
498                regions.push(SpannedRegion::structure(input, marker_span));
499                in_list_item = true;
500                append_piece(
501                    &mut ProseAcc {
502                        text: &mut current_prose,
503                        span: &mut prose_span,
504                        term: &mut list_term,
505                    },
506                    line,
507                    marker.len(),
508                    false,
509                    false,
510                    input,
511                    &mut regions,
512                );
513                i += 1;
514                continue;
515            }
516
517            // Regular prose (also serves as list-item continuation when in_list_item)
518            if in_list_item {
519                append_piece(
520                    &mut ProseAcc {
521                        text: &mut current_prose,
522                        span: &mut prose_span,
523                        term: &mut list_term,
524                    },
525                    line,
526                    0,
527                    true,
528                    false,
529                    input,
530                    &mut regions,
531                );
532            } else {
533                append_piece(
534                    &mut ProseAcc {
535                        text: &mut current_prose,
536                        span: &mut prose_span,
537                        term: &mut list_term,
538                    },
539                    line,
540                    0,
541                    true,
542                    true,
543                    input,
544                    &mut regions,
545                );
546            }
547            i += 1;
548        }
549
550        close_list_item(
551            &mut in_list_item,
552            &mut current_prose,
553            &mut prose_span,
554            &mut list_term,
555            input,
556            &mut regions,
557        );
558        flush_prose_spanned(&mut current_prose, &mut prose_span, &mut regions);
559        // Unclosed fence at EOF: emit a code region with empty footer.
560        if in_fenced_code {
561            let eof = ByteSpan::new(input.len(), input.len());
562            regions.push(SpannedRegion::code(
563                input,
564                code_lang.take(),
565                code_header,
566                ByteSpan::new(code_body_start, input.len()),
567                eof,
568            ));
569        }
570        regions
571    }
572}
573
574#[cfg(test)]
575mod tests {
576    use super::*;
577    use crate::parser::Region;
578
579    #[test]
580    fn simple_prose() {
581        let input = "Hello world. This is a test.\nAnother line here.";
582        let regions = MarkdownParser.parse(input);
583        assert_eq!(
584            regions,
585            vec![Region::Prose(
586                "Hello world. This is a test. Another line here.".to_string()
587            )]
588        );
589    }
590
591    #[test]
592    fn fenced_code_preserved() {
593        let input = "Some text.\n```python\nprint('hello')\n```\nMore text.";
594        let regions = MarkdownParser.parse(input);
595        assert!(matches!(&regions[0], Region::Prose(_)));
596        // Code blocks now collapse into a single Region::Code carrying
597        // header, body, and footer.
598        match &regions[1] {
599            Region::Code {
600                lang,
601                header,
602                body,
603                footer,
604            } => {
605                assert_eq!(lang.as_deref(), Some("python"));
606                assert_eq!(header, "```python\n");
607                assert_eq!(body, "print('hello')\n");
608                assert_eq!(footer, "```\n");
609            }
610            other => panic!("expected Region::Code, got {other:?}"),
611        }
612        assert!(matches!(&regions[2], Region::Prose(_)));
613    }
614
615    #[test]
616    fn frontmatter_preserved() {
617        let input = "---\ntitle: Test\nauthor: Someone\n---\n\nSome text.";
618        let regions = MarkdownParser.parse(input);
619        // First 4 lines are structure (frontmatter)
620        assert!(matches!(&regions[0], Region::Structure(_)));
621        assert!(matches!(&regions[1], Region::Structure(_)));
622        assert!(matches!(&regions[2], Region::Structure(_)));
623        assert!(matches!(&regions[3], Region::Structure(_)));
624    }
625
626    #[test]
627    fn table_preserved() {
628        let input = "| Feature | Why |\n|---------|-----|\n| `Foo` | Bar |";
629        let regions = MarkdownParser.parse(input);
630        assert!(
631            regions.iter().all(|r| matches!(r, Region::Structure(_))),
632            "all table rows should be Structure, got: {:?}",
633            regions
634        );
635    }
636
637    #[test]
638    fn table_with_surrounding_prose() {
639        let input = "Some text before.\n\n| A | B |\n|---|---|\n| 1 | 2 |\n\nSome text after.";
640        let regions = MarkdownParser.parse(input);
641        // Should have: Prose, Blank, 3x Structure (table rows), Blank, Prose
642        let prose_count = regions
643            .iter()
644            .filter(|r| matches!(r, Region::Prose(_)))
645            .count();
646        let structure_count = regions
647            .iter()
648            .filter(|r| matches!(r, Region::Structure(_)))
649            .count();
650        assert_eq!(prose_count, 2);
651        assert_eq!(structure_count, 3);
652    }
653
654    #[test]
655    fn wide_table_preserved_verbatim() {
656        let input = "| Feature                         | Why excluded                                          | Follow-up article type     |\n|---------------------------------|-------------------------------------------------------|----------------------------|\n| `DraftValidation`               | LLM-assisted; needs API key, not production-reliable  | Step-by-Step Project       |";
657        let regions = MarkdownParser.parse(input);
658        assert_eq!(regions.len(), 3);
659        assert!(regions.iter().all(|r| matches!(r, Region::Structure(_))));
660        // Verify each line is preserved exactly (with trailing newline)
661        for r in &regions {
662            if let Region::Structure(s) = r {
663                assert!(s.starts_with('|'));
664                assert!(
665                    s.ends_with('|') || s.ends_with("|\n"),
666                    "table row must be the input slice: {s:?}"
667                );
668            }
669        }
670    }
671
672    #[test]
673    fn list_item_continuation_joined() {
674        let input = "1. First line of item\ncontinuation text here.\nAnother sentence.";
675        let regions = MarkdownParser.parse(input);
676        assert_eq!(regions[0], Region::Structure("1. ".to_string()));
677        // All three lines should be joined into one Prose region
678        assert_eq!(
679            regions[1],
680            Region::Prose(
681                "First line of item continuation text here. Another sentence.".to_string()
682            )
683        );
684        // No trailing newline in the source, so no terminator Structure.
685        assert_eq!(regions.len(), 2);
686    }
687
688    #[test]
689    fn list_item_continuation_stops_at_blank() {
690        let input = "- Item one text.\ncontinuation.\n\nParagraph after.";
691        let regions = MarkdownParser.parse(input);
692        assert_eq!(regions[0], Region::Structure("- ".to_string()));
693        assert_eq!(
694            regions[1],
695            Region::Prose("Item one text. continuation.".to_string())
696        );
697        assert_eq!(regions[2], Region::Structure("\n".to_string()));
698        assert!(matches!(&regions[3], Region::BlankLines(_)));
699        assert_eq!(regions[4], Region::Prose("Paragraph after.".to_string()));
700    }
701
702    #[test]
703    fn list_item_continuation_stops_at_next_item() {
704        let input = "- First item\ncontinuation.\n- Second item";
705        let regions = MarkdownParser.parse(input);
706        // First item
707        assert_eq!(regions[0], Region::Structure("- ".to_string()));
708        assert_eq!(
709            regions[1],
710            Region::Prose("First item continuation.".to_string())
711        );
712        assert_eq!(regions[2], Region::Structure("\n".to_string()));
713        // Second item
714        assert_eq!(regions[3], Region::Structure("- ".to_string()));
715        assert_eq!(regions[4], Region::Prose("Second item".to_string()));
716        assert_eq!(regions.len(), 5);
717    }
718
719    #[test]
720    fn numbered_list_with_backtick_continuation() {
721        // The exact bug from the user report
722        let input = "1. **Quality gates:** `Thresholds(warning=0.1)`\nlets you express failure rates. Replaces binary assert.";
723        let regions = MarkdownParser.parse(input);
724        assert_eq!(regions[0], Region::Structure("1. ".to_string()));
725        assert_eq!(
726            regions[1],
727            Region::Prose(
728                "**Quality gates:** `Thresholds(warning=0.1)` lets you express failure rates. Replaces binary assert.".to_string()
729            )
730        );
731        assert_eq!(regions.len(), 2);
732    }
733
734    #[test]
735    fn heading_is_structure_not_prose() {
736        let input = "## My Heading";
737        let regions = MarkdownParser.parse(input);
738        assert_eq!(regions.len(), 1);
739        assert_eq!(regions[0], Region::Structure("## My Heading".to_string()));
740    }
741
742    #[test]
743    fn numbered_atx_heading_with_code_stays_one_line() {
744        // Regression: rtrash README / snapper-25kc — snapper -i turned
745        // `### 1. \`cargo binstall\` (preferred binary install)` into an orphan
746        // `### 1.` plus a reflowed title paragraph.
747        let input = "### 1. `cargo binstall` (preferred binary install)\n\nBody sentence one. Body sentence two.\n";
748        let regions = MarkdownParser.parse(input);
749        assert!(
750            matches!(&regions[0], Region::Structure(s) if s == "### 1. `cargo binstall` (preferred binary install)\n"),
751            "expected full ATX line as Structure, got: {:?}",
752            regions[0]
753        );
754        // Title must not appear as Prose (would be sentence-reflowed).
755        assert!(
756            !regions
757                .iter()
758                .any(|r| matches!(r, Region::Prose(p) if p.contains("cargo binstall"))),
759            "heading title must not be Prose: {regions:?}"
760        );
761    }
762
763    #[test]
764    fn atx_heading_levels_preserved_verbatim() {
765        for hashes in 1..=6 {
766            let marks = "#".repeat(hashes);
767            let line = format!("{marks} Title with `code` and (parens)");
768            let regions = MarkdownParser.parse(&line);
769            assert_eq!(
770                regions,
771                vec![Region::Structure(line.clone())],
772                "level {hashes}"
773            );
774        }
775    }
776
777    #[test]
778    fn setext_heading_equals_is_structure() {
779        let input = "Setext Title With Period. Still Title\n=====================================\n\nBody after setext.\n";
780        let regions = MarkdownParser.parse(input);
781        assert!(
782            matches!(&regions[0], Region::Structure(s) if s == "Setext Title With Period. Still Title\n"),
783            "setext title must be Structure, got: {:?}",
784            regions[0]
785        );
786        assert!(
787            matches!(&regions[1], Region::Structure(s) if s.starts_with('=')),
788            "setext underline must be Structure, got: {:?}",
789            regions[1]
790        );
791        assert!(
792            !regions
793                .iter()
794                .any(|r| matches!(r, Region::Prose(p) if p.contains("Still Title"))),
795            "setext title must not be Prose: {regions:?}"
796        );
797    }
798
799    #[test]
800    fn setext_heading_dashes_is_structure() {
801        let input = "Secondary Setext Title\n----------------------\n\nParagraph text here.\n";
802        let regions = MarkdownParser.parse(input);
803        assert_eq!(
804            regions[0],
805            Region::Structure("Secondary Setext Title\n".to_string())
806        );
807        assert!(matches!(&regions[1], Region::Structure(s) if s.starts_with('-')));
808    }
809
810    #[test]
811    fn multi_sentence_setext_title_stays_one_line() {
812        use crate::format::Format;
813        use crate::{FormatConfig, format_text};
814
815        let input = "Setext Title With Period. Still Title\n=====================================\n\nBody after setext. Second body.\n";
816        let cfg = FormatConfig {
817            format: Format::Markdown,
818            ..Default::default()
819        }
820        .without_safety_backstops();
821        let out = format_text(input, &cfg).unwrap();
822        assert!(
823            out.starts_with(
824                "Setext Title With Period. Still Title\n=====================================\n"
825            ),
826            "setext title+underline must stay intact, got:\n{out}"
827        );
828        assert!(
829            !out.contains("Still Title =====") && !out.contains("Still Title\nStill"),
830            "must not glue underline onto reflowed title:\n{out}"
831        );
832        assert_eq!(format_text(&out, &cfg).unwrap(), out);
833    }
834
835    #[test]
836    fn setext_after_prose_flushes_body() {
837        let input = "Body sentence one. Body two.\n\nHeading Here\n============\n";
838        let regions = MarkdownParser.parse(input);
839        let prose: Vec<_> = regions
840            .iter()
841            .filter_map(|r| match r {
842                Region::Prose(p) => Some(p.as_str()),
843                _ => None,
844            })
845            .collect();
846        assert!(prose.iter().any(|p| p.contains("Body sentence one")));
847        assert!(
848            regions
849                .iter()
850                .any(|r| matches!(r, Region::Structure(s) if s == "Heading Here\n"))
851        );
852    }
853
854    #[test]
855    fn blockquote_marker_is_structure() {
856        let input = "> One. Two.";
857        let regions = MarkdownParser.parse(input);
858        assert_eq!(regions[0], Region::Structure("> ".to_string()));
859        assert_eq!(regions[1], Region::Prose("One. Two.".to_string()));
860        // No trailing newline in the source, so no terminator Structure.
861        assert_eq!(regions.len(), 2);
862        assert!(
863            !regions
864                .iter()
865                .any(|r| matches!(r, Region::Prose(p) if p.contains('>'))),
866            "quote marker must not leak into Prose: {regions:?}"
867        );
868    }
869
870    #[test]
871    fn blockquote_multiline_keeps_each_marker() {
872        // Each source quote line is its own item so splice ranges stay
873        // contiguous. Reflow of already-split quotes is identity.
874        let regions = MarkdownParser.parse("> One.\n> Two.");
875        assert_eq!(regions[0], Region::Structure("> ".to_string()));
876        assert_eq!(regions[1], Region::Prose("One.".to_string()));
877        assert_eq!(regions[2], Region::Structure("\n".to_string()));
878        assert_eq!(regions[3], Region::Structure("> ".to_string()));
879        assert_eq!(regions[4], Region::Prose("Two.".to_string()));
880    }
881
882    #[test]
883    fn list_and_quote_multi_sentence_hangs() {
884        use crate::format::Format;
885        use crate::{FormatConfig, format_text};
886
887        let cfg = FormatConfig {
888            format: Format::Markdown,
889            ..Default::default()
890        };
891        let dash = format_text("- One. Two.\n", &cfg).unwrap();
892        assert_eq!(dash, "- One.\n  Two.\n");
893        assert_eq!(format_text(&dash, &cfg).unwrap(), dash);
894
895        let numbered = format_text("1. One. Two.\n", &cfg).unwrap();
896        assert_eq!(numbered, "1. One.\n   Two.\n");
897        assert_eq!(format_text(&numbered, &cfg).unwrap(), numbered);
898
899        let quote = format_text("> One. Two.\n", &cfg).unwrap();
900        assert_eq!(quote, "> One.\n> Two.\n");
901        assert_eq!(format_text(&quote, &cfg).unwrap(), quote);
902    }
903
904    #[test]
905    fn blockquote_keeps_marker_on_each_content_line() {
906        use crate::format::Format;
907        use crate::{FormatConfig, format_text};
908
909        let cfg = FormatConfig {
910            format: Format::Markdown,
911            ..Default::default()
912        };
913        for input in ["> One. Two.\n", "> One.\n> Two.\n"] {
914            let out = format_text(input, &cfg).unwrap();
915            let quote_lines: Vec<_> = out.lines().filter(|l| !l.is_empty()).collect();
916            assert_eq!(
917                quote_lines,
918                vec!["> One.", "> Two."],
919                "each content line needs `>`, input {input:?}, got:\n{out}"
920            );
921            assert_eq!(format_text(&out, &cfg).unwrap(), out);
922        }
923    }
924
925    #[test]
926    fn nested_blockquote_keeps_full_prefix() {
927        let input = "> > Nested one. Nested two.";
928        let regions = MarkdownParser.parse(input);
929        assert_eq!(regions[0], Region::Structure("> > ".to_string()));
930        assert_eq!(
931            regions[1],
932            Region::Prose("Nested one. Nested two.".to_string())
933        );
934        assert_eq!(regions.len(), 2);
935    }
936
937    #[test]
938    fn nested_blockquote_reflow_repeats_prefix() {
939        use crate::format::Format;
940        use crate::{FormatConfig, format_text};
941
942        let input = "> Quoted one. Quoted two.\n> > Nested one. Nested two.\n";
943        let cfg = FormatConfig {
944            format: Format::Markdown,
945            ..Default::default()
946        };
947        let out = format_text(input, &cfg).unwrap();
948        assert_eq!(
949            out,
950            "> Quoted one.\n> Quoted two.\n> > Nested one.\n> > Nested two.\n"
951        );
952        assert_eq!(format_text(&out, &cfg).unwrap(), out);
953    }
954
955    #[test]
956    fn nested_list_stays_two_items_after_reflow() {
957        use crate::format::Format;
958        use crate::{FormatConfig, format_text};
959
960        let input = "1. Parent one. Parent two.\n   - Child one. Child two.\n";
961        let cfg = FormatConfig {
962            format: Format::Markdown,
963            ..Default::default()
964        };
965        let out = format_text(input, &cfg).unwrap();
966        assert_eq!(
967            out,
968            "1. Parent one.\n   Parent two.\n   - Child one.\n     Child two.\n"
969        );
970        assert_eq!(format_text(&out, &cfg).unwrap(), out);
971
972        let regions = MarkdownParser.parse(&out);
973        assert_eq!(regions[0], Region::Structure("1. ".to_string()));
974        assert_eq!(
975            regions[1],
976            Region::Prose("Parent one. Parent two.".to_string())
977        );
978        assert_eq!(regions[2], Region::Structure("\n".to_string()));
979        assert_eq!(regions[3], Region::Structure("   - ".to_string()));
980        assert_eq!(
981            regions[4],
982            Region::Prose("Child one. Child two.".to_string())
983        );
984        assert_eq!(regions[5], Region::Structure("\n".to_string()));
985    }
986
987    #[test]
988    fn hard_break_two_spaces_not_joined_with_space() {
989        use crate::format::Format;
990        use crate::{FormatConfig, format_text};
991
992        let input = "line  \ncontinued. Next sentence.\n";
993        let regions = MarkdownParser.parse(input);
994        let joined: String = regions
995            .iter()
996            .map(|r| match r {
997                Region::Prose(p) | Region::Structure(p) | Region::BlankLines(p) => p.as_str(),
998                Region::Code { .. } => "",
999            })
1000            .collect();
1001        assert!(
1002            !joined.contains("line continued"),
1003            "two trailing spaces are a hard break, not a space join: {regions:?}"
1004        );
1005        assert!(
1006            regions.iter().any(|r| match r {
1007                Region::Structure(s) => s.contains("  \n") || s.ends_with("  \n"),
1008                _ => false,
1009            }) || joined.contains("line  \n"),
1010            "hard-break spaces must survive classification: {regions:?}"
1011        );
1012
1013        let cfg = FormatConfig {
1014            format: Format::Markdown,
1015            ..Default::default()
1016        };
1017        let out = format_text(input, &cfg).unwrap();
1018        assert!(
1019            !out.contains("line continued"),
1020            "must not collapse hard break to a space, got:\n{out}"
1021        );
1022        assert!(
1023            out.contains("line  \n") || out.contains("line  \r"),
1024            "two trailing spaces must remain, got:\n{out:?}"
1025        );
1026        assert!(out.contains("Next sentence."));
1027        assert_eq!(format_text(&out, &cfg).unwrap(), out);
1028    }
1029
1030    #[test]
1031    fn hard_break_backslash_not_joined_with_space() {
1032        use crate::format::Format;
1033        use crate::{FormatConfig, format_text};
1034
1035        let input = "line\\\ncontinued. Next sentence.\n";
1036        let cfg = FormatConfig {
1037            format: Format::Markdown,
1038            ..Default::default()
1039        };
1040        let out = format_text(input, &cfg).unwrap();
1041        assert!(
1042            !out.contains("line continued") && !out.contains("line\\ continued"),
1043            "backslash hard break must not become a space, got:\n{out}"
1044        );
1045        assert!(
1046            out.contains("line\\\ncontinued"),
1047            "backslash hard break must remain, got:\n{out:?}"
1048        );
1049        assert!(out.contains("Next sentence."));
1050        assert_eq!(format_text(&out, &cfg).unwrap(), out);
1051    }
1052
1053    #[test]
1054    fn html_comment_multiline_is_structure() {
1055        let input = "Before sentence. After.\n<!--\nHidden. With a period.\nStill comment.\n-->\nMore. Text.";
1056        let regions = MarkdownParser.parse(input);
1057        let comment = regions.iter().find_map(|r| match r {
1058            Region::Structure(s) if s.contains("<!--") => Some(s.as_str()),
1059            _ => None,
1060        });
1061        let comment = comment.expect(&format!("comment must be Structure, got {regions:?}"));
1062        assert!(comment.contains("<!--"), "{comment}");
1063        assert!(comment.contains("Hidden. With a period."), "{comment}");
1064        assert!(comment.contains("Still comment."), "{comment}");
1065        assert!(comment.contains("-->"), "{comment}");
1066        assert!(
1067            !regions
1068                .iter()
1069                .any(|r| matches!(r, Region::Prose(p) if p.contains("Hidden") || p.contains("Still comment"))),
1070            "comment body must not be Prose: {regions:?}"
1071        );
1072    }
1073
1074    #[test]
1075    fn html_comment_multiline_passes_through_format() {
1076        use crate::format::Format;
1077        use crate::{FormatConfig, format_text};
1078
1079        let input = "Before sentence. After.\n<!--\nHidden. With a period.\nStill comment.\n-->\nMore. Text.\n";
1080        let cfg = FormatConfig {
1081            format: Format::Markdown,
1082            ..Default::default()
1083        };
1084        let out = format_text(input, &cfg).unwrap();
1085        assert!(
1086            out.contains("<!--\nHidden. With a period.\nStill comment.\n-->\n"),
1087            "multiline comment must pass through, got:\n{out}"
1088        );
1089        assert!(out.contains("Before sentence.\nAfter."));
1090        assert!(out.contains("More.\nText."));
1091        assert_eq!(format_text(&out, &cfg).unwrap(), out);
1092    }
1093
1094    #[test]
1095    fn html_comment_pragma_still_disables_reflow() {
1096        use crate::format::Format;
1097        use crate::{FormatConfig, format_text};
1098
1099        let input = "Hello world. Goodbye world.\n<!-- snapper:off -->\nKeep this. Exactly here.\n<!-- snapper:on -->\nFinal thing. Last sentence.\n";
1100        let cfg = FormatConfig {
1101            format: Format::Markdown,
1102            ..Default::default()
1103        };
1104        let out = format_text(input, &cfg).unwrap();
1105        assert!(out.contains("Hello world.\nGoodbye world.\n"));
1106        assert!(
1107            out.contains("Keep this. Exactly here.\n"),
1108            "pragma-off body must stay untouched, got:\n{out}"
1109        );
1110        assert!(out.contains("Final thing.\nLast sentence."));
1111        assert!(out.contains("<!-- snapper:off -->"));
1112        assert!(out.contains("<!-- snapper:on -->"));
1113    }
1114
1115    #[test]
1116    fn quote_hard_break_then_nonquote_has_no_stray_marker() {
1117        use crate::format::Format;
1118        use crate::{FormatConfig, format_text};
1119
1120        let input = "> line  \nNext sentence.\n";
1121        let regions = MarkdownParser.parse(input);
1122        let after_break = regions
1123            .iter()
1124            .skip_while(|r| !matches!(r, Region::Structure(s) if s.ends_with("  \n")));
1125        assert!(
1126            !after_break
1127                .clone()
1128                .any(|r| matches!(r, Region::Structure(s) if is_quote_resume(s))),
1129            "must not emit `>` after a quote hard break into non-quote, got: {regions:?}"
1130        );
1131
1132        let cfg = FormatConfig {
1133            format: Format::Markdown,
1134            ..Default::default()
1135        };
1136        let out = format_text(input, &cfg).unwrap();
1137        assert!(
1138            !out.contains("> \n") && !out.lines().any(|l| l == ">" || l.trim() == ">"),
1139            "stray empty quote line, got:\n{out:?}"
1140        );
1141        assert!(
1142            out.starts_with("> line  \nNext sentence."),
1143            "hard break then non-quote body, got:\n{out:?}"
1144        );
1145        assert_eq!(format_text(&out, &cfg).unwrap(), out);
1146
1147        let still_quote = format_text("> line  \n> continued.\n", &cfg).unwrap();
1148        assert!(
1149            still_quote.starts_with("> line  \n> continued."),
1150            "in-quote hard break must still resume `>`, got:\n{still_quote:?}"
1151        );
1152    }
1153
1154    fn is_quote_resume(s: &str) -> bool {
1155        !s.is_empty()
1156            && !s.contains('\n')
1157            && s.contains('>')
1158            && s.bytes().all(|b| b == b'>' || b == b' ')
1159    }
1160
1161    #[test]
1162    fn quote_wrap_repeats_prefix_under_max_width() {
1163        use crate::format::Format;
1164        use crate::{FormatConfig, format_text};
1165
1166        let input = "> One two three four five six seven eight.\n";
1167        let cfg = FormatConfig {
1168            format: Format::Markdown,
1169            max_width: 20,
1170            ..Default::default()
1171        };
1172        let out = format_text(input, &cfg).unwrap();
1173        let lines: Vec<_> = out.lines().filter(|l| !l.is_empty()).collect();
1174        assert!(
1175            lines.len() > 1,
1176            "sentence must wrap under max_width=20, got:\n{out}"
1177        );
1178        for line in &lines {
1179            assert!(
1180                line.starts_with("> "),
1181                "every wrap line keeps `>`, not a space hang, got:\n{out}"
1182            );
1183            assert!(
1184                line.chars().count() <= 20,
1185                "prefix counts toward max_width: {line:?} ({out})"
1186            );
1187        }
1188        assert!(
1189            !out.contains("\n  "),
1190            "must not hang quote wrap with spaces: {out:?}"
1191        );
1192        assert_eq!(format_text(&out, &cfg).unwrap(), out);
1193    }
1194}