1use crate::utils::regex_cache::{
7 DISPLAY_MATH_REGEX, EMOJI_SHORTCODE_REGEX, FOOTNOTE_REF_REGEX, HTML_ENTITY_REGEX, HTML_TAG_PATTERN,
8 INLINE_IMAGE_FANCY_REGEX, INLINE_LINK_FANCY_REGEX, INLINE_MATH_REGEX, REF_IMAGE_REGEX, REF_LINK_REGEX,
9 SHORTCUT_REF_REGEX, STRIKETHROUGH_FANCY_REGEX, WIKI_LINK_REGEX,
10};
11#[derive(Clone)]
13pub struct ReflowOptions {
14 pub line_length: usize,
16 pub break_on_sentences: bool,
18 pub preserve_breaks: bool,
20 pub sentence_per_line: bool,
22}
23
24impl Default for ReflowOptions {
25 fn default() -> Self {
26 Self {
27 line_length: 80,
28 break_on_sentences: true,
29 preserve_breaks: false,
30 sentence_per_line: false,
31 }
32 }
33}
34
35fn is_sentence_boundary(text: &str, pos: usize) -> bool {
38 let chars: Vec<char> = text.chars().collect();
39
40 if pos + 2 >= chars.len() {
41 return false;
42 }
43
44 let c = chars[pos];
46 if c != '.' && c != '!' && c != '?' {
47 return false;
48 }
49
50 if chars[pos + 1] != ' ' {
52 return false;
53 }
54
55 if !chars[pos + 2].is_uppercase() {
57 return false;
58 }
59
60 if pos > 0 {
62 let prev_word = &text[..pos];
64 let ignored_words = [
65 "ie", "i.e", "eg", "e.g", "etc", "ex", "vs", "Mr", "Mrs", "Dr", "Ms", "Prof", "Sr", "Jr",
66 ];
67 for word in &ignored_words {
68 if prev_word.to_lowercase().ends_with(&word.to_lowercase()) {
69 return false;
70 }
71 }
72
73 if pos > 0 && chars[pos - 1].is_numeric() && pos + 2 < chars.len() && chars[pos + 2].is_numeric() {
75 return false;
76 }
77 }
78
79 true
80}
81
82pub fn split_into_sentences(text: &str) -> Vec<String> {
84 let mut sentences = Vec::new();
85 let mut current_sentence = String::new();
86 let mut chars = text.chars().peekable();
87 let mut pos = 0;
88
89 while let Some(c) = chars.next() {
90 current_sentence.push(c);
91
92 if is_sentence_boundary(text, pos) {
93 if chars.peek() == Some(&' ') {
95 chars.next();
96 pos += 1;
97 }
98
99 sentences.push(current_sentence.trim().to_string());
100 current_sentence.clear();
101 }
102
103 pos += 1;
104 }
105
106 if !current_sentence.trim().is_empty() {
108 sentences.push(current_sentence.trim().to_string());
109 }
110
111 sentences
112}
113
114fn is_horizontal_rule(line: &str) -> bool {
116 if line.len() < 3 {
117 return false;
118 }
119
120 let chars: Vec<char> = line.chars().collect();
122 if chars.is_empty() {
123 return false;
124 }
125
126 let first_char = chars[0];
127 if first_char != '-' && first_char != '_' && first_char != '*' {
128 return false;
129 }
130
131 for c in &chars {
133 if *c != first_char && *c != ' ' {
134 return false;
135 }
136 }
137
138 let non_space_count = chars.iter().filter(|c| **c != ' ').count();
140 non_space_count >= 3
141}
142
143fn is_numbered_list_item(line: &str) -> bool {
145 let mut chars = line.chars();
146
147 if !chars.next().is_some_and(|c| c.is_numeric()) {
149 return false;
150 }
151
152 while let Some(c) = chars.next() {
154 if c == '.' {
155 return chars.next().is_none_or(|c| c == ' ');
157 }
158 if !c.is_numeric() {
159 return false;
160 }
161 }
162
163 false
164}
165
166fn has_hard_break(line: &str) -> bool {
172 let line = line.strip_suffix('\r').unwrap_or(line);
173 line.ends_with(" ") || line.ends_with('\\')
174}
175
176fn trim_preserving_hard_break(s: &str) -> String {
182 let s = s.strip_suffix('\r').unwrap_or(s);
184
185 if s.ends_with('\\') {
187 return s.to_string();
189 }
190
191 if s.ends_with(" ") {
193 let content_end = s.trim_end().len();
195 if content_end == 0 {
196 return String::new();
198 }
199 format!("{} ", &s[..content_end])
201 } else {
202 s.trim_end().to_string()
204 }
205}
206
207pub fn reflow_line(line: &str, options: &ReflowOptions) -> Vec<String> {
208 if options.sentence_per_line {
210 let elements = parse_markdown_elements(line);
211 return reflow_elements_sentence_per_line(&elements);
212 }
213
214 if line.chars().count() <= options.line_length {
216 return vec![line.to_string()];
217 }
218
219 let elements = parse_markdown_elements(line);
221
222 reflow_elements(&elements, options)
224}
225
226#[derive(Debug, Clone)]
228enum Element {
229 Text(String),
231 Link { text: String, url: String },
233 ReferenceLink { text: String, reference: String },
235 EmptyReferenceLink { text: String },
237 ShortcutReference { reference: String },
239 InlineImage { alt: String, url: String },
241 ReferenceImage { alt: String, reference: String },
243 EmptyReferenceImage { alt: String },
245 FootnoteReference { note: String },
247 Strikethrough(String),
249 WikiLink(String),
251 InlineMath(String),
253 DisplayMath(String),
255 EmojiShortcode(String),
257 HtmlTag(String),
259 HtmlEntity(String),
261 Code(String),
263 Bold(String),
265 Italic(String),
267}
268
269impl std::fmt::Display for Element {
270 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
271 match self {
272 Element::Text(s) => write!(f, "{s}"),
273 Element::Link { text, url } => write!(f, "[{text}]({url})"),
274 Element::ReferenceLink { text, reference } => write!(f, "[{text}][{reference}]"),
275 Element::EmptyReferenceLink { text } => write!(f, "[{text}][]"),
276 Element::ShortcutReference { reference } => write!(f, "[{reference}]"),
277 Element::InlineImage { alt, url } => write!(f, ""),
278 Element::ReferenceImage { alt, reference } => write!(f, "![{alt}][{reference}]"),
279 Element::EmptyReferenceImage { alt } => write!(f, "![{alt}][]"),
280 Element::FootnoteReference { note } => write!(f, "[^{note}]"),
281 Element::Strikethrough(s) => write!(f, "~~{s}~~"),
282 Element::WikiLink(s) => write!(f, "[[{s}]]"),
283 Element::InlineMath(s) => write!(f, "${s}$"),
284 Element::DisplayMath(s) => write!(f, "$${s}$$"),
285 Element::EmojiShortcode(s) => write!(f, ":{s}:"),
286 Element::HtmlTag(s) => write!(f, "{s}"),
287 Element::HtmlEntity(s) => write!(f, "{s}"),
288 Element::Code(s) => write!(f, "`{s}`"),
289 Element::Bold(s) => write!(f, "**{s}**"),
290 Element::Italic(s) => write!(f, "*{s}*"),
291 }
292 }
293}
294
295impl Element {
296 fn len(&self) -> usize {
297 match self {
298 Element::Text(s) => s.chars().count(),
299 Element::Link { text, url } => text.chars().count() + url.chars().count() + 4, Element::ReferenceLink { text, reference } => text.chars().count() + reference.chars().count() + 4, Element::EmptyReferenceLink { text } => text.chars().count() + 4, Element::ShortcutReference { reference } => reference.chars().count() + 2, Element::InlineImage { alt, url } => alt.chars().count() + url.chars().count() + 5, Element::ReferenceImage { alt, reference } => alt.chars().count() + reference.chars().count() + 5, Element::EmptyReferenceImage { alt } => alt.chars().count() + 5, Element::FootnoteReference { note } => note.chars().count() + 3, Element::Strikethrough(s) => s.chars().count() + 4, Element::WikiLink(s) => s.chars().count() + 4, Element::InlineMath(s) => s.chars().count() + 2, Element::DisplayMath(s) => s.chars().count() + 4, Element::EmojiShortcode(s) => s.chars().count() + 2, Element::HtmlTag(s) => s.chars().count(), Element::HtmlEntity(s) => s.chars().count(), Element::Code(s) => s.chars().count() + 2, Element::Bold(s) => s.chars().count() + 4, Element::Italic(s) => s.chars().count() + 2, }
318 }
319}
320
321fn parse_markdown_elements(text: &str) -> Vec<Element> {
330 let mut elements = Vec::new();
331 let mut remaining = text;
332
333 while !remaining.is_empty() {
334 let mut earliest_match: Option<(usize, &str, fancy_regex::Match)> = None;
336
337 if let Ok(Some(m)) = INLINE_IMAGE_FANCY_REGEX.find(remaining)
340 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
341 {
342 earliest_match = Some((m.start(), "inline_image", m));
343 }
344
345 if let Ok(Some(m)) = REF_IMAGE_REGEX.find(remaining)
347 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
348 {
349 earliest_match = Some((m.start(), "ref_image", m));
350 }
351
352 if let Ok(Some(m)) = FOOTNOTE_REF_REGEX.find(remaining)
354 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
355 {
356 earliest_match = Some((m.start(), "footnote_ref", m));
357 }
358
359 if let Ok(Some(m)) = INLINE_LINK_FANCY_REGEX.find(remaining)
361 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
362 {
363 earliest_match = Some((m.start(), "inline_link", m));
364 }
365
366 if let Ok(Some(m)) = REF_LINK_REGEX.find(remaining)
368 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
369 {
370 earliest_match = Some((m.start(), "ref_link", m));
371 }
372
373 if let Ok(Some(m)) = SHORTCUT_REF_REGEX.find(remaining)
376 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
377 {
378 earliest_match = Some((m.start(), "shortcut_ref", m));
379 }
380
381 if let Ok(Some(m)) = WIKI_LINK_REGEX.find(remaining)
383 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
384 {
385 earliest_match = Some((m.start(), "wiki_link", m));
386 }
387
388 if let Ok(Some(m)) = DISPLAY_MATH_REGEX.find(remaining)
390 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
391 {
392 earliest_match = Some((m.start(), "display_math", m));
393 }
394
395 if let Ok(Some(m)) = INLINE_MATH_REGEX.find(remaining)
397 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
398 {
399 earliest_match = Some((m.start(), "inline_math", m));
400 }
401
402 if let Ok(Some(m)) = STRIKETHROUGH_FANCY_REGEX.find(remaining)
404 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
405 {
406 earliest_match = Some((m.start(), "strikethrough", m));
407 }
408
409 if let Ok(Some(m)) = EMOJI_SHORTCODE_REGEX.find(remaining)
411 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
412 {
413 earliest_match = Some((m.start(), "emoji", m));
414 }
415
416 if let Ok(Some(m)) = HTML_ENTITY_REGEX.find(remaining)
418 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
419 {
420 earliest_match = Some((m.start(), "html_entity", m));
421 }
422
423 if let Ok(Some(m)) = HTML_TAG_PATTERN.find(remaining)
425 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
426 {
427 earliest_match = Some((m.start(), "html_tag", m));
428 }
429
430 let mut next_special = remaining.len();
432 let mut special_type = "";
433
434 if let Some(pos) = remaining.find('`')
435 && pos < next_special
436 {
437 next_special = pos;
438 special_type = "code";
439 }
440 if let Some(pos) = remaining.find("**")
441 && pos < next_special
442 {
443 next_special = pos;
444 special_type = "bold";
445 }
446 if let Some(pos) = remaining.find('*')
447 && pos < next_special
448 && !remaining[pos..].starts_with("**")
449 {
450 next_special = pos;
451 special_type = "italic";
452 }
453
454 let should_process_markdown_link = if let Some((pos, _, _)) = earliest_match {
456 pos < next_special
457 } else {
458 false
459 };
460
461 if should_process_markdown_link {
462 let (pos, pattern_type, match_obj) = earliest_match.unwrap();
463
464 if pos > 0 {
466 elements.push(Element::Text(remaining[..pos].to_string()));
467 }
468
469 match pattern_type {
471 "inline_image" => {
472 if let Ok(Some(caps)) = INLINE_IMAGE_FANCY_REGEX.captures(remaining) {
473 let alt = caps.get(1).map(|m| m.as_str()).unwrap_or("");
474 let url = caps.get(2).map(|m| m.as_str()).unwrap_or("");
475 elements.push(Element::InlineImage {
476 alt: alt.to_string(),
477 url: url.to_string(),
478 });
479 remaining = &remaining[match_obj.end()..];
480 } else {
481 elements.push(Element::Text("!".to_string()));
482 remaining = &remaining[1..];
483 }
484 }
485 "ref_image" => {
486 if let Ok(Some(caps)) = REF_IMAGE_REGEX.captures(remaining) {
487 let alt = caps.get(1).map(|m| m.as_str()).unwrap_or("");
488 let reference = caps.get(2).map(|m| m.as_str()).unwrap_or("");
489
490 if reference.is_empty() {
491 elements.push(Element::EmptyReferenceImage { alt: alt.to_string() });
492 } else {
493 elements.push(Element::ReferenceImage {
494 alt: alt.to_string(),
495 reference: reference.to_string(),
496 });
497 }
498 remaining = &remaining[match_obj.end()..];
499 } else {
500 elements.push(Element::Text("!".to_string()));
501 remaining = &remaining[1..];
502 }
503 }
504 "footnote_ref" => {
505 if let Ok(Some(caps)) = FOOTNOTE_REF_REGEX.captures(remaining) {
506 let note = caps.get(1).map(|m| m.as_str()).unwrap_or("");
507 elements.push(Element::FootnoteReference { note: note.to_string() });
508 remaining = &remaining[match_obj.end()..];
509 } else {
510 elements.push(Element::Text("[".to_string()));
511 remaining = &remaining[1..];
512 }
513 }
514 "inline_link" => {
515 if let Ok(Some(caps)) = INLINE_LINK_FANCY_REGEX.captures(remaining) {
516 let text = caps.get(1).map(|m| m.as_str()).unwrap_or("");
517 let url = caps.get(2).map(|m| m.as_str()).unwrap_or("");
518 elements.push(Element::Link {
519 text: text.to_string(),
520 url: url.to_string(),
521 });
522 remaining = &remaining[match_obj.end()..];
523 } else {
524 elements.push(Element::Text("[".to_string()));
526 remaining = &remaining[1..];
527 }
528 }
529 "ref_link" => {
530 if let Ok(Some(caps)) = REF_LINK_REGEX.captures(remaining) {
531 let text = caps.get(1).map(|m| m.as_str()).unwrap_or("");
532 let reference = caps.get(2).map(|m| m.as_str()).unwrap_or("");
533
534 if reference.is_empty() {
535 elements.push(Element::EmptyReferenceLink { text: text.to_string() });
537 } else {
538 elements.push(Element::ReferenceLink {
540 text: text.to_string(),
541 reference: reference.to_string(),
542 });
543 }
544 remaining = &remaining[match_obj.end()..];
545 } else {
546 elements.push(Element::Text("[".to_string()));
548 remaining = &remaining[1..];
549 }
550 }
551 "shortcut_ref" => {
552 if let Ok(Some(caps)) = SHORTCUT_REF_REGEX.captures(remaining) {
553 let reference = caps.get(1).map(|m| m.as_str()).unwrap_or("");
554 elements.push(Element::ShortcutReference {
555 reference: reference.to_string(),
556 });
557 remaining = &remaining[match_obj.end()..];
558 } else {
559 elements.push(Element::Text("[".to_string()));
561 remaining = &remaining[1..];
562 }
563 }
564 "wiki_link" => {
565 if let Ok(Some(caps)) = WIKI_LINK_REGEX.captures(remaining) {
566 let content = caps.get(1).map(|m| m.as_str()).unwrap_or("");
567 elements.push(Element::WikiLink(content.to_string()));
568 remaining = &remaining[match_obj.end()..];
569 } else {
570 elements.push(Element::Text("[[".to_string()));
571 remaining = &remaining[2..];
572 }
573 }
574 "display_math" => {
575 if let Ok(Some(caps)) = DISPLAY_MATH_REGEX.captures(remaining) {
576 let math = caps.get(1).map(|m| m.as_str()).unwrap_or("");
577 elements.push(Element::DisplayMath(math.to_string()));
578 remaining = &remaining[match_obj.end()..];
579 } else {
580 elements.push(Element::Text("$$".to_string()));
581 remaining = &remaining[2..];
582 }
583 }
584 "inline_math" => {
585 if let Ok(Some(caps)) = INLINE_MATH_REGEX.captures(remaining) {
586 let math = caps.get(1).map(|m| m.as_str()).unwrap_or("");
587 elements.push(Element::InlineMath(math.to_string()));
588 remaining = &remaining[match_obj.end()..];
589 } else {
590 elements.push(Element::Text("$".to_string()));
591 remaining = &remaining[1..];
592 }
593 }
594 "strikethrough" => {
595 if let Ok(Some(caps)) = STRIKETHROUGH_FANCY_REGEX.captures(remaining) {
596 let text = caps.get(1).map(|m| m.as_str()).unwrap_or("");
597 elements.push(Element::Strikethrough(text.to_string()));
598 remaining = &remaining[match_obj.end()..];
599 } else {
600 elements.push(Element::Text("~~".to_string()));
601 remaining = &remaining[2..];
602 }
603 }
604 "emoji" => {
605 if let Ok(Some(caps)) = EMOJI_SHORTCODE_REGEX.captures(remaining) {
606 let emoji = caps.get(1).map(|m| m.as_str()).unwrap_or("");
607 elements.push(Element::EmojiShortcode(emoji.to_string()));
608 remaining = &remaining[match_obj.end()..];
609 } else {
610 elements.push(Element::Text(":".to_string()));
611 remaining = &remaining[1..];
612 }
613 }
614 "html_entity" => {
615 elements.push(Element::HtmlEntity(remaining[..match_obj.end()].to_string()));
617 remaining = &remaining[match_obj.end()..];
618 }
619 "html_tag" => {
620 elements.push(Element::HtmlTag(remaining[..match_obj.end()].to_string()));
622 remaining = &remaining[match_obj.end()..];
623 }
624 _ => {
625 elements.push(Element::Text("[".to_string()));
627 remaining = &remaining[1..];
628 }
629 }
630 } else {
631 if next_special > 0 && next_special < remaining.len() {
635 elements.push(Element::Text(remaining[..next_special].to_string()));
636 remaining = &remaining[next_special..];
637 }
638
639 match special_type {
641 "code" => {
642 if let Some(code_end) = remaining[1..].find('`') {
644 let code = &remaining[1..1 + code_end];
645 elements.push(Element::Code(code.to_string()));
646 remaining = &remaining[1 + code_end + 1..];
647 } else {
648 elements.push(Element::Text(remaining.to_string()));
650 break;
651 }
652 }
653 "bold" => {
654 if let Some(bold_end) = remaining[2..].find("**") {
656 let bold_text = &remaining[2..2 + bold_end];
657 elements.push(Element::Bold(bold_text.to_string()));
658 remaining = &remaining[2 + bold_end + 2..];
659 } else {
660 elements.push(Element::Text("**".to_string()));
662 remaining = &remaining[2..];
663 }
664 }
665 "italic" => {
666 if let Some(italic_end) = remaining[1..].find('*') {
668 let italic_text = &remaining[1..1 + italic_end];
669 elements.push(Element::Italic(italic_text.to_string()));
670 remaining = &remaining[1 + italic_end + 1..];
671 } else {
672 elements.push(Element::Text("*".to_string()));
674 remaining = &remaining[1..];
675 }
676 }
677 _ => {
678 elements.push(Element::Text(remaining.to_string()));
680 break;
681 }
682 }
683 }
684 }
685
686 elements
687}
688
689fn reflow_elements_sentence_per_line(elements: &[Element]) -> Vec<String> {
691 let mut lines = Vec::new();
692 let mut current_line = String::new();
693
694 for element in elements {
695 let element_str = format!("{element}");
696
697 if let Element::Text(text) = element {
699 let combined = format!("{current_line}{text}");
701 let sentences = split_into_sentences(&combined);
702
703 if sentences.len() > 1 {
704 for (i, sentence) in sentences.iter().enumerate() {
706 if i == 0 {
707 lines.push(sentence.to_string());
709 } else if i == sentences.len() - 1 {
710 current_line = sentence.to_string();
712 } else {
713 lines.push(sentence.to_string());
715 }
716 }
717 } else {
718 current_line = combined;
720 }
721 } else {
722 if !current_line.is_empty()
725 && !current_line.ends_with(' ')
726 && !current_line.ends_with('(')
727 && !current_line.ends_with('[')
728 {
729 current_line.push(' ');
730 }
731 current_line.push_str(&element_str);
732 }
733 }
734
735 if !current_line.is_empty() {
737 lines.push(current_line.trim().to_string());
738 }
739
740 lines
741}
742
743fn reflow_elements(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
745 let mut lines = Vec::new();
746 let mut current_line = String::new();
747 let mut current_length = 0;
748
749 for element in elements {
750 let element_str = format!("{element}");
751 let element_len = element.len();
752
753 if let Element::Text(text) = element {
755 let words: Vec<&str> = text.split_whitespace().collect();
757
758 for word in words {
759 let word_len = word.chars().count();
760 if current_length > 0 && current_length + 1 + word_len > options.line_length {
761 lines.push(current_line.trim().to_string());
763 current_line = word.to_string();
764 current_length = word_len;
765 } else {
766 if current_length > 0 {
768 current_line.push(' ');
769 current_length += 1;
770 }
771 current_line.push_str(word);
772 current_length += word_len;
773 }
774 }
775 } else {
776 if current_length > 0 && current_length + 1 + element_len > options.line_length {
779 lines.push(current_line.trim().to_string());
781 current_line = element_str;
782 current_length = element_len;
783 } else {
784 if current_length > 0 {
786 current_line.push(' ');
787 current_length += 1;
788 }
789 current_line.push_str(&element_str);
790 current_length += element_len;
791 }
792 }
793 }
794
795 if !current_line.is_empty() {
797 lines.push(current_line.trim_end().to_string());
798 }
799
800 lines
801}
802
803pub fn reflow_markdown(content: &str, options: &ReflowOptions) -> String {
805 let lines: Vec<&str> = content.lines().collect();
806 let mut result = Vec::new();
807 let mut i = 0;
808
809 while i < lines.len() {
810 let line = lines[i];
811 let trimmed = line.trim();
812
813 if trimmed.is_empty() {
815 result.push(String::new());
816 i += 1;
817 continue;
818 }
819
820 if trimmed.starts_with('#') {
822 result.push(line.to_string());
823 i += 1;
824 continue;
825 }
826
827 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
829 result.push(line.to_string());
830 i += 1;
831 while i < lines.len() {
833 result.push(lines[i].to_string());
834 if lines[i].trim().starts_with("```") || lines[i].trim().starts_with("~~~") {
835 i += 1;
836 break;
837 }
838 i += 1;
839 }
840 continue;
841 }
842
843 if line.starts_with(" ") || line.starts_with("\t") {
845 result.push(line.to_string());
847 i += 1;
848 while i < lines.len() {
849 let next_line = lines[i];
850 if next_line.starts_with(" ") || next_line.starts_with("\t") || next_line.trim().is_empty() {
852 result.push(next_line.to_string());
853 i += 1;
854 } else {
855 break;
856 }
857 }
858 continue;
859 }
860
861 if trimmed.starts_with('>') {
863 let quote_prefix = line[0..line.find('>').unwrap() + 1].to_string();
864 let quote_content = &line[quote_prefix.len()..].trim_start();
865
866 let reflowed = reflow_line(quote_content, options);
867 for reflowed_line in reflowed.iter() {
868 result.push(format!("{quote_prefix} {reflowed_line}"));
869 }
870 i += 1;
871 continue;
872 }
873
874 if is_horizontal_rule(trimmed) {
876 result.push(line.to_string());
877 i += 1;
878 continue;
879 }
880
881 if (trimmed.starts_with('-') && !is_horizontal_rule(trimmed))
883 || (trimmed.starts_with('*') && !is_horizontal_rule(trimmed))
884 || trimmed.starts_with('+')
885 || is_numbered_list_item(trimmed)
886 {
887 let indent = line.len() - line.trim_start().len();
889 let indent_str = " ".repeat(indent);
890
891 let mut marker_end = indent;
894 let mut content_start = indent;
895
896 if trimmed.chars().next().is_some_and(|c| c.is_numeric()) {
897 if let Some(period_pos) = line[indent..].find('.') {
899 marker_end = indent + period_pos + 1; content_start = marker_end;
901 while content_start < line.len() && line.chars().nth(content_start) == Some(' ') {
903 content_start += 1;
904 }
905 }
906 } else {
907 marker_end = indent + 1; content_start = marker_end;
910 while content_start < line.len() && line.chars().nth(content_start) == Some(' ') {
912 content_start += 1;
913 }
914 }
915
916 let marker = &line[indent..marker_end];
917
918 let mut list_content = vec![trim_preserving_hard_break(&line[content_start..])];
921 i += 1;
922
923 while i < lines.len() {
925 let next_line = lines[i];
926 let next_trimmed = next_line.trim();
927
928 if next_trimmed.is_empty()
930 || next_trimmed.starts_with('#')
931 || next_trimmed.starts_with("```")
932 || next_trimmed.starts_with("~~~")
933 || next_trimmed.starts_with('>')
934 || next_trimmed.starts_with('|')
935 || (next_trimmed.starts_with('[') && next_line.contains("]:"))
936 || is_horizontal_rule(next_trimmed)
937 || (next_trimmed.starts_with('-')
938 && (next_trimmed.len() == 1 || next_trimmed.chars().nth(1) == Some(' ')))
939 || (next_trimmed.starts_with('*')
940 && (next_trimmed.len() == 1 || next_trimmed.chars().nth(1) == Some(' ')))
941 || (next_trimmed.starts_with('+')
942 && (next_trimmed.len() == 1 || next_trimmed.chars().nth(1) == Some(' ')))
943 || is_numbered_list_item(next_trimmed)
944 {
945 break;
946 }
947
948 let next_indent = next_line.len() - next_line.trim_start().len();
950 if next_indent >= content_start {
951 let trimmed_start = next_line.trim_start();
954 list_content.push(trim_preserving_hard_break(trimmed_start));
955 i += 1;
956 } else {
957 break;
959 }
960 }
961
962 let combined_content = if options.preserve_breaks {
965 list_content[0].clone()
966 } else {
967 let has_hard_breaks = list_content.iter().any(|line| has_hard_break(line));
969 if has_hard_breaks {
970 list_content.join("\n")
972 } else {
973 list_content.join(" ")
975 }
976 };
977
978 let trimmed_marker = marker;
980 let continuation_spaces = content_start;
981
982 let prefix_length = indent + trimmed_marker.len() + 1;
984
985 let adjusted_options = ReflowOptions {
987 line_length: options.line_length.saturating_sub(prefix_length),
988 ..options.clone()
989 };
990
991 let reflowed = reflow_line(&combined_content, &adjusted_options);
992 for (j, reflowed_line) in reflowed.iter().enumerate() {
993 if j == 0 {
994 result.push(format!("{indent_str}{trimmed_marker} {reflowed_line}"));
995 } else {
996 let continuation_indent = " ".repeat(continuation_spaces);
998 result.push(format!("{continuation_indent}{reflowed_line}"));
999 }
1000 }
1001 continue;
1002 }
1003
1004 if trimmed.contains('|') {
1006 result.push(line.to_string());
1007 i += 1;
1008 continue;
1009 }
1010
1011 if trimmed.starts_with('[') && line.contains("]:") {
1013 result.push(line.to_string());
1014 i += 1;
1015 continue;
1016 }
1017
1018 let mut is_single_line_paragraph = true;
1020 if i + 1 < lines.len() {
1021 let next_line = lines[i + 1];
1022 let next_trimmed = next_line.trim();
1023 if !next_trimmed.is_empty()
1025 && !next_trimmed.starts_with('#')
1026 && !next_trimmed.starts_with("```")
1027 && !next_trimmed.starts_with("~~~")
1028 && !next_trimmed.starts_with('>')
1029 && !next_trimmed.starts_with('|')
1030 && !(next_trimmed.starts_with('[') && next_line.contains("]:"))
1031 && !is_horizontal_rule(next_trimmed)
1032 && !(next_trimmed.starts_with('-')
1033 && !is_horizontal_rule(next_trimmed)
1034 && (next_trimmed.len() == 1 || next_trimmed.chars().nth(1) == Some(' ')))
1035 && !(next_trimmed.starts_with('*')
1036 && !is_horizontal_rule(next_trimmed)
1037 && (next_trimmed.len() == 1 || next_trimmed.chars().nth(1) == Some(' ')))
1038 && !(next_trimmed.starts_with('+')
1039 && (next_trimmed.len() == 1 || next_trimmed.chars().nth(1) == Some(' ')))
1040 && !is_numbered_list_item(next_trimmed)
1041 {
1042 is_single_line_paragraph = false;
1043 }
1044 }
1045
1046 if is_single_line_paragraph && line.chars().count() <= options.line_length {
1048 result.push(line.to_string());
1049 i += 1;
1050 continue;
1051 }
1052
1053 let mut paragraph_parts = Vec::new();
1055 let mut current_part = vec![line];
1056 i += 1;
1057
1058 if options.preserve_breaks {
1060 let hard_break_type = if line.strip_suffix('\r').unwrap_or(line).ends_with('\\') {
1062 Some("\\")
1063 } else if line.ends_with(" ") {
1064 Some(" ")
1065 } else {
1066 None
1067 };
1068 let reflowed = reflow_line(line, options);
1069
1070 if let Some(break_marker) = hard_break_type {
1072 if !reflowed.is_empty() {
1073 let mut reflowed_with_break = reflowed;
1074 let last_idx = reflowed_with_break.len() - 1;
1075 if !has_hard_break(&reflowed_with_break[last_idx]) {
1076 reflowed_with_break[last_idx].push_str(break_marker);
1077 }
1078 result.extend(reflowed_with_break);
1079 }
1080 } else {
1081 result.extend(reflowed);
1082 }
1083 } else {
1084 while i < lines.len() {
1086 let prev_line = if !current_part.is_empty() {
1087 current_part.last().unwrap()
1088 } else {
1089 ""
1090 };
1091 let next_line = lines[i];
1092 let next_trimmed = next_line.trim();
1093
1094 if next_trimmed.is_empty()
1096 || next_trimmed.starts_with('#')
1097 || next_trimmed.starts_with("```")
1098 || next_trimmed.starts_with("~~~")
1099 || next_trimmed.starts_with('>')
1100 || next_trimmed.starts_with('|')
1101 || (next_trimmed.starts_with('[') && next_line.contains("]:"))
1102 || is_horizontal_rule(next_trimmed)
1103 || (next_trimmed.starts_with('-')
1104 && !is_horizontal_rule(next_trimmed)
1105 && (next_trimmed.len() == 1 || next_trimmed.chars().nth(1) == Some(' ')))
1106 || (next_trimmed.starts_with('*')
1107 && !is_horizontal_rule(next_trimmed)
1108 && (next_trimmed.len() == 1 || next_trimmed.chars().nth(1) == Some(' ')))
1109 || (next_trimmed.starts_with('+')
1110 && (next_trimmed.len() == 1 || next_trimmed.chars().nth(1) == Some(' ')))
1111 || is_numbered_list_item(next_trimmed)
1112 {
1113 break;
1114 }
1115
1116 if has_hard_break(prev_line) {
1118 paragraph_parts.push(current_part.join(" "));
1120 current_part = vec![next_line];
1121 } else {
1122 current_part.push(next_line);
1123 }
1124 i += 1;
1125 }
1126
1127 if !current_part.is_empty() {
1129 if current_part.len() == 1 {
1130 paragraph_parts.push(current_part[0].to_string());
1132 } else {
1133 paragraph_parts.push(current_part.join(" "));
1134 }
1135 }
1136
1137 for (j, part) in paragraph_parts.iter().enumerate() {
1139 let reflowed = reflow_line(part, options);
1140 result.extend(reflowed);
1141
1142 if j < paragraph_parts.len() - 1 && !result.is_empty() {
1145 let last_idx = result.len() - 1;
1146 if !has_hard_break(&result[last_idx]) {
1147 result[last_idx].push_str(" ");
1148 }
1149 }
1150 }
1151 }
1152 }
1153
1154 let result_text = result.join("\n");
1156 if content.ends_with('\n') && !result_text.ends_with('\n') {
1157 format!("{result_text}\n")
1158 } else {
1159 result_text
1160 }
1161}
1162
1163#[cfg(test)]
1164mod tests {
1165 use super::*;
1166
1167 #[test]
1168 fn test_list_item_trailing_whitespace_removal() {
1169 let input = "1. First line with trailing spaces \n Second line with trailing spaces \n Third line\n";
1172
1173 let options = ReflowOptions {
1174 line_length: 999999,
1175 break_on_sentences: true, preserve_breaks: false,
1177 sentence_per_line: false,
1178 };
1179
1180 let result = reflow_markdown(input, &options);
1181
1182 eprintln!("Input: {input:?}");
1183 eprintln!("Result: {result:?}");
1184
1185 assert!(
1188 !result.contains(" "),
1189 "Result should not contain 3+ consecutive spaces: {result:?}"
1190 );
1191
1192 assert!(result.contains(" \n"), "Hard breaks should be preserved: {result:?}");
1194
1195 assert!(
1198 result.lines().count() >= 2,
1199 "Should have multiple lines (not reflowed due to hard breaks), got: {}",
1200 result.lines().count()
1201 );
1202 }
1203
1204 #[test]
1205 fn test_reflow_simple_text() {
1206 let options = ReflowOptions {
1207 line_length: 20,
1208 ..Default::default()
1209 };
1210
1211 let input = "This is a very long line that needs to be wrapped";
1212 let result = reflow_line(input, &options);
1213
1214 assert_eq!(result.len(), 3);
1215 assert!(result[0].chars().count() <= 20);
1216 assert!(result[1].chars().count() <= 20);
1217 assert!(result[2].chars().count() <= 20);
1218 }
1219
1220 #[test]
1221 fn test_preserve_inline_code() {
1222 let options = ReflowOptions {
1223 line_length: 30,
1224 ..Default::default()
1225 };
1226
1227 let result = reflow_line("This line has `inline code` that should be preserved", &options);
1228 let joined = result.join(" ");
1230 assert!(joined.contains("`inline code`"));
1231 }
1232
1233 #[test]
1234 fn test_preserve_links() {
1235 let options = ReflowOptions {
1236 line_length: 40,
1237 ..Default::default()
1238 };
1239
1240 let text = "Check out [this link](https://example.com/very/long/url) for more info";
1241 let result = reflow_line(text, &options);
1242
1243 let joined = result.join(" ");
1245 assert!(joined.contains("[this link](https://example.com/very/long/url)"));
1246 }
1247
1248 #[test]
1249 fn test_reference_link_patterns_fixed() {
1250 let options = ReflowOptions {
1251 line_length: 30,
1252 break_on_sentences: true,
1253 preserve_breaks: false,
1254 sentence_per_line: false,
1255 };
1256
1257 let test_cases = vec![
1259 ("Check out [text][ref] for details", vec!["[text][ref]"]),
1261 ("See [text][] for info", vec!["[text][]"]),
1263 ("Visit [homepage] today", vec!["[homepage]"]),
1265 (
1267 "Links: [first][ref1] and [second][ref2] here",
1268 vec!["[first][ref1]", "[second][ref2]"],
1269 ),
1270 (
1272 "See [inline](url) and [reference][ref] links",
1273 vec", "[reference][ref]"],
1274 ),
1275 ];
1276
1277 for (input, expected_patterns) in test_cases {
1278 println!("\nTesting: {input}");
1279 let result = reflow_line(input, &options);
1280 let joined = result.join(" ");
1281 println!("Result: {joined}");
1282
1283 for expected_pattern in expected_patterns {
1285 assert!(
1286 joined.contains(expected_pattern),
1287 "Expected '{expected_pattern}' to be preserved in '{input}', but got '{joined}'"
1288 );
1289 }
1290
1291 assert!(
1293 !joined.contains("[ ") || !joined.contains("] ["),
1294 "Detected broken reference link pattern with spaces inside brackets in '{joined}'"
1295 );
1296 }
1297 }
1298
1299 #[test]
1300 fn test_sentence_detection_basic() {
1301 assert!(is_sentence_boundary("Hello. World", 5));
1303 assert!(is_sentence_boundary("Test! Another", 4));
1304 assert!(is_sentence_boundary("Question? Answer", 8));
1305
1306 assert!(!is_sentence_boundary("Hello world", 5));
1308 assert!(!is_sentence_boundary("Test.com", 4));
1309 assert!(!is_sentence_boundary("3.14 pi", 1));
1310 }
1311
1312 #[test]
1313 fn test_sentence_detection_abbreviations() {
1314 assert!(!is_sentence_boundary("Mr. Smith", 2));
1316 assert!(!is_sentence_boundary("Dr. Jones", 2));
1317 assert!(!is_sentence_boundary("e.g. example", 3));
1318 assert!(!is_sentence_boundary("i.e. that is", 3));
1319 assert!(!is_sentence_boundary("etc. items", 3));
1320
1321 assert!(is_sentence_boundary("Mr. Smith arrived. Next sentence.", 17));
1323 }
1324
1325 #[test]
1326 fn test_split_into_sentences() {
1327 let text = "First sentence. Second sentence. Third one!";
1328 let sentences = split_into_sentences(text);
1329 assert_eq!(sentences.len(), 3);
1330 assert_eq!(sentences[0], "First sentence.");
1331 assert_eq!(sentences[1], "Second sentence.");
1332 assert_eq!(sentences[2], "Third one!");
1333
1334 let text2 = "Mr. Smith met Dr. Jones.";
1336 let sentences2 = split_into_sentences(text2);
1337 assert_eq!(sentences2.len(), 1);
1338 assert_eq!(sentences2[0], "Mr. Smith met Dr. Jones.");
1339
1340 let text3 = "This is a single sentence.";
1342 let sentences3 = split_into_sentences(text3);
1343 assert_eq!(sentences3.len(), 1);
1344 assert_eq!(sentences3[0], "This is a single sentence.");
1345 }
1346
1347 #[test]
1348 fn test_sentence_per_line_reflow() {
1349 let options = ReflowOptions {
1350 line_length: 80,
1351 break_on_sentences: true,
1352 preserve_breaks: false,
1353 sentence_per_line: true,
1354 };
1355
1356 let input = "First sentence. Second sentence. Third sentence.";
1358 let result = reflow_line(input, &options);
1359 assert_eq!(result.len(), 3);
1360 assert_eq!(result[0], "First sentence.");
1361 assert_eq!(result[1], "Second sentence.");
1362 assert_eq!(result[2], "Third sentence.");
1363
1364 let input2 = "This has **bold**. And [a link](url).";
1366 let result2 = reflow_line(input2, &options);
1367 assert_eq!(result2.len(), 2);
1368 assert_eq!(result2[0], "This has **bold**.");
1369 assert_eq!(result2[1], "And [a link](url).");
1370 }
1371
1372 #[test]
1373 fn test_sentence_per_line_with_backticks() {
1374 let options = ReflowOptions {
1375 line_length: 80,
1376 break_on_sentences: true,
1377 preserve_breaks: false,
1378 sentence_per_line: true,
1379 };
1380
1381 let input = "This sentence has `code` in it. And this has `more code` too.";
1382 let result = reflow_line(input, &options);
1383 assert_eq!(result.len(), 2);
1384 assert_eq!(result[0], "This sentence has `code` in it.");
1385 assert_eq!(result[1], "And this has `more code` too.");
1386 }
1387
1388 #[test]
1389 fn test_sentence_per_line_with_backticks_in_parens() {
1390 let options = ReflowOptions {
1391 line_length: 80,
1392 break_on_sentences: true,
1393 preserve_breaks: false,
1394 sentence_per_line: true,
1395 };
1396
1397 let input = "Configure in (`.rumdl.toml` or `pyproject.toml`). Next sentence.";
1398 let result = reflow_line(input, &options);
1399 assert_eq!(result.len(), 2);
1400 assert_eq!(result[0], "Configure in (`.rumdl.toml` or `pyproject.toml`).");
1401 assert_eq!(result[1], "Next sentence.");
1402 }
1403
1404 #[test]
1405 fn test_sentence_per_line_with_questions_exclamations() {
1406 let options = ReflowOptions {
1407 line_length: 80,
1408 break_on_sentences: true,
1409 preserve_breaks: false,
1410 sentence_per_line: true,
1411 };
1412
1413 let input = "Is this a question? Yes it is! And a statement.";
1414 let result = reflow_line(input, &options);
1415 assert_eq!(result.len(), 3);
1416 assert_eq!(result[0], "Is this a question?");
1417 assert_eq!(result[1], "Yes it is!");
1418 assert_eq!(result[2], "And a statement.");
1419 }
1420
1421 #[test]
1422 fn test_reference_link_edge_cases() {
1423 let options = ReflowOptions {
1424 line_length: 40,
1425 break_on_sentences: true,
1426 preserve_breaks: false,
1427 sentence_per_line: false,
1428 };
1429
1430 let test_cases = vec![
1432 ("Text with \\[escaped\\] brackets", vec!["\\[escaped\\]"]),
1434 (
1436 "Link [text with [nested] content][ref]",
1437 vec!["[text with [nested] content][ref]"],
1438 ),
1439 (
1441 "First [ref][link] then [inline](url)",
1442 vec!["[ref][link]", "[inline](url)"],
1443 ),
1444 ("Array [0] and reference [link] here", vec!["[0]", "[link]"]),
1446 (
1448 "Complex [text with *emphasis*][] reference",
1449 vec!["[text with *emphasis*][]"],
1450 ),
1451 ];
1452
1453 for (input, expected_patterns) in test_cases {
1454 println!("\nTesting edge case: {input}");
1455 let result = reflow_line(input, &options);
1456 let joined = result.join(" ");
1457 println!("Result: {joined}");
1458
1459 for expected_pattern in expected_patterns {
1461 assert!(
1462 joined.contains(expected_pattern),
1463 "Expected '{expected_pattern}' to be preserved in '{input}', but got '{joined}'"
1464 );
1465 }
1466 }
1467 }
1468
1469 #[test]
1470 fn test_reflow_with_emphasis() {
1471 let options = ReflowOptions {
1472 line_length: 25,
1473 ..Default::default()
1474 };
1475
1476 let result = reflow_line("This is *emphasized* and **strong** text that needs wrapping", &options);
1477
1478 let joined = result.join(" ");
1480 assert!(joined.contains("*emphasized*"));
1481 assert!(joined.contains("**strong**"));
1482 }
1483
1484 #[test]
1485 fn test_image_patterns_preserved() {
1486 let options = ReflowOptions {
1487 line_length: 30,
1488 ..Default::default()
1489 };
1490
1491 let test_cases = vec for details",
1496 vec"],
1497 ),
1498 ("See ![image][ref] for info", vec!["![image][ref]"]),
1500 ("Visit ![homepage][] today", vec!["![homepage][]"]),
1502 (
1504 "Images:  and ![second][ref2]",
1505 vec", "![second][ref2]"],
1506 ),
1507 ];
1508
1509 for (input, expected_patterns) in test_cases {
1510 println!("\nTesting: {input}");
1511 let result = reflow_line(input, &options);
1512 let joined = result.join(" ");
1513 println!("Result: {joined}");
1514
1515 for expected_pattern in expected_patterns {
1516 assert!(
1517 joined.contains(expected_pattern),
1518 "Expected '{expected_pattern}' to be preserved in '{input}', but got '{joined}'"
1519 );
1520 }
1521 }
1522 }
1523
1524 #[test]
1525 fn test_extended_markdown_patterns() {
1526 let options = ReflowOptions {
1527 line_length: 40,
1528 ..Default::default()
1529 };
1530
1531 let test_cases = vec![
1532 ("Text with ~~strikethrough~~ preserved", vec!["~~strikethrough~~"]),
1534 (
1536 "Check [[wiki link]] and [[page|display]]",
1537 vec!["[[wiki link]]", "[[page|display]]"],
1538 ),
1539 (
1541 "Inline $x^2 + y^2$ and display $$\\int f(x) dx$$",
1542 vec!["$x^2 + y^2$", "$$\\int f(x) dx$$"],
1543 ),
1544 ("Use :smile: and :heart: emojis", vec![":smile:", ":heart:"]),
1546 (
1548 "Text with <span>tag</span> and <br/>",
1549 vec!["<span>", "</span>", "<br/>"],
1550 ),
1551 ("Non-breaking space and em—dash", vec![" ", "—"]),
1553 ];
1554
1555 for (input, expected_patterns) in test_cases {
1556 let result = reflow_line(input, &options);
1557 let joined = result.join(" ");
1558
1559 for pattern in expected_patterns {
1560 assert!(
1561 joined.contains(pattern),
1562 "Expected '{pattern}' to be preserved in '{input}', but got '{joined}'"
1563 );
1564 }
1565 }
1566 }
1567
1568 #[test]
1569 fn test_complex_mixed_patterns() {
1570 let options = ReflowOptions {
1571 line_length: 50,
1572 ..Default::default()
1573 };
1574
1575 let input = "Line with **bold**, `code`, [link](url), , ~~strike~~, $math$, :emoji:, and <tag> all together";
1577 let result = reflow_line(input, &options);
1578 let joined = result.join(" ");
1579
1580 assert!(joined.contains("**bold**"));
1582 assert!(joined.contains("`code`"));
1583 assert!(joined.contains("[link](url)"));
1584 assert!(joined.contains(""));
1585 assert!(joined.contains("~~strike~~"));
1586 assert!(joined.contains("$math$"));
1587 assert!(joined.contains(":emoji:"));
1588 assert!(joined.contains("<tag>"));
1589 }
1590
1591 #[test]
1592 fn test_footnote_patterns_preserved() {
1593 let options = ReflowOptions {
1594 line_length: 40,
1595 ..Default::default()
1596 };
1597
1598 let test_cases = vec![
1599 ("This has a footnote[^1] reference", vec!["[^1]"]),
1601 ("Text with [^first] and [^second] notes", vec!["[^first]", "[^second]"]),
1603 ("Reference to [^long-footnote-name] here", vec!["[^long-footnote-name]"]),
1605 ];
1606
1607 for (input, expected_patterns) in test_cases {
1608 let result = reflow_line(input, &options);
1609 let joined = result.join(" ");
1610
1611 for expected_pattern in expected_patterns {
1612 assert!(
1613 joined.contains(expected_pattern),
1614 "Expected '{expected_pattern}' to be preserved in '{input}', but got '{joined}'"
1615 );
1616 }
1617 }
1618 }
1619
1620 #[test]
1621 fn test_reflow_markdown_numbered_lists() {
1622 let options = ReflowOptions {
1624 line_length: 50,
1625 ..Default::default()
1626 };
1627
1628 let content = r#"1. List `manifest` to find the manifest with the largest ID. Say it's `00000000000000000002.manifest` in this example.
16292. Short item
16303. Another long item that definitely exceeds the fifty character limit and needs wrapping"#;
1631
1632 let result = reflow_markdown(content, &options);
1633
1634 let expected = r#"1. List `manifest` to find the manifest with the
1636 largest ID. Say it's
1637 `00000000000000000002.manifest` in this
1638 example.
16392. Short item
16403. Another long item that definitely exceeds the
1641 fifty character limit and needs wrapping"#;
1642
1643 assert_eq!(
1644 result, expected,
1645 "Numbered lists should be reflowed with proper markers and indentation.\nExpected:\n{expected}\nGot:\n{result}"
1646 );
1647 }
1648
1649 #[test]
1650 fn test_reflow_markdown_bullet_lists() {
1651 let options = ReflowOptions {
1652 line_length: 40,
1653 ..Default::default()
1654 };
1655
1656 let content = r#"- First bullet point with a very long line that needs wrapping
1657* Second bullet using asterisk
1658+ Third bullet using plus sign
1659- Short one"#;
1660
1661 let result = reflow_markdown(content, &options);
1662
1663 let expected = r#"- First bullet point with a very long
1665 line that needs wrapping
1666* Second bullet using asterisk
1667+ Third bullet using plus sign
1668- Short one"#;
1669
1670 assert_eq!(
1671 result, expected,
1672 "Bullet lists should preserve markers and indent continuations with 2 spaces.\nExpected:\n{expected}\nGot:\n{result}"
1673 );
1674 }
1675}