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
12static 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
20static QUOTE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(\s*(?:> )+)(.*)$").unwrap());
23
24static TABLE_ROW_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*\|.*\|\s*$").unwrap());
27
28static SETEXT_UNDERLINE_RE: LazyLock<Regex> =
31 LazyLock::new(|| Regex::new(r"^ {0,3}(?:=+|-+)\s*$").unwrap());
32
33pub struct MarkdownParser;
34
35fn 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
66fn 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
95fn 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
157fn 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
166fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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!(®ions[0], Region::Prose(_)));
596 match ®ions[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!(®ions[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 assert!(matches!(®ions[0], Region::Structure(_)));
621 assert!(matches!(®ions[1], Region::Structure(_)));
622 assert!(matches!(®ions[2], Region::Structure(_)));
623 assert!(matches!(®ions[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 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 for r in ®ions {
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 assert_eq!(
679 regions[1],
680 Region::Prose(
681 "First line of item continuation text here. Another sentence.".to_string()
682 )
683 );
684 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!(®ions[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 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 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 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 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!(®ions[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 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!(®ions[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!(®ions[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!(®ions[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 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 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("e, &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}