1use crate::utils::calculate_indentation_width_default;
7use crate::utils::is_definition_list_item;
8use crate::utils::mkdocs_attr_list::{ATTR_LIST_PATTERN, is_standalone_attr_list};
9use crate::utils::mkdocs_snippets::is_snippet_block_delimiter;
10use crate::utils::regex_cache::{
11 DISPLAY_MATH_REGEX, EMAIL_PATTERN, EMOJI_SHORTCODE_REGEX, HTML_ENTITY_REGEX, HTML_TAG_PATTERN,
12 HUGO_SHORTCODE_REGEX, INLINE_MATH_REGEX, WIKI_LINK_REGEX,
13};
14use crate::utils::sentence_utils::{
15 get_abbreviations, is_cjk_char, is_cjk_sentence_ending, is_closing_quote, is_opening_quote,
16 text_ends_with_abbreviation,
17};
18use pulldown_cmark::{BrokenLink, CowStr, Event, LinkType, Options, Parser, Tag, TagEnd};
19use std::collections::HashSet;
20use unicode_width::UnicodeWidthStr;
21
22#[derive(Clone, Copy, Debug, Default, PartialEq)]
24pub enum ReflowLengthMode {
25 Chars,
27 #[default]
29 Visual,
30 Bytes,
32}
33
34fn display_len(s: &str, mode: ReflowLengthMode) -> usize {
36 match mode {
37 ReflowLengthMode::Chars => s.chars().count(),
38 ReflowLengthMode::Visual => s.width(),
39 ReflowLengthMode::Bytes => s.len(),
40 }
41}
42
43fn is_non_breaking_space(c: char) -> bool {
47 matches!(c, '\u{00A0}' | '\u{202F}' | '\u{2007}')
48}
49
50fn is_breakable_whitespace(c: char) -> bool {
55 c.is_whitespace() && !is_non_breaking_space(c)
56}
57
58fn split_breakable_words(text: &str) -> impl Iterator<Item = &str> {
60 text.split(is_breakable_whitespace).filter(|word| !word.is_empty())
61}
62
63fn code_span_wraps_losslessly(content: &str) -> bool {
72 let mut prev_ws = false;
73 for c in content.chars() {
74 let ws = is_breakable_whitespace(c);
75 if ws && (prev_ws || c != ' ') {
76 return false;
77 }
78 prev_ws = ws;
79 }
80 true
81}
82
83struct NestedStructure {
86 atomic: Vec<(usize, usize)>,
93 markers: Vec<(usize, usize)>,
98}
99
100struct OpenSpan {
102 span: (usize, usize),
104 content: Option<(usize, usize)>,
107}
108
109fn note_span_content(open: &mut [OpenSpan], start: usize, end: usize) {
112 for open_span in open.iter_mut() {
113 if start >= open_span.span.0 && end <= open_span.span.1 {
114 open_span.content = Some(match open_span.content {
115 Some((known_start, known_end)) => (known_start.min(start), known_end.max(end)),
116 None => (start, end),
117 });
118 }
119 }
120}
121
122fn merge_ranges(mut ranges: Vec<(usize, usize)>) -> Vec<(usize, usize)> {
123 ranges.sort_unstable();
126 let mut merged: Vec<(usize, usize)> = Vec::with_capacity(ranges.len());
127 for (start, end) in ranges {
128 match merged.last_mut() {
129 Some(last) if start <= last.1 => last.1 = last.1.max(end),
130 _ => merged.push((start, end)),
131 }
132 }
133 merged
134}
135
136fn nested_structure(content: &str, defined_references: Option<&HashSet<String>>, attr_lists: bool) -> NestedStructure {
138 let mut options = Options::empty();
139 options.insert(Options::ENABLE_STRIKETHROUGH);
140
141 let mut atomic: Vec<(usize, usize)> = Vec::new();
142 let mut markers: Vec<(usize, usize)> = Vec::new();
143 let mut open: Vec<OpenSpan> = Vec::new();
146
147 for (event, range) in Parser::new_ext(content, options).into_offset_iter() {
148 let (start, end) = (range.start, range.end);
149 if !matches!(event, Event::End(_)) {
153 note_span_content(&mut open, start, end);
154 }
155 match event {
156 Event::Code(_) | Event::InlineHtml(_) | Event::Start(Tag::Link { .. } | Tag::Image { .. }) => {
157 atomic.push((start, end));
158 }
159 Event::Start(Tag::Emphasis | Tag::Strong | Tag::Strikethrough) => {
160 open.push(OpenSpan {
161 span: (start, end),
162 content: None,
163 });
164 }
165 Event::End(TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough) => {
166 if let Some(OpenSpan {
167 span: (span_start, span_end),
168 content,
169 }) = open.pop()
170 {
171 match content {
172 Some((content_start, content_end)) => {
176 markers.push((span_start, content_start));
177 markers.push((content_end, span_end));
178 }
179 None => atomic.push((span_start, span_end)),
182 }
183 }
184 }
185 _ => {}
186 }
187 }
188
189 for span in extract_link_spans(content, defined_references) {
194 atomic.push((span.start, span.end));
195 }
196
197 for found in WIKI_LINK_REGEX
201 .find_iter(content)
202 .chain(HUGO_SHORTCODE_REGEX.find_iter(content))
203 .chain(DISPLAY_MATH_REGEX.find_iter(content))
204 {
205 atomic.push((found.start(), found.end()));
206 }
207 let mut from = 0;
208 while let Ok(Some(found)) = INLINE_MATH_REGEX.find_from_pos(content, from) {
209 atomic.push((found.start(), found.end()));
210 from = found.end();
211 }
212
213 if attr_lists {
219 for found in ATTR_LIST_PATTERN.find_iter(content) {
220 atomic.push((found.start(), found.end()));
221 }
222 }
223
224 NestedStructure {
225 atomic: merge_ranges(atomic),
226 markers: merge_ranges(markers),
227 }
228}
229
230fn breakable_units<'a>(
253 content: &'a str,
254 defined_references: Option<&HashSet<String>>,
255 attr_lists: bool,
256) -> Option<Vec<&'a str>> {
257 if !content.contains(['`', '*', '_', '~', '[', '<', '$', '{']) {
260 return Some(split_breakable_words(content).collect());
261 }
262
263 let NestedStructure { atomic, markers } = nested_structure(content, defined_references, attr_lists);
264
265 let mut units = Vec::new();
266 let mut unit_start = None;
267 let mut next_atomic = 0;
268 let mut next_marker = 0;
269 for (offset, ch) in content.char_indices() {
270 while atomic.get(next_atomic).is_some_and(|&(_, end)| end <= offset) {
271 next_atomic += 1;
272 }
273 if atomic.get(next_atomic).is_some_and(|&(start, _)| offset >= start) {
274 if unit_start.is_none() {
277 unit_start = Some(offset);
278 }
279 continue;
280 }
281 while markers.get(next_marker).is_some_and(|&(_, end)| end <= offset) {
282 next_marker += 1;
283 }
284 if matches!(ch, '`' | '*' | '_' | '~') && markers.get(next_marker).is_none_or(|&(start, _)| offset < start) {
285 return None;
286 }
287 if is_breakable_whitespace(ch) {
288 if let Some(start) = unit_start.take() {
289 units.push(&content[start..offset]);
290 }
291 } else if unit_start.is_none() {
292 unit_start = Some(offset);
293 }
294 }
295 if let Some(start) = unit_start {
296 units.push(&content[start..]);
297 }
298 Some(units)
299}
300
301#[derive(Clone)]
303pub struct ReflowOptions {
304 pub line_length: usize,
306 pub break_on_sentences: bool,
308 pub preserve_breaks: bool,
310 pub sentence_per_line: bool,
312 pub semantic_line_breaks: bool,
314 pub abbreviations: Option<Vec<String>>,
318 pub length_mode: ReflowLengthMode,
320 pub attr_lists: bool,
323 pub myst_roles: bool,
327 pub require_sentence_capital: bool,
332 pub max_list_continuation_indent: Option<usize>,
336 pub defined_references: Option<HashSet<String>>,
350 pub atomic_spans: bool,
354}
355
356impl Default for ReflowOptions {
357 fn default() -> Self {
358 Self {
359 line_length: 80,
360 break_on_sentences: true,
361 preserve_breaks: false,
362 sentence_per_line: false,
363 semantic_line_breaks: false,
364 abbreviations: None,
365 length_mode: ReflowLengthMode::default(),
366 attr_lists: false,
367 myst_roles: false,
368 require_sentence_capital: true,
369 max_list_continuation_indent: None,
370 defined_references: None,
371 atomic_spans: true,
372 }
373 }
374}
375
376pub fn normalize_reference_label(label: &str) -> String {
383 label.split_whitespace().collect::<Vec<_>>().join(" ").to_lowercase()
384}
385
386fn footnote_refs_end(chars: &[char], start: usize) -> Option<usize> {
392 let mut pos = start;
393 let mut found = false;
394
395 loop {
396 if chars.get(pos) != Some(&'[') || chars.get(pos + 1) != Some(&'^') {
397 break;
398 }
399 let label_start = pos + 2;
400 let mut label_end = label_start;
401 while matches!(chars.get(label_end), Some(c) if c.is_ascii_alphanumeric() || *c == '_' || *c == '-') {
402 label_end += 1;
403 }
404 if label_end == label_start || chars.get(label_end) != Some(&']') {
405 break;
406 }
407 pos = label_end + 1;
408 found = true;
409 }
410
411 found.then_some(pos)
412}
413
414fn is_sentence_boundary(
418 text: &str,
419 chars: &[char],
420 pos: usize,
421 byte_offset_after_punct: usize,
422 abbreviations: &HashSet<String>,
423 require_sentence_capital: bool,
424) -> bool {
425 if pos + 1 >= chars.len() {
426 return false;
427 }
428
429 let c = chars[pos];
430 let next_char = chars[pos + 1];
431
432 if is_cjk_sentence_ending(c) {
435 let mut after_punct_pos = pos + 1;
437 while after_punct_pos < chars.len()
438 && (chars[after_punct_pos] == '*' || chars[after_punct_pos] == '_' || chars[after_punct_pos] == '~')
439 {
440 after_punct_pos += 1;
441 }
442
443 while after_punct_pos < chars.len() && chars[after_punct_pos].is_whitespace() {
445 after_punct_pos += 1;
446 }
447
448 if after_punct_pos >= chars.len() {
450 return false;
451 }
452
453 while after_punct_pos < chars.len()
455 && (chars[after_punct_pos] == '*' || chars[after_punct_pos] == '_' || chars[after_punct_pos] == '~')
456 {
457 after_punct_pos += 1;
458 }
459
460 if after_punct_pos >= chars.len() {
461 return false;
462 }
463
464 return true;
467 }
468
469 if c != '.' && c != '!' && c != '?' {
471 return false;
472 }
473
474 let (_space_pos, after_space_pos) = if next_char == ' ' {
476 (pos + 1, pos + 2)
478 } else if is_closing_quote(next_char) && pos + 2 < chars.len() {
479 if chars[pos + 2] == ' ' {
481 (pos + 2, pos + 3)
483 } else if (chars[pos + 2] == '*' || chars[pos + 2] == '_') && pos + 3 < chars.len() && chars[pos + 3] == ' ' {
484 (pos + 3, pos + 4)
486 } else if (chars[pos + 2] == '*' || chars[pos + 2] == '_')
487 && pos + 4 < chars.len()
488 && chars[pos + 3] == chars[pos + 2]
489 && chars[pos + 4] == ' '
490 {
491 (pos + 4, pos + 5)
493 } else {
494 return false;
495 }
496 } else if (next_char == '*' || next_char == '_') && pos + 2 < chars.len() && chars[pos + 2] == ' ' {
497 (pos + 2, pos + 3)
499 } else if (next_char == '*' || next_char == '_')
500 && pos + 3 < chars.len()
501 && chars[pos + 2] == next_char
502 && chars[pos + 3] == ' '
503 {
504 (pos + 3, pos + 4)
506 } else if next_char == '~' && pos + 3 < chars.len() && chars[pos + 2] == '~' && chars[pos + 3] == ' ' {
507 (pos + 3, pos + 4)
509 } else if next_char == '[' {
510 match footnote_refs_end(chars, pos + 1) {
516 Some(end_pos) if chars.get(end_pos) == Some(&' ') => (end_pos, end_pos + 1),
517 _ => return false,
518 }
519 } else {
520 return false;
521 };
522
523 let mut next_char_pos = after_space_pos;
525 while next_char_pos < chars.len() && chars[next_char_pos].is_whitespace() {
526 next_char_pos += 1;
527 }
528
529 if next_char_pos >= chars.len() {
531 return false;
532 }
533
534 let mut first_letter_pos = next_char_pos;
536 while first_letter_pos < chars.len()
537 && (chars[first_letter_pos] == '*'
538 || chars[first_letter_pos] == '_'
539 || chars[first_letter_pos] == '~'
540 || is_opening_quote(chars[first_letter_pos]))
541 {
542 first_letter_pos += 1;
543 }
544
545 if first_letter_pos >= chars.len() {
547 return false;
548 }
549
550 let first_char = chars[first_letter_pos];
551
552 if c == '!' || c == '?' {
554 return true;
555 }
556
557 if pos > 0 {
561 if text_ends_with_abbreviation(&text[..byte_offset_after_punct], abbreviations) {
563 return false;
564 }
565
566 if chars[pos - 1].is_numeric() && first_char.is_ascii_digit() {
568 return false;
569 }
570
571 if chars[pos - 1].is_ascii_uppercase() && (pos == 1 || (pos >= 2 && chars[pos - 2].is_whitespace())) {
575 return false;
576 }
577 }
578
579 if require_sentence_capital && !first_char.is_uppercase() && !is_cjk_char(first_char) {
582 return false;
583 }
584
585 true
586}
587
588pub fn split_into_sentences(text: &str) -> Vec<String> {
590 split_into_sentences_custom(text, &None)
591}
592
593pub fn split_into_sentences_custom(text: &str, custom_abbreviations: &Option<Vec<String>>) -> Vec<String> {
595 let abbreviations = get_abbreviations(custom_abbreviations);
596 split_into_sentences_with_set(text, &abbreviations, true)
597}
598
599fn split_into_sentences_with_set(
602 text: &str,
603 abbreviations: &HashSet<String>,
604 require_sentence_capital: bool,
605) -> Vec<String> {
606 let char_vec: Vec<char> = text.chars().collect();
607
608 let mut char_offsets = Vec::with_capacity(char_vec.len() + 1);
612 let mut offset = 0;
613 for c in &char_vec {
614 char_offsets.push(offset);
615 offset += c.len_utf8();
616 }
617 char_offsets.push(offset);
618
619 let code_spans = extract_code_spans(text);
621 let mut span_it = code_spans.iter().peekable();
622
623 let mut sentences = Vec::new();
624 let mut current_sentence = String::new();
625 let mut pos = 0;
626
627 while pos < char_vec.len() {
628 let c = char_vec[pos];
629 current_sentence.push(c);
630
631 let byte_idx = char_offsets[pos];
632
633 while let Some(span) = span_it.peek() {
635 if span.end <= byte_idx {
636 span_it.next();
637 } else {
638 break;
639 }
640 }
641
642 let in_code = if let Some(span) = span_it.peek() {
644 byte_idx >= span.start && byte_idx < span.end
645 } else {
646 false
647 };
648
649 if !in_code
650 && is_sentence_boundary(
651 text,
652 &char_vec,
653 pos,
654 char_offsets[pos + 1],
655 abbreviations,
656 require_sentence_capital,
657 )
658 {
659 if let Some(end_pos) = footnote_refs_end(&char_vec, pos + 1) {
661 while pos + 1 < end_pos {
662 pos += 1;
663 current_sentence.push(char_vec[pos]);
664 }
665 }
666
667 while pos + 1 < char_vec.len() {
669 let next = char_vec[pos + 1];
670 if next == '*' || next == '_' || next == '~' || is_closing_quote(next) {
671 pos += 1;
672 current_sentence.push(char_vec[pos]);
673 } else {
674 break;
675 }
676 }
677
678 if pos + 1 < char_vec.len() && char_vec[pos + 1] == ' ' {
680 pos += 1; }
682
683 sentences.push(current_sentence.trim().to_string());
684 current_sentence.clear();
685 }
686
687 pos += 1;
688 }
689
690 if !current_sentence.trim().is_empty() {
692 sentences.push(current_sentence.trim().to_string());
693 }
694 sentences
695}
696
697fn is_horizontal_rule(line: &str) -> bool {
699 if line.len() < 3 {
700 return false;
701 }
702
703 let mut chars = line.chars();
706 let Some(first_char) = chars.next() else {
707 return false;
708 };
709 if first_char != '-' && first_char != '_' && first_char != '*' {
710 return false;
711 }
712
713 let mut non_space_count = 1usize; for c in chars {
715 if c == ' ' {
716 continue;
717 }
718 if c != first_char {
719 return false;
720 }
721 non_space_count += 1;
722 }
723 non_space_count >= 3
724}
725
726fn is_numbered_list_item(line: &str) -> bool {
728 let mut chars = line.chars();
729
730 if !chars.next().is_some_and(char::is_numeric) {
732 return false;
733 }
734
735 while let Some(c) = chars.next() {
737 if c == '.' {
738 return chars.next() == Some(' ');
741 }
742 if !c.is_numeric() {
743 return false;
744 }
745 }
746
747 false
748}
749
750fn is_unordered_list_marker(s: &str) -> bool {
752 matches!(s.as_bytes().first(), Some(b'-' | b'*' | b'+'))
753 && !is_horizontal_rule(s)
754 && (s.len() == 1 || s.as_bytes().get(1) == Some(&b' '))
755}
756
757fn is_block_boundary_core(trimmed: &str) -> bool {
760 trimmed.is_empty()
761 || trimmed.starts_with('#')
762 || trimmed.starts_with("```")
763 || trimmed.starts_with("~~~")
764 || trimmed.starts_with('>')
765 || (trimmed.starts_with('[') && trimmed.contains("]:"))
766 || is_horizontal_rule(trimmed)
767 || is_unordered_list_marker(trimmed)
768 || is_numbered_list_item(trimmed)
769 || is_definition_list_item(trimmed)
770 || trimmed.starts_with(":::")
771}
772
773fn is_block_boundary(trimmed: &str) -> bool {
776 is_block_boundary_core(trimmed) || trimmed.starts_with('|')
777}
778
779fn is_paragraph_boundary(trimmed: &str, line: &str) -> bool {
783 is_block_boundary_core(trimmed)
784 || calculate_indentation_width_default(line) >= 4
785 || crate::utils::table_utils::TableUtils::is_potential_table_row(line)
786}
787
788fn has_hard_break(line: &str) -> bool {
794 let line = line.strip_suffix('\r').unwrap_or(line);
795 line.ends_with(" ") || line.ends_with('\\')
796}
797
798fn ends_with_sentence_punct(text: &str) -> bool {
800 text.ends_with('.') || text.ends_with('!') || text.ends_with('?')
801}
802
803fn trim_preserving_hard_break(s: &str) -> String {
809 let s = s.strip_suffix('\r').unwrap_or(s);
811
812 if s.ends_with('\\') {
814 return s.to_string();
816 }
817
818 if s.ends_with(" ") {
820 let content_end = s.trim_end().len();
822 if content_end == 0 {
823 return String::new();
825 }
826 format!("{} ", &s[..content_end])
828 } else {
829 s.trim_end().to_string()
831 }
832}
833
834fn parse_elements(text: &str, options: &ReflowOptions) -> Vec<Element> {
836 parse_markdown_elements_inner(
837 text,
838 options.attr_lists,
839 options.myst_roles,
840 options.defined_references.as_ref(),
841 )
842}
843
844pub fn reflow_line(line: &str, options: &ReflowOptions) -> Vec<String> {
845 if options.sentence_per_line {
847 let elements = parse_elements(line, options);
848 return merge_block_construct_continuations(reflow_elements_sentence_per_line(
849 &elements,
850 &options.abbreviations,
851 options.require_sentence_capital,
852 ));
853 }
854
855 if options.semantic_line_breaks {
857 let elements = parse_elements(line, options);
858 return merge_block_construct_continuations(reflow_elements_semantic(&elements, options));
859 }
860
861 if options.line_length == 0 || display_len(line, options.length_mode) <= options.line_length {
864 return vec![line.to_string()];
865 }
866
867 let elements = parse_elements(line, options);
869
870 merge_block_construct_continuations(reflow_elements(&elements, options))
872}
873
874#[derive(Debug, Clone)]
876enum Element {
877 Text(String),
879 Link(String),
881 ReferenceLink(String),
883 EmptyReferenceLink(String),
885 ShortcutReference(String),
887 InlineImage(String),
889 ReferenceImage(String),
891 EmptyReferenceImage(String),
893 LinkedImage(String),
895 FootnoteReference(String),
897 Strikethrough {
899 content: String,
900 double: bool,
902 },
903 WikiLink(String),
905 InlineMath(String),
907 DisplayMath(String),
909 EmojiShortcode(String),
911 Autolink(String),
913 HtmlTag(String),
915 HtmlEntity(String),
917 HugoShortcode(String),
919 AttrList(String),
921 MystRole(String),
925 Code { content: String, marker: String },
927 Bold {
929 content: String,
930 underscore: bool,
932 },
933 Italic {
935 content: String,
936 underscore: bool,
938 },
939}
940
941impl std::fmt::Display for Element {
942 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
943 match self {
944 Element::Text(s) => write!(f, "{s}"),
945 Element::Link(s) => write!(f, "{s}"),
946 Element::ReferenceLink(s) => write!(f, "{s}"),
947 Element::EmptyReferenceLink(s) => write!(f, "{s}"),
948 Element::ShortcutReference(s) => write!(f, "{s}"),
949 Element::InlineImage(s) => write!(f, "{s}"),
950 Element::ReferenceImage(s) => write!(f, "{s}"),
951 Element::EmptyReferenceImage(s) => write!(f, "{s}"),
952 Element::LinkedImage(s) => write!(f, "{s}"),
953 Element::FootnoteReference(s) => write!(f, "{s}"),
954 Element::Strikethrough { content, double } => {
955 let marker = if *double { "~~" } else { "~" };
956 write!(f, "{marker}{content}{marker}")
957 }
958 Element::WikiLink(s) => write!(f, "[[{s}]]"),
959 Element::InlineMath(s) => write!(f, "${s}$"),
960 Element::DisplayMath(s) => write!(f, "$${s}$$"),
961 Element::EmojiShortcode(s) => write!(f, ":{s}:"),
962 Element::Autolink(s) => write!(f, "{s}"),
963 Element::HtmlTag(s) => write!(f, "{s}"),
964 Element::HtmlEntity(s) => write!(f, "{s}"),
965 Element::HugoShortcode(s) => write!(f, "{s}"),
966 Element::AttrList(s) => write!(f, "{s}"),
967 Element::MystRole(s) => write!(f, "{s}"),
968 Element::Code { content, marker } => write!(f, "{marker}{content}{marker}"),
969 Element::Bold { content, underscore } => {
970 if *underscore {
971 write!(f, "__{content}__")
972 } else {
973 write!(f, "**{content}**")
974 }
975 }
976 Element::Italic { content, underscore } => {
977 if *underscore {
978 write!(f, "_{content}_")
979 } else {
980 write!(f, "*{content}*")
981 }
982 }
983 }
984 }
985}
986
987impl Element {
988 fn display_len(&self, mode: ReflowLengthMode) -> usize {
989 match self {
990 Element::Text(s)
991 | Element::Link(s)
992 | Element::ReferenceLink(s)
993 | Element::EmptyReferenceLink(s)
994 | Element::ShortcutReference(s)
995 | Element::InlineImage(s)
996 | Element::ReferenceImage(s)
997 | Element::EmptyReferenceImage(s)
998 | Element::LinkedImage(s)
999 | Element::FootnoteReference(s)
1000 | Element::Autolink(s)
1001 | Element::HtmlTag(s)
1002 | Element::HtmlEntity(s)
1003 | Element::HugoShortcode(s)
1004 | Element::AttrList(s)
1005 | Element::MystRole(s) => display_len(s, mode),
1006 Element::WikiLink(s) => display_len(s, mode) + 4,
1007 Element::InlineMath(s) => display_len(s, mode) + 2,
1008 Element::DisplayMath(s) => display_len(s, mode) + 4,
1009 Element::EmojiShortcode(s) => display_len(s, mode) + 2,
1010 Element::Strikethrough { content, double } => display_len(content, mode) + if *double { 4 } else { 2 },
1011 Element::Code { content, marker } => display_len(content, mode) + display_len(marker, mode) * 2,
1012 Element::Bold { content, .. } => display_len(content, mode) + 4,
1013 Element::Italic { content, .. } => display_len(content, mode) + 2,
1014 }
1015 }
1016}
1017
1018#[derive(Debug, Clone)]
1020struct EmphasisSpan {
1021 start: usize,
1023 end: usize,
1025 content: String,
1027 is_strong: bool,
1029 is_strikethrough: bool,
1031 uses_underscore: bool,
1033 strikethrough_double: bool,
1036}
1037
1038fn extract_emphasis_and_code_spans(text: &str) -> (Vec<EmphasisSpan>, Vec<CodeSpan>) {
1048 let has_emphasis = text.contains(['*', '_', '~']);
1050 let has_code = text.contains('`');
1051 if !has_emphasis && !has_code {
1052 return (Vec::new(), Vec::new());
1053 }
1054
1055 let mut emphasis_spans = Vec::new();
1056 let mut code_spans = Vec::new();
1057
1058 let mut options = Options::empty();
1059 if has_emphasis {
1060 options.insert(Options::ENABLE_STRIKETHROUGH);
1061 }
1062
1063 let mut emphasis_stack: Vec<(usize, bool)> = Vec::new(); let mut strong_stack: Vec<(usize, bool)> = Vec::new();
1066 let mut strikethrough_stack: Vec<usize> = Vec::new();
1067
1068 let parser = Parser::new_ext(text, options).into_offset_iter();
1069
1070 for (event, range) in parser {
1071 match event {
1072 Event::Code(_) => {
1073 code_spans.push(CodeSpan {
1074 start: range.start,
1075 end: range.end,
1076 });
1077 }
1078 Event::Start(Tag::Emphasis) => {
1079 let uses_underscore = text.get(range.start..range.start + 1) == Some("_");
1081 emphasis_stack.push((range.start, uses_underscore));
1082 }
1083 Event::End(TagEnd::Emphasis) => {
1084 if let Some((start_byte, uses_underscore)) = emphasis_stack.pop() {
1085 let content_start = start_byte + 1;
1086 let content_end = range.end - 1;
1087 if content_end > content_start
1088 && let Some(content) = text.get(content_start..content_end)
1089 {
1090 emphasis_spans.push(EmphasisSpan {
1091 start: start_byte,
1092 end: range.end,
1093 content: content.to_string(),
1094 is_strong: false,
1095 is_strikethrough: false,
1096 uses_underscore,
1097 strikethrough_double: false,
1098 });
1099 }
1100 }
1101 }
1102 Event::Start(Tag::Strong) => {
1103 let uses_underscore = text.get(range.start..range.start + 2) == Some("__");
1104 strong_stack.push((range.start, uses_underscore));
1105 }
1106 Event::End(TagEnd::Strong) => {
1107 if let Some((start_byte, uses_underscore)) = strong_stack.pop() {
1108 let content_start = start_byte + 2;
1109 let content_end = range.end - 2;
1110 if content_end > content_start
1111 && let Some(content) = text.get(content_start..content_end)
1112 {
1113 emphasis_spans.push(EmphasisSpan {
1114 start: start_byte,
1115 end: range.end,
1116 content: content.to_string(),
1117 is_strong: true,
1118 is_strikethrough: false,
1119 uses_underscore,
1120 strikethrough_double: false,
1121 });
1122 }
1123 }
1124 }
1125 Event::Start(Tag::Strikethrough) => {
1126 strikethrough_stack.push(range.start);
1127 }
1128 Event::End(TagEnd::Strikethrough) => {
1129 if let Some(start_byte) = strikethrough_stack.pop() {
1130 let double = text.get(start_byte..start_byte + 2) == Some("~~");
1131 let marker_len = if double { 2 } else { 1 };
1132 let content_start = start_byte + marker_len;
1133 let content_end = range.end - marker_len;
1134 if content_end > content_start
1135 && let Some(content) = text.get(content_start..content_end)
1136 {
1137 emphasis_spans.push(EmphasisSpan {
1138 start: start_byte,
1139 end: range.end,
1140 content: content.to_string(),
1141 is_strong: false,
1142 is_strikethrough: true,
1143 uses_underscore: false,
1144 strikethrough_double: double,
1145 });
1146 }
1147 }
1148 }
1149 _ => {}
1150 }
1151 }
1152
1153 emphasis_spans.sort_by_key(|s| s.start);
1154 (emphasis_spans, code_spans)
1155}
1156
1157#[derive(Debug, Clone)]
1158struct CodeSpan {
1159 start: usize,
1160 end: usize,
1161}
1162
1163fn extract_code_spans(text: &str) -> Vec<CodeSpan> {
1164 if !text.contains('`') {
1166 return Vec::new();
1167 }
1168
1169 let mut spans = Vec::new();
1170 let parser = Parser::new(text).into_offset_iter();
1171 for (event, range) in parser {
1172 if let Event::Code(_) = event {
1173 spans.push(CodeSpan {
1174 start: range.start,
1175 end: range.end,
1176 });
1177 }
1178 }
1179 spans
1180}
1181
1182#[derive(Debug, Clone)]
1183struct LinkSpan {
1184 start: usize,
1185 end: usize,
1186 link_type: Option<LinkType>,
1187 is_image: bool,
1188 is_footnote: bool,
1189}
1190
1191fn extract_link_spans(text: &str, defined_references: Option<&HashSet<String>>) -> Vec<LinkSpan> {
1192 if !text.contains('[') {
1195 return Vec::new();
1196 }
1197
1198 let mut spans = Vec::new();
1199 let mut options = Options::empty();
1200 options.insert(Options::ENABLE_FOOTNOTES);
1201
1202 let resolve = move |link: BrokenLink<'_>| -> Option<(CowStr<'_>, CowStr<'_>)> {
1219 let atomic = match link.link_type {
1224 LinkType::Shortcut | LinkType::ShortcutUnknown => match defined_references {
1225 Some(defs) => defs.contains(&normalize_reference_label(link.reference.as_ref())),
1226 None => true,
1227 },
1228 _ => true,
1229 };
1230 atomic.then_some((CowStr::Borrowed(""), CowStr::Borrowed("")))
1231 };
1232 let parser = Parser::new_with_broken_link_callback(text, options, Some(resolve)).into_offset_iter();
1233 let mut stack = Vec::new();
1234
1235 for (event, range) in parser {
1236 match event {
1237 Event::Start(Tag::Link { link_type, .. }) => {
1238 stack.push((range.start, Some(link_type), false));
1239 }
1240 Event::Start(Tag::Image { link_type, .. }) => {
1241 stack.push((range.start, Some(link_type), true));
1242 }
1243 Event::End(TagEnd::Link) => {
1244 if let Some((start_byte, link_type, is_image)) = stack.pop()
1245 && stack.is_empty()
1246 {
1247 spans.push(LinkSpan {
1248 start: start_byte,
1249 end: range.end,
1250 link_type,
1251 is_image,
1252 is_footnote: false,
1253 });
1254 }
1255 }
1256 Event::End(TagEnd::Image) => {
1257 if let Some((start_byte, link_type, is_image)) = stack.pop()
1258 && stack.is_empty()
1259 {
1260 spans.push(LinkSpan {
1261 start: start_byte,
1262 end: range.end,
1263 link_type,
1264 is_image,
1265 is_footnote: false,
1266 });
1267 }
1268 }
1269 Event::FootnoteReference(_) if stack.is_empty() => {
1270 spans.push(LinkSpan {
1271 start: range.start,
1272 end: range.end,
1273 link_type: None,
1274 is_image: false,
1275 is_footnote: true,
1276 });
1277 }
1278 _ => {}
1279 }
1280 }
1281
1282 spans.sort_by_key(|s| s.start);
1283 spans
1284}
1285
1286fn myst_role_len_at(text: &str, absolute_pos: usize, code_spans: &[CodeSpan]) -> Option<usize> {
1294 let bytes = text.as_bytes();
1295 if bytes.first() != Some(&b'{') {
1296 return None;
1297 }
1298
1299 let mut j = 1;
1301 match bytes.get(j) {
1302 Some(&b) if b.is_ascii_alphabetic() || b == b'_' => {}
1303 _ => return None,
1304 }
1305 while let Some(&b) = bytes.get(j) {
1306 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b':' | b'.') {
1307 j += 1;
1308 } else {
1309 break;
1310 }
1311 }
1312 if bytes.get(j) != Some(&b'}') {
1313 return None;
1314 }
1315 j += 1; let code_span_start = absolute_pos + j;
1319 if let Ok(idx) = code_spans.binary_search_by_key(&code_span_start, |span| span.start) {
1320 let span = &code_spans[idx];
1321 let code_span_len = span.end - span.start;
1322 return Some(j + code_span_len);
1323 }
1324
1325 None
1326}
1327
1328fn inline_math_len_at_start(s: &str) -> Option<usize> {
1335 let bytes = s.as_bytes();
1336 if bytes.first() != Some(&b'$') || bytes.get(1) == Some(&b'$') {
1338 return None;
1339 }
1340 let close = 1 + s[1..].find('$')?;
1343 if bytes.get(close + 1) == Some(&b'$') {
1345 return None;
1346 }
1347 Some(close + 1)
1348}
1349
1350#[derive(Clone, Copy, Debug)]
1352struct PatternMatch {
1353 start: usize,
1354 end: usize,
1355}
1356
1357#[derive(Clone, Copy)]
1371enum PatternCache {
1372 Unsearched,
1373 NotFound,
1374 Found(PatternMatch),
1375}
1376
1377impl PatternCache {
1378 fn earliest_in(
1382 &mut self,
1383 remaining: &str,
1384 cursor: usize,
1385 find: impl FnOnce(&str) -> Option<(usize, usize)>,
1386 ) -> Option<(usize, usize)> {
1387 let stale = match self {
1388 PatternCache::Found(pm) => pm.start < cursor,
1389 PatternCache::NotFound => false,
1390 PatternCache::Unsearched => true,
1391 };
1392 if stale {
1393 *self = match find(remaining) {
1394 Some((start, end)) => PatternCache::Found(PatternMatch {
1395 start: cursor + start,
1396 end: cursor + end,
1397 }),
1398 None => PatternCache::NotFound,
1399 };
1400 }
1401 match self {
1402 PatternCache::Found(pm) => Some((pm.start - cursor, pm.end - cursor)),
1403 _ => None,
1404 }
1405 }
1406}
1407
1408fn parse_markdown_elements_inner(
1419 text: &str,
1420 attr_lists: bool,
1421 myst_roles: bool,
1422 defined_references: Option<&HashSet<String>>,
1423) -> Vec<Element> {
1424 let mut elements = Vec::new();
1425 let mut remaining = text;
1426
1427 let (emphasis_spans, code_spans) = extract_emphasis_and_code_spans(text);
1432 let link_spans = extract_link_spans(text, defined_references);
1433
1434 let mut cached_wiki_link = PatternCache::Unsearched;
1437 let mut cached_display_math = PatternCache::Unsearched;
1438 let mut cached_inline_math = PatternCache::Unsearched;
1439 let mut cached_emoji = PatternCache::Unsearched;
1440 let mut cached_html_entity = PatternCache::Unsearched;
1441 let mut cached_hugo_shortcode = PatternCache::Unsearched;
1442 let mut cached_html_tag = PatternCache::Unsearched;
1443 let mut cached_next_curly = PatternCache::Unsearched;
1444
1445 let mut link_span_idx = 0usize;
1449 let mut emphasis_span_idx = 0usize;
1450 let mut code_span_idx = 0usize;
1451
1452 while !remaining.is_empty() {
1453 let current_offset = text.len() - remaining.len();
1455 let mut earliest_match: Option<(usize, usize, &str)> = None;
1458
1459 while link_span_idx < link_spans.len() && link_spans[link_span_idx].start < current_offset {
1461 link_span_idx += 1;
1462 }
1463 let next_link: Option<&LinkSpan> = link_spans.get(link_span_idx);
1464
1465 if let Some(span) = next_link {
1466 let pos_in_remaining = span.start - current_offset;
1467 if earliest_match
1468 .as_ref()
1469 .is_none_or(|(start, _, _)| pos_in_remaining < *start)
1470 {
1471 let match_end = span.end - current_offset;
1472 earliest_match = Some((pos_in_remaining, match_end, "link_span"));
1473 }
1474 }
1475
1476 if let Some((start, end)) = cached_wiki_link.earliest_in(remaining, current_offset, |suffix| {
1478 WIKI_LINK_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1479 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1480 {
1481 earliest_match = Some((start, end, "wiki_link"));
1482 }
1483
1484 if let Some((start, end)) = cached_display_math.earliest_in(remaining, current_offset, |suffix| {
1486 DISPLAY_MATH_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1487 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1488 {
1489 earliest_match = Some((start, end, "display_math"));
1490 }
1491
1492 let inline_math_probe = if current_offset > 0 && text.as_bytes()[current_offset - 1] == b'$' {
1506 inline_math_len_at_start(remaining).map(|len| (0, len))
1507 } else {
1508 None
1509 };
1510 if let Some((start, end)) = inline_math_probe.or_else(|| {
1511 cached_inline_math.earliest_in(remaining, current_offset, |suffix| {
1512 INLINE_MATH_REGEX
1513 .find(suffix)
1514 .ok()
1515 .flatten()
1516 .map(|m| (m.start(), m.end()))
1517 })
1518 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1519 {
1520 earliest_match = Some((start, end, "inline_math"));
1521 }
1522
1523 if let Some((start, end)) = cached_emoji.earliest_in(remaining, current_offset, |suffix| {
1525 EMOJI_SHORTCODE_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1526 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1527 {
1528 earliest_match = Some((start, end, "emoji"));
1529 }
1530
1531 if let Some((start, end)) = cached_html_entity.earliest_in(remaining, current_offset, |suffix| {
1533 HTML_ENTITY_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1534 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1535 {
1536 earliest_match = Some((start, end, "html_entity"));
1537 }
1538
1539 if let Some((start, end)) = cached_hugo_shortcode.earliest_in(remaining, current_offset, |suffix| {
1542 HUGO_SHORTCODE_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1543 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1544 {
1545 earliest_match = Some((start, end, "hugo_shortcode"));
1546 }
1547
1548 if let Some((start, end)) = cached_html_tag.earliest_in(remaining, current_offset, |suffix| {
1555 let mut from = 0;
1556 while let Some(m) = HTML_TAG_PATTERN.find(&suffix[from..]) {
1557 let (tag_start, tag_end) = (from + m.start(), from + m.end());
1558 let tag = &suffix[tag_start..tag_end];
1559 let is_url_autolink = tag.starts_with("<http://")
1561 || tag.starts_with("<https://")
1562 || tag.starts_with("<mailto:")
1563 || tag.starts_with("<ftp://")
1564 || tag.starts_with("<ftps://");
1565 let is_email_autolink = {
1568 let content = tag.trim_start_matches('<').trim_end_matches('>');
1569 EMAIL_PATTERN.is_match(content)
1570 };
1571 if is_url_autolink || is_email_autolink {
1572 from = tag_end;
1573 } else {
1574 return Some((tag_start, tag_end));
1575 }
1576 }
1577 None
1578 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1579 {
1580 earliest_match = Some((start, end, "html_tag"));
1581 }
1582
1583 let mut next_special = remaining.len();
1585 let mut special_type = "";
1586 let mut pulldown_emphasis: Option<&EmphasisSpan> = None;
1587 let mut attr_list_len: usize = 0;
1588 let mut myst_role_len: usize = 0;
1589
1590 while code_span_idx < code_spans.len() && code_spans[code_span_idx].start < current_offset {
1592 code_span_idx += 1;
1593 }
1594 let next_code_span: Option<&CodeSpan> = code_spans.get(code_span_idx);
1595 if let Some(span) = next_code_span {
1596 let pos_in_remaining = span.start - current_offset;
1597 if pos_in_remaining < next_special {
1598 next_special = pos_in_remaining;
1599 special_type = "pulldown_code";
1600 }
1601 }
1602
1603 let next_curly_pos = cached_next_curly
1606 .earliest_in(remaining, current_offset, |suffix| {
1607 suffix.find('{').map(|pos| (pos, pos + 1))
1608 })
1609 .map(|(start, _)| start);
1610
1611 if myst_roles
1616 && let Some(pos) = next_curly_pos
1617 && pos < next_special
1618 && let Some(role_len) = myst_role_len_at(&remaining[pos..], current_offset + pos, &code_spans)
1619 {
1620 next_special = pos;
1621 special_type = "myst_role";
1622 myst_role_len = role_len;
1623 }
1624
1625 if attr_lists
1627 && let Some(pos) = next_curly_pos
1628 && pos < next_special
1629 && let Some(m) = ATTR_LIST_PATTERN.find(&remaining[pos..])
1630 && m.start() == 0
1631 {
1632 next_special = pos;
1633 special_type = "attr_list";
1634 attr_list_len = m.end();
1635 }
1636
1637 while emphasis_span_idx < emphasis_spans.len() && emphasis_spans[emphasis_span_idx].start < current_offset {
1639 emphasis_span_idx += 1;
1640 }
1641 if let Some(span) = emphasis_spans.get(emphasis_span_idx) {
1642 let pos_in_remaining = span.start - current_offset;
1643 if pos_in_remaining < next_special {
1644 next_special = pos_in_remaining;
1645 special_type = "pulldown_emphasis";
1646 pulldown_emphasis = Some(span);
1647 }
1648 }
1649
1650 let should_process_markdown_link = if let Some((pos, _, _)) = earliest_match {
1652 pos < next_special
1653 } else {
1654 false
1655 };
1656
1657 if should_process_markdown_link {
1658 let (pos, match_end, pattern_type) = earliest_match.unwrap();
1659
1660 if pos > 0 {
1662 elements.push(Element::Text(remaining[..pos].to_string()));
1663 }
1664
1665 match pattern_type {
1667 "link_span" => {
1668 let span = next_link.unwrap();
1669 let raw_text = remaining[pos..match_end].to_string();
1670 if span.is_footnote {
1671 elements.push(Element::FootnoteReference(raw_text));
1672 } else if span.is_image {
1673 match span.link_type {
1674 Some(LinkType::Inline) => elements.push(Element::InlineImage(raw_text)),
1675 Some(LinkType::Reference)
1678 | Some(LinkType::ReferenceUnknown)
1679 | Some(LinkType::Shortcut)
1680 | Some(LinkType::ShortcutUnknown) => elements.push(Element::ReferenceImage(raw_text)),
1681 Some(LinkType::Collapsed) | Some(LinkType::CollapsedUnknown) => {
1682 elements.push(Element::EmptyReferenceImage(raw_text))
1683 }
1684 _ => elements.push(Element::InlineImage(raw_text)),
1685 }
1686 } else {
1687 match span.link_type {
1688 Some(LinkType::Inline) => {
1689 if raw_text.starts_with('[') && raw_text.contains("![") {
1690 elements.push(Element::LinkedImage(raw_text));
1691 } else {
1692 elements.push(Element::Link(raw_text));
1693 }
1694 }
1695 Some(LinkType::Reference) | Some(LinkType::ReferenceUnknown) => {
1698 elements.push(Element::ReferenceLink(raw_text))
1699 }
1700 Some(LinkType::Collapsed) | Some(LinkType::CollapsedUnknown) => {
1701 elements.push(Element::EmptyReferenceLink(raw_text))
1702 }
1703 Some(LinkType::Shortcut) | Some(LinkType::ShortcutUnknown) => {
1704 elements.push(Element::ShortcutReference(raw_text))
1705 }
1706 Some(LinkType::Autolink) | Some(LinkType::Email) => {
1707 elements.push(Element::Autolink(raw_text))
1708 }
1709 _ => elements.push(Element::Link(raw_text)),
1710 }
1711 }
1712 remaining = &remaining[match_end..];
1713 }
1714 "wiki_link" => {
1715 if let Some(caps) = WIKI_LINK_REGEX.captures(remaining) {
1716 let content = caps.get(1).map_or("", |m| m.as_str());
1717 elements.push(Element::WikiLink(content.to_string()));
1718 remaining = &remaining[match_end..];
1719 } else {
1720 elements.push(Element::Text("[[".to_string()));
1721 remaining = &remaining[2..];
1722 }
1723 }
1724 "display_math" => {
1725 if let Some(caps) = DISPLAY_MATH_REGEX.captures(remaining) {
1726 let math = caps.get(1).map_or("", |m| m.as_str());
1727 elements.push(Element::DisplayMath(math.to_string()));
1728 remaining = &remaining[match_end..];
1729 } else {
1730 elements.push(Element::Text("$$".to_string()));
1731 remaining = &remaining[2..];
1732 }
1733 }
1734 "inline_math" => {
1735 if let Ok(Some(caps)) = INLINE_MATH_REGEX.captures(remaining) {
1736 let math = caps.get(1).map_or("", |m| m.as_str());
1737 elements.push(Element::InlineMath(math.to_string()));
1738 remaining = &remaining[match_end..];
1739 } else {
1740 elements.push(Element::Text("$".to_string()));
1741 remaining = &remaining[1..];
1742 }
1743 }
1744 "emoji" => {
1745 if let Some(caps) = EMOJI_SHORTCODE_REGEX.captures(remaining) {
1746 let emoji = caps.get(1).map_or("", |m| m.as_str());
1747 elements.push(Element::EmojiShortcode(emoji.to_string()));
1748 remaining = &remaining[match_end..];
1749 } else {
1750 elements.push(Element::Text(":".to_string()));
1751 remaining = &remaining[1..];
1752 }
1753 }
1754 "html_entity" => {
1755 elements.push(Element::HtmlEntity(remaining[pos..match_end].to_string()));
1757 remaining = &remaining[match_end..];
1758 }
1759 "hugo_shortcode" => {
1760 elements.push(Element::HugoShortcode(remaining[pos..match_end].to_string()));
1762 remaining = &remaining[match_end..];
1763 }
1764 "html_tag" => {
1765 elements.push(Element::HtmlTag(remaining[pos..match_end].to_string()));
1767 remaining = &remaining[match_end..];
1768 }
1769 _ => unreachable!("unknown pattern type: {}", pattern_type),
1770 }
1771 } else {
1772 if next_special > 0 && next_special < remaining.len() {
1776 elements.push(Element::Text(remaining[..next_special].to_string()));
1777 remaining = &remaining[next_special..];
1778 }
1779
1780 match special_type {
1782 "pulldown_code" => {
1783 let span = next_code_span.unwrap();
1784 let span_len = span.end - span.start;
1785 let code_raw = &remaining[..span_len];
1786 if let Some((content, marker)) = decompose_code_span(code_raw) {
1787 elements.push(Element::Code {
1788 content: content.to_string(),
1789 marker: marker.to_string(),
1790 });
1791 } else {
1792 elements.push(Element::Text(code_raw.to_string()));
1793 }
1794 remaining = &remaining[span_len..];
1795 }
1796 "attr_list" => {
1797 elements.push(Element::AttrList(remaining[..attr_list_len].to_string()));
1798 remaining = &remaining[attr_list_len..];
1799 }
1800 "myst_role" => {
1801 elements.push(Element::MystRole(remaining[..myst_role_len].to_string()));
1802 remaining = &remaining[myst_role_len..];
1803 }
1804 "pulldown_emphasis" => {
1805 let span = pulldown_emphasis.expect("pulldown_emphasis must be set");
1807 let span_len = span.end - span.start;
1808 if span.is_strikethrough {
1809 elements.push(Element::Strikethrough {
1810 content: span.content.clone(),
1811 double: span.strikethrough_double,
1812 });
1813 } else if span.is_strong {
1814 elements.push(Element::Bold {
1815 content: span.content.clone(),
1816 underscore: span.uses_underscore,
1817 });
1818 } else {
1819 elements.push(Element::Italic {
1820 content: span.content.clone(),
1821 underscore: span.uses_underscore,
1822 });
1823 }
1824 remaining = &remaining[span_len..];
1825 }
1826 _ => {
1827 elements.push(Element::Text(remaining.to_string()));
1829 break;
1830 }
1831 }
1832 }
1833 }
1834
1835 let mut merged_elements = Vec::new();
1837 for el in elements {
1838 match el {
1839 Element::Text(s) => {
1840 if let Some(Element::Text(last_s)) = merged_elements.last_mut() {
1841 last_s.push_str(&s);
1842 } else {
1843 merged_elements.push(Element::Text(s));
1844 }
1845 }
1846 other => merged_elements.push(other),
1847 }
1848 }
1849 merged_elements
1850}
1851
1852fn should_insert_space_before_join(current: &str) -> bool {
1853 !current.is_empty()
1854 && !current.ends_with(' ')
1855 && !current.ends_with('(')
1856 && !current.ends_with('[')
1857 && !current.ends_with('-')
1858}
1859
1860fn is_setext_or_thematic(text: &str) -> bool {
1866 let mut marker = 0u8;
1867 let mut count = 0usize;
1868 let mut has_space = false;
1869 for &b in text.as_bytes() {
1870 match b {
1871 b' ' | b'\t' => has_space = true,
1872 b'-' | b'=' | b'*' | b'_' => {
1873 if marker == 0 {
1874 marker = b;
1875 } else if b != marker {
1876 return false;
1877 }
1878 count += 1;
1879 }
1880 _ => return false,
1881 }
1882 }
1883 match marker {
1884 b'=' => !has_space,
1885 b'-' => !has_space || count >= 3,
1886 b'*' | b'_' => count >= 3,
1887 _ => false,
1888 }
1889}
1890
1891fn starts_block_construct(text: &str) -> bool {
1903 let text = text.trim_start();
1904 let bytes = text.as_bytes();
1905 let Some(&first) = bytes.first() else {
1906 return false;
1907 };
1908 let marker_then_boundary = |len: usize| bytes.len() == len || bytes[len] == b' ' || bytes[len] == b'\t';
1909 match first {
1910 b'>' => true,
1912 b'-' | b'*' | b'+' => marker_then_boundary(1) || is_setext_or_thematic(text),
1913 b'_' | b'=' => is_setext_or_thematic(text),
1914 b':' => is_definition_list_item(text) || text.starts_with(":::"),
1915 b'|' => true,
1916 b'#' => {
1917 let hashes = bytes.iter().take_while(|&&b| b == b'#').count();
1918 hashes <= 6 && marker_then_boundary(hashes)
1919 }
1920 b'`' => bytes.iter().take_while(|&&b| b == b'`').count() >= 3,
1921 b'~' => bytes.iter().take_while(|&&b| b == b'~').count() >= 3,
1922 b'0'..=b'9' => {
1929 let digits = bytes.iter().take_while(|b| b.is_ascii_digit()).count();
1930 digits <= 9
1931 && text[..digits].trim_start_matches('0') == "1"
1932 && bytes.len() > digits + 1
1933 && (bytes[digits] == b'.' || bytes[digits] == b')')
1934 && (bytes[digits + 1] == b' ' || bytes[digits + 1] == b'\t')
1935 }
1936 b'[' => {
1944 let mut escaped = false;
1945 let mut label_close = None;
1946 for (i, &b) in bytes.iter().enumerate().skip(1) {
1947 if escaped {
1948 escaped = false;
1949 } else if b == b'\\' {
1950 escaped = true;
1951 } else if b == b']' {
1952 label_close = Some(i);
1953 break;
1954 }
1955 }
1956 label_close.is_some_and(|i| bytes.get(i + 1) == Some(&b':'))
1957 }
1958 b'<' => crate::utils::html_block::parse_html_block_start(text).is_some(),
1961 _ => false,
1962 }
1963}
1964
1965fn merge_block_construct_continuations(lines: Vec<String>) -> Vec<String> {
1974 let mut merged: Vec<String> = Vec::with_capacity(lines.len());
1975 for line in lines {
1976 merged.push(line);
1977 while merged.len() > 1 && starts_block_construct(merged.last().expect("non-empty")) {
1981 let last = merged.pop().expect("non-empty");
1982 let prev = merged.last_mut().expect("len > 1");
1983 prev.push(' ');
1984 prev.push_str(last.trim_start());
1985 }
1986 }
1987 merged
1988}
1989
1990fn reflow_elements_sentence_per_line(
1992 elements: &[Element],
1993 custom_abbreviations: &Option<Vec<String>>,
1994 require_sentence_capital: bool,
1995) -> Vec<String> {
1996 let abbreviations = get_abbreviations(custom_abbreviations);
1997 let mut lines = Vec::new();
1998 let mut current_line = String::new();
1999
2000 for (idx, element) in elements.iter().enumerate() {
2001 if let Element::Text(text) = element {
2003 let combined = format!("{current_line}{text}");
2005 let sentences = split_into_sentences_with_set(&combined, &abbreviations, require_sentence_capital);
2007
2008 if sentences.len() > 1 {
2009 for (i, sentence) in sentences.iter().enumerate() {
2011 if i == 0 {
2012 let trimmed = sentence.trim();
2015
2016 if text_ends_with_abbreviation(trimmed, &abbreviations) {
2017 current_line.clone_from(sentence);
2019 } else {
2020 lines.push(sentence.clone());
2022 current_line.clear();
2023 }
2024 } else if i == sentences.len() - 1 {
2025 let trimmed = sentence.trim();
2027 let ends_with_sentence_punct = ends_with_sentence_punct(trimmed);
2028
2029 if ends_with_sentence_punct && !text_ends_with_abbreviation(trimmed, &abbreviations) {
2030 lines.push(sentence.clone());
2032 current_line.clear();
2033 } else {
2034 current_line.clone_from(sentence);
2036 }
2037 } else {
2038 lines.push(sentence.clone());
2040 }
2041 }
2042 } else {
2043 let trimmed = combined.trim();
2045
2046 if trimmed.is_empty() {
2050 continue;
2051 }
2052
2053 let ends_with_sentence_punct = ends_with_sentence_punct(trimmed);
2054
2055 if ends_with_sentence_punct && !text_ends_with_abbreviation(trimmed, &abbreviations) {
2056 lines.push(combined.trim_matches(is_breakable_whitespace).to_string());
2059 current_line.clear();
2060 } else {
2061 current_line = combined;
2063 }
2064 }
2065 } else if let Element::Italic { content, underscore } = element {
2066 let marker = if *underscore { "_" } else { "*" };
2068 handle_emphasis_sentence_split(
2069 content,
2070 marker,
2071 &abbreviations,
2072 require_sentence_capital,
2073 &mut current_line,
2074 &mut lines,
2075 );
2076 } else if let Element::Bold { content, underscore } = element {
2077 let marker = if *underscore { "__" } else { "**" };
2079 handle_emphasis_sentence_split(
2080 content,
2081 marker,
2082 &abbreviations,
2083 require_sentence_capital,
2084 &mut current_line,
2085 &mut lines,
2086 );
2087 } else if let Element::Strikethrough { content, double } = element {
2088 handle_emphasis_sentence_split(
2090 content,
2091 if *double { "~~" } else { "~" },
2092 &abbreviations,
2093 require_sentence_capital,
2094 &mut current_line,
2095 &mut lines,
2096 );
2097 } else {
2098 let element_str = format!("{element}");
2100 let is_adjacent = if idx > 0 {
2104 match &elements[idx - 1] {
2105 Element::Text(t) => !t.is_empty() && !t.ends_with(is_breakable_whitespace),
2106 _ => true,
2107 }
2108 } else {
2109 false
2110 };
2111
2112 if !is_adjacent && should_insert_space_before_join(¤t_line) {
2114 current_line.push(' ');
2115 }
2116 current_line.push_str(&element_str);
2117 }
2118 }
2119
2120 if !current_line.is_empty() {
2122 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
2123 }
2124 lines
2125}
2126
2127fn handle_emphasis_sentence_split(
2129 content: &str,
2130 marker: &str,
2131 abbreviations: &HashSet<String>,
2132 require_sentence_capital: bool,
2133 current_line: &mut String,
2134 lines: &mut Vec<String>,
2135) {
2136 let sentences = split_into_sentences_with_set(content, abbreviations, require_sentence_capital);
2138
2139 if sentences.len() <= 1 {
2140 if should_insert_space_before_join(current_line) {
2142 current_line.push(' ');
2143 }
2144 current_line.push_str(marker);
2145 current_line.push_str(content);
2146 current_line.push_str(marker);
2147
2148 let trimmed = content.trim();
2150 let ends_with_punct = ends_with_sentence_punct(trimmed);
2151 if ends_with_punct && !text_ends_with_abbreviation(trimmed, abbreviations) {
2152 lines.push(current_line.clone());
2153 current_line.clear();
2154 }
2155 } else {
2156 for (i, sentence) in sentences.iter().enumerate() {
2158 let trimmed = sentence.trim();
2159 if trimmed.is_empty() {
2160 continue;
2161 }
2162
2163 if i == 0 {
2164 if should_insert_space_before_join(current_line) {
2166 current_line.push(' ');
2167 }
2168 current_line.push_str(marker);
2169 current_line.push_str(trimmed);
2170 current_line.push_str(marker);
2171
2172 let ends_with_punct = ends_with_sentence_punct(trimmed);
2174 if ends_with_punct && !text_ends_with_abbreviation(trimmed, abbreviations) {
2175 lines.push(current_line.clone());
2176 current_line.clear();
2177 }
2178 } else if i == sentences.len() - 1 {
2179 let ends_with_punct = ends_with_sentence_punct(trimmed);
2181
2182 let mut line = String::new();
2183 line.push_str(marker);
2184 line.push_str(trimmed);
2185 line.push_str(marker);
2186
2187 if ends_with_punct && !text_ends_with_abbreviation(trimmed, abbreviations) {
2188 lines.push(line);
2189 } else {
2190 *current_line = line;
2192 }
2193 } else {
2194 let mut line = String::new();
2196 line.push_str(marker);
2197 line.push_str(trimmed);
2198 line.push_str(marker);
2199 lines.push(line);
2200 }
2201 }
2202 }
2203}
2204
2205const BREAK_WORDS: &[&str] = &[
2209 "and",
2210 "or",
2211 "but",
2212 "nor",
2213 "yet",
2214 "so",
2215 "for",
2216 "which",
2217 "that",
2218 "because",
2219 "when",
2220 "if",
2221 "while",
2222 "where",
2223 "although",
2224 "though",
2225 "unless",
2226 "since",
2227 "after",
2228 "before",
2229 "until",
2230 "as",
2231 "once",
2232 "whether",
2233 "however",
2234 "therefore",
2235 "moreover",
2236 "furthermore",
2237 "nevertheless",
2238 "whereas",
2239];
2240
2241fn is_clause_punctuation(c: char) -> bool {
2243 matches!(c, ',' | ';' | ':' | '\u{2014}') }
2245
2246fn clause_break_allowed_after(chars: &[char], i: usize) -> bool {
2254 if chars[i] == '\u{2014}' {
2255 return true;
2256 }
2257 match chars.get(i + 1) {
2258 None => true,
2259 Some(next) => next.is_whitespace(),
2260 }
2261}
2262
2263fn paren_group_end<'a>(slice: &'a str, element_spans: &[(usize, usize)], offset: usize) -> Option<(usize, &'a str)> {
2277 debug_assert!(slice.starts_with('('));
2278 let mut depth: i32 = 0;
2279 for (local_byte, c) in slice.char_indices() {
2280 let global_byte = offset + local_byte;
2281 if depth > 0 && is_inside_element(global_byte, element_spans) {
2286 continue;
2287 }
2288 match c {
2289 '(' => depth += 1,
2290 ')' => {
2291 depth -= 1;
2292 if depth == 0 {
2293 let end = local_byte + 1;
2294 let inner = &slice[1..local_byte];
2295 return Some((end, inner));
2296 }
2297 }
2298 _ => {}
2299 }
2300 }
2301 None
2302}
2303
2304fn split_at_parenthetical(
2321 text: &str,
2322 line_length: usize,
2323 element_spans: &[(usize, usize)],
2324 length_mode: ReflowLengthMode,
2325) -> Option<(String, String)> {
2326 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
2327
2328 if text.starts_with('(')
2330 && let Some((end_local, inner)) = paren_group_end(text, element_spans, 0)
2331 && inner.contains(' ')
2332 {
2333 let tail = &text[end_local..];
2337 let attached_len = tail
2338 .char_indices()
2339 .take_while(|(_, c)| is_closing_quote(*c) || is_clause_punctuation(*c))
2340 .last()
2341 .map_or(0, |(idx, c)| idx + c.len_utf8());
2342 let first_end = end_local + attached_len;
2343 let rest_start = first_end;
2344 let first = &text[..first_end];
2345 let first_len = display_len(first, length_mode);
2346 if first_len <= line_length {
2349 let rest = text[rest_start..].trim_start();
2350 if !rest.is_empty() {
2351 return Some((first.to_string(), rest.to_string()));
2352 }
2353 }
2354 }
2355
2356 let mut best_open_byte: Option<usize> = None;
2358 let mut pos = 0usize;
2359 while pos < text.len() {
2360 if text.as_bytes()[pos] != b'(' {
2362 let c = text[pos..].chars().next().unwrap();
2363 pos += c.len_utf8();
2364 continue;
2365 }
2366 if is_inside_element(pos, element_spans) {
2368 pos += 1;
2369 continue;
2370 }
2371 if let Some((end_local, inner)) = paren_group_end(&text[pos..], element_spans, pos) {
2372 let first = text[..pos].trim_end();
2373 let first_len = display_len(first, length_mode);
2374 if !first.is_empty()
2375 && first_len >= min_first_len
2376 && first_len <= line_length
2377 && inner.contains(' ')
2378 && best_open_byte.is_none_or(|prev| pos > prev)
2379 {
2380 best_open_byte = Some(pos);
2381 }
2382 pos += end_local;
2383 } else {
2384 pos += 1;
2385 }
2386 }
2387
2388 let open_byte = best_open_byte?;
2389 let first = text[..open_byte].trim_end().to_string();
2390 let rest = text[open_byte..].to_string();
2391 if first.is_empty() || rest.trim().is_empty() {
2392 return None;
2393 }
2394 Some((first, rest))
2395}
2396
2397fn compute_element_spans(elements: &[Element]) -> Vec<(usize, usize)> {
2401 let mut spans = Vec::new();
2402 let mut offset = 0;
2403 for element in elements {
2404 let len = element.display_len(ReflowLengthMode::Bytes);
2405 if !matches!(element, Element::Text(_)) {
2406 spans.push((offset, offset + len));
2407 }
2408 offset += len;
2409 }
2410 spans
2411}
2412
2413fn is_inside_element(pos: usize, spans: &[(usize, usize)]) -> bool {
2415 spans.iter().any(|(start, end)| pos > *start && pos < *end)
2416}
2417
2418const MIN_SPLIT_RATIO: f64 = 0.3;
2421
2422fn split_at_clause_punctuation(
2426 text: &str,
2427 line_length: usize,
2428 element_spans: &[(usize, usize)],
2429 length_mode: ReflowLengthMode,
2430) -> Option<(String, String)> {
2431 let chars: Vec<char> = text.chars().collect();
2432 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
2433
2434 let mut width_acc = 0;
2436 let mut search_end_char = 0;
2437 for (idx, &c) in chars.iter().enumerate() {
2438 let c_width = display_len(&c.to_string(), length_mode);
2439 if width_acc + c_width > line_length {
2440 break;
2441 }
2442 width_acc += c_width;
2443 search_end_char = idx + 1;
2444 }
2445
2446 let mut paren_depth: i32 = 0;
2453 let mut best_pos = None;
2454 for i in (0..search_end_char).rev() {
2455 let byte_start: usize = chars[..i].iter().map(|c| c.len_utf8()).sum();
2457 let byte_after: usize = byte_start + chars[i].len_utf8();
2459
2460 if !is_inside_element(byte_start, element_spans) {
2461 match chars[i] {
2462 ')' => paren_depth += 1,
2463 '(' => paren_depth = paren_depth.saturating_sub(1),
2464 _ => {}
2465 }
2466 }
2467
2468 if paren_depth == 0
2469 && is_clause_punctuation(chars[i])
2470 && clause_break_allowed_after(&chars, i)
2471 && !is_inside_element(byte_after, element_spans)
2472 {
2473 best_pos = Some(i);
2474 break;
2475 }
2476 }
2477
2478 let pos = best_pos?;
2479
2480 let first: String = chars[..=pos].iter().collect();
2482 let first_display_len = display_len(&first, length_mode);
2483 if first_display_len < min_first_len {
2484 return None;
2485 }
2486
2487 let rest: String = chars[pos + 1..].iter().collect();
2489 let rest = rest.trim_start().to_string();
2490
2491 if rest.is_empty() {
2492 return None;
2493 }
2494
2495 Some((first, rest))
2496}
2497
2498fn paren_depth_map(text: &str, element_spans: &[(usize, usize)]) -> Vec<i32> {
2505 let mut map = vec![0i32; text.len()];
2506 let mut depth = 0i32;
2507 for (byte, c) in text.char_indices() {
2508 if !is_inside_element(byte, element_spans) {
2509 match c {
2510 '(' => depth += 1,
2511 ')' => depth = depth.saturating_sub(1),
2512 _ => {}
2513 }
2514 }
2515 let end = (byte + c.len_utf8()).min(map.len());
2517 for slot in &mut map[byte..end] {
2518 *slot = depth;
2519 }
2520 }
2521 map
2522}
2523
2524fn is_standalone_parenthetical(line: &str) -> bool {
2533 let trimmed = line.trim();
2534 if !trimmed.starts_with('(') {
2535 return false;
2536 }
2537 let core = trimmed.trim_end_matches(|c: char| is_clause_punctuation(c));
2539 if !core.ends_with(')') {
2540 return false;
2541 }
2542 let inner = &core[1..core.len() - 1];
2544 if !inner.contains(' ') {
2545 return false;
2546 }
2547 let mut depth = 0i32;
2549 for c in core.chars() {
2550 match c {
2551 '(' => depth += 1,
2552 ')' => depth -= 1,
2553 _ => {}
2554 }
2555 if depth < 0 {
2556 return false;
2557 }
2558 }
2559 depth == 0
2560}
2561
2562fn split_at_break_word(
2566 text: &str,
2567 line_length: usize,
2568 element_spans: &[(usize, usize)],
2569 length_mode: ReflowLengthMode,
2570) -> Option<(String, String)> {
2571 let lower = text.to_lowercase();
2572 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
2573 let mut best_split: Option<(usize, usize)> = None; let depth_map = paren_depth_map(text, element_spans);
2578
2579 for &word in BREAK_WORDS {
2580 let mut search_start = 0;
2581 while let Some(pos) = lower[search_start..].find(word) {
2582 let abs_pos = search_start + pos;
2583
2584 let preceded_by_space = abs_pos == 0 || text.as_bytes().get(abs_pos - 1) == Some(&b' ');
2586 let followed_by_space = text.as_bytes().get(abs_pos + word.len()) == Some(&b' ');
2587
2588 if preceded_by_space && followed_by_space {
2589 let first_part = text[..abs_pos].trim_end();
2591 let first_part_len = display_len(first_part, length_mode);
2592
2593 let inside_paren = depth_map.get(abs_pos).is_some_and(|&d| d > 0);
2595
2596 if first_part_len >= min_first_len
2597 && first_part_len <= line_length
2598 && !is_inside_element(abs_pos, element_spans)
2599 && !inside_paren
2600 {
2601 if best_split.is_none_or(|(prev_pos, _)| abs_pos > prev_pos) {
2603 best_split = Some((abs_pos, word.len()));
2604 }
2605 }
2606 }
2607
2608 search_start = abs_pos + word.len();
2609 }
2610 }
2611
2612 let (byte_start, _word_len) = best_split?;
2613
2614 let first = text[..byte_start].trim_end().to_string();
2615 let rest = text[byte_start..].to_string();
2616
2617 if first.is_empty() || rest.trim().is_empty() {
2618 return None;
2619 }
2620
2621 Some((first, rest))
2622}
2623
2624fn cascade_split_line(text: &str, options: &ReflowOptions) -> Vec<String> {
2635 let line_length = options.line_length;
2636 let length_mode = options.length_mode;
2637 let attr_lists = options.attr_lists;
2638 let myst_roles = options.myst_roles;
2639 let defined_references = options.defined_references.as_ref();
2640 if line_length == 0 || display_len(text, length_mode) <= line_length {
2641 return vec![text.to_string()];
2642 }
2643
2644 let elements = parse_markdown_elements_inner(text, attr_lists, myst_roles, defined_references);
2645 let element_spans = compute_element_spans(&elements);
2646
2647 let rebased_spans = |start: usize| -> Vec<(usize, usize)> {
2651 if start == 0 {
2652 return element_spans.clone();
2653 }
2654 element_spans
2655 .iter()
2656 .filter(|&&(_, end)| end > start)
2657 .map(|&(s, e)| (s.saturating_sub(start), e.saturating_sub(start)))
2658 .collect()
2659 };
2660
2661 let mut result = Vec::new();
2662 let mut start = 0usize;
2663
2664 loop {
2665 let remaining = &text[start..];
2666 if display_len(remaining, length_mode) <= line_length {
2667 result.push(remaining.to_string());
2668 return result;
2669 }
2670
2671 let spans = rebased_spans(start);
2672
2673 let split = split_at_parenthetical(remaining, line_length, &spans, length_mode)
2677 .or_else(|| split_at_clause_punctuation(remaining, line_length, &spans, length_mode))
2678 .or_else(|| split_at_break_word(remaining, line_length, &spans, length_mode));
2679
2680 if let Some((first, rest)) = split {
2681 let consumed = remaining.len().saturating_sub(rest.len());
2682 if consumed == 0 {
2685 break;
2686 }
2687 result.push(first);
2688 start += consumed;
2689 continue;
2690 }
2691
2692 break;
2694 }
2695
2696 let mut fallback_options = options.clone();
2698 fallback_options.break_on_sentences = false;
2699 fallback_options.preserve_breaks = false;
2700 fallback_options.sentence_per_line = false;
2701 fallback_options.semantic_line_breaks = false;
2702 fallback_options.require_sentence_capital = true;
2703 fallback_options.max_list_continuation_indent = None;
2704 fallback_options.defined_references = None;
2705 let remaining = &text[start..];
2706 let tail_elements = if start == 0 {
2707 elements
2708 } else {
2709 parse_markdown_elements_inner(remaining, attr_lists, myst_roles, defined_references)
2710 };
2711 result.extend(reflow_elements(&tail_elements, &fallback_options));
2712 result
2713}
2714
2715fn reflow_elements_semantic(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
2719 let sentence_lines =
2721 reflow_elements_sentence_per_line(elements, &options.abbreviations, options.require_sentence_capital);
2722
2723 if options.line_length == 0 {
2726 return sentence_lines;
2727 }
2728
2729 let length_mode = options.length_mode;
2730 let mut result = Vec::new();
2731 for line in sentence_lines {
2732 if display_len(&line, length_mode) <= options.line_length {
2733 result.push(line);
2734 } else {
2735 result.extend(cascade_split_line(&line, options));
2736 }
2737 }
2738
2739 let min_line_len = ((options.line_length as f64) * MIN_SPLIT_RATIO) as usize;
2742 let mut merged: Vec<String> = Vec::with_capacity(result.len());
2743 for line in result {
2744 if !merged.is_empty() && display_len(&line, length_mode) < min_line_len && !line.trim().is_empty() {
2745 if is_standalone_parenthetical(&line) {
2748 merged.push(line);
2749 continue;
2750 }
2751
2752 let prev_ends_at_sentence = {
2754 let trimmed = merged.last().unwrap().trim_end();
2755 trimmed
2756 .chars()
2757 .rev()
2758 .find(|c| !matches!(c, '"' | '\'' | '\u{201D}' | '\u{2019}' | ')' | ']'))
2759 .is_some_and(|c| matches!(c, '.' | '!' | '?'))
2760 };
2761
2762 if !prev_ends_at_sentence {
2763 let prev = merged.last_mut().unwrap();
2764 let combined = format!("{prev} {line}");
2765 if display_len(&combined, length_mode) <= options.line_length {
2767 *prev = combined;
2768 continue;
2769 }
2770 }
2771 }
2772 merged.push(line);
2773 }
2774 merged
2775}
2776
2777fn rfind_safe_space(line: &str, element_spans: &[(usize, usize)]) -> Option<usize> {
2787 line.char_indices().rev().map(|(pos, _)| pos).find(|&pos| {
2788 line.as_bytes()[pos] == b' '
2789 && !element_spans.iter().any(|(s, e)| pos > *s && pos < *e)
2790 && !starts_block_construct(&line[pos + 1..])
2791 })
2792}
2793
2794fn break_before_attached(
2801 lines: &mut Vec<String>,
2802 current_line: &mut String,
2803 current_length: &mut usize,
2804 element_spans: &mut Vec<(usize, usize)>,
2805 attach: &str,
2806 separator: &str,
2807 length_mode: ReflowLengthMode,
2808) -> Option<usize> {
2809 let last_space = rfind_safe_space(current_line, element_spans)?;
2810 let before = current_line[..last_space]
2811 .trim_end_matches(is_breakable_whitespace)
2812 .to_string();
2813 let after = current_line[last_space + 1..].to_string();
2814 lines.push(before);
2815 let carried = after.len();
2816 *current_line = format!("{after}{separator}{attach}");
2817 *current_length = display_len(current_line, length_mode);
2818 element_spans.clear();
2819 Some(carried)
2820}
2821
2822fn reflow_elements(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
2824 let mut lines = Vec::new();
2825 let mut current_line = String::new();
2826 let mut current_length = 0;
2827 let mut current_line_element_spans: Vec<(usize, usize)> = Vec::new();
2829 let length_mode = options.length_mode;
2830
2831 for (idx, element) in elements.iter().enumerate() {
2832 let element_len = element.display_len(length_mode);
2833
2834 let is_adjacent_to_prev = if idx > 0 {
2843 match (&elements[idx - 1], element) {
2844 (Element::Text(t), _) => !t.is_empty() && !t.ends_with(is_breakable_whitespace),
2845 (_, Element::Text(t)) => !t.is_empty() && !t.starts_with(is_breakable_whitespace),
2846 _ => true,
2847 }
2848 } else {
2849 false
2850 };
2851
2852 if let Element::Text(text) = element {
2854 let has_leading_space = text.starts_with(is_breakable_whitespace);
2856 let words: Vec<&str> = split_breakable_words(text).collect();
2858
2859 for (i, word) in words.iter().enumerate() {
2860 let word_len = display_len(word, length_mode);
2861 let is_trailing_punct = word.chars().all(|c| {
2867 matches!(c, ',' | '.' | ':' | ';' | '!' | '?' | ')' | ']' | '}') || is_non_breaking_space(c)
2868 });
2869
2870 let is_first_adjacent = i == 0 && is_adjacent_to_prev;
2873
2874 if is_first_adjacent {
2875 if current_length + word_len > options.line_length
2877 && current_length > 0
2878 && break_before_attached(
2879 &mut lines,
2880 &mut current_line,
2881 &mut current_length,
2882 &mut current_line_element_spans,
2883 word,
2884 "",
2885 length_mode,
2886 )
2887 .is_some()
2888 {
2889 } else {
2894 current_line.push_str(word);
2895 current_length += word_len;
2896 }
2897 } else if current_length > 0 && current_length + 1 + word_len > options.line_length {
2898 if is_trailing_punct {
2899 if break_before_attached(
2906 &mut lines,
2907 &mut current_line,
2908 &mut current_length,
2909 &mut current_line_element_spans,
2910 word,
2911 " ",
2912 length_mode,
2913 )
2914 .is_none()
2915 {
2916 current_line.push(' ');
2917 current_line.push_str(word);
2918 current_length += 1 + word_len;
2919 }
2920 } else if !starts_block_construct(word) {
2921 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
2923 current_line = word.to_string();
2924 current_length = word_len;
2925 current_line_element_spans.clear();
2926 } else if break_before_attached(
2927 &mut lines,
2928 &mut current_line,
2929 &mut current_length,
2930 &mut current_line_element_spans,
2931 word,
2932 " ",
2933 length_mode,
2934 )
2935 .is_some()
2936 {
2937 } else {
2942 if i > 0 || has_leading_space {
2945 current_line.push(' ');
2946 current_length += 1;
2947 }
2948 current_line.push_str(word);
2949 current_length += word_len;
2950 }
2951 } else {
2952 let add_space = current_length > 0 && (i > 0 || has_leading_space);
2964 if add_space {
2965 current_line.push(' ');
2966 current_length += 1;
2967 }
2968 current_line.push_str(word);
2969 current_length += word_len;
2970 }
2971 }
2972 } else {
2973 let span_info = match element {
2974 Element::Italic { content, underscore } => {
2975 Some((content.as_str(), if *underscore { "_" } else { "*" }, false))
2976 }
2977 Element::Bold { content, underscore } => {
2978 Some((content.as_str(), if *underscore { "__" } else { "**" }, false))
2979 }
2980 Element::Strikethrough { content, double } => {
2981 Some((content.as_str(), if *double { "~~" } else { "~" }, false))
2982 }
2983 Element::Code { content, marker } => Some((content.as_str(), marker.as_str(), true)),
2984 _ => None,
2985 };
2986
2987 let breakable: Option<Vec<&str>> = match span_info {
2991 Some((content, _, is_code)) => {
2992 if is_code {
2993 (!options.atomic_spans && code_span_wraps_losslessly(content))
2994 .then(|| split_breakable_words(content).collect())
2995 } else {
2996 (!options.atomic_spans || element_len > options.line_length)
2997 .then(|| breakable_units(content, options.defined_references.as_ref(), options.attr_lists))
2998 .flatten()
2999 }
3000 }
3001 None => None,
3002 };
3003
3004 if let Some(words) = breakable {
3005 let (_, marker, is_code) = span_info.expect("breakable implies a span");
3006 let n = words.len();
3007 if n == 0 {
3008 let full = format!("{marker}{marker}");
3010 let full_len = display_len(&full, length_mode);
3011 if !is_adjacent_to_prev && current_length > 0 {
3012 current_line.push(' ');
3013 current_length += 1;
3014 }
3015 current_line.push_str(&full);
3016 current_length += full_len;
3017 } else {
3018 for (i, word) in words.iter().enumerate() {
3019 let is_first = i == 0;
3020 let is_last = i == n - 1;
3021
3022 let space_start = if is_first && is_code && word.starts_with('`') {
3023 " "
3024 } else {
3025 ""
3026 };
3027 let space_end = if is_last && is_code && word.ends_with('`') {
3028 " "
3029 } else {
3030 ""
3031 };
3032
3033 let word_str: String = match (is_first, is_last) {
3034 (true, true) => format!("{marker}{space_start}{word}{space_end}{marker}"),
3035 (true, false) => format!("{marker}{space_start}{word}"),
3036 (false, true) => format!("{word}{space_end}{marker}"),
3037 (false, false) => word.to_string(),
3038 };
3039 let word_len = display_len(&word_str, length_mode);
3040
3041 let needs_space = if is_first {
3042 !is_adjacent_to_prev && current_length > 0
3043 } else {
3044 current_length > 0
3045 };
3046
3047 if needs_space
3048 && current_length + 1 + word_len > options.line_length
3049 && !starts_block_construct(&word_str)
3050 {
3051 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
3052 current_line = word_str;
3053 current_length = word_len;
3054 current_line_element_spans.clear();
3055 } else {
3056 if needs_space {
3057 current_line.push(' ');
3058 current_length += 1;
3059 }
3060 current_line.push_str(&word_str);
3061 current_length += word_len;
3062 }
3063 }
3064 }
3065 } else {
3066 let element_str = format!("{element}");
3069
3070 if is_adjacent_to_prev {
3071 if current_length + element_len > options.line_length
3073 && let Some(carried) = break_before_attached(
3074 &mut lines,
3075 &mut current_line,
3076 &mut current_length,
3077 &mut current_line_element_spans,
3078 &element_str,
3079 "",
3080 length_mode,
3081 )
3082 {
3083 current_line_element_spans.push((carried, carried + element_str.len()));
3087 } else {
3088 let start = current_line.len();
3089 current_line.push_str(&element_str);
3090 current_length += element_len;
3091 current_line_element_spans.push((start, current_line.len()));
3092 }
3093 } else if current_length > 0 && current_length + 1 + element_len > options.line_length {
3094 if !starts_block_construct(&element_str) {
3095 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
3097 current_line.clone_from(&element_str);
3098 current_length = element_len;
3099 current_line_element_spans.clear();
3100 current_line_element_spans.push((0, element_str.len()));
3101 } else if let Some(carried) = break_before_attached(
3102 &mut lines,
3103 &mut current_line,
3104 &mut current_length,
3105 &mut current_line_element_spans,
3106 &element_str,
3107 " ",
3108 length_mode,
3109 ) {
3110 let start = carried + 1;
3114 current_line_element_spans.push((start, start + element_str.len()));
3115 } else {
3116 let ends_with_opener =
3119 current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
3120 if !ends_with_opener {
3121 current_line.push(' ');
3122 current_length += 1;
3123 }
3124 let start = current_line.len();
3125 current_line.push_str(&element_str);
3126 current_length += element_len;
3127 current_line_element_spans.push((start, current_line.len()));
3128 }
3129 } else {
3130 let ends_with_opener =
3132 current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
3133 if current_length > 0 && !ends_with_opener {
3134 current_line.push(' ');
3135 current_length += 1;
3136 }
3137 let start = current_line.len();
3138 current_line.push_str(&element_str);
3139 current_length += element_len;
3140 current_line_element_spans.push((start, current_line.len()));
3141 }
3142 }
3143 }
3144 }
3145
3146 if !current_line.is_empty() {
3148 lines.push(current_line.trim_end_matches(is_breakable_whitespace).to_string());
3149 }
3150
3151 lines
3152}
3153
3154pub fn reflow_markdown(content: &str, options: &ReflowOptions) -> String {
3156 let lines: Vec<&str> = content.lines().collect();
3157 let mut result = Vec::new();
3158 let mut i = 0;
3159
3160 while i < lines.len() {
3161 let line = lines[i];
3162 let trimmed = line.trim();
3163
3164 if trimmed.is_empty() {
3166 result.push(String::new());
3167 i += 1;
3168 continue;
3169 }
3170
3171 if trimmed.starts_with('#') {
3173 result.push(line.to_string());
3174 i += 1;
3175 continue;
3176 }
3177
3178 if trimmed.starts_with(":::") {
3180 result.push(line.to_string());
3181 i += 1;
3182 continue;
3183 }
3184
3185 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
3187 result.push(line.to_string());
3188 i += 1;
3189 while i < lines.len() {
3191 result.push(lines[i].to_string());
3192 if lines[i].trim().starts_with("```") || lines[i].trim().starts_with("~~~") {
3193 i += 1;
3194 break;
3195 }
3196 i += 1;
3197 }
3198 continue;
3199 }
3200
3201 if calculate_indentation_width_default(line) >= 4 {
3203 result.push(line.to_string());
3205 i += 1;
3206 while i < lines.len() {
3207 let next_line = lines[i];
3208 if calculate_indentation_width_default(next_line) >= 4 || next_line.trim().is_empty() {
3210 result.push(next_line.to_string());
3211 i += 1;
3212 } else {
3213 break;
3214 }
3215 }
3216 continue;
3217 }
3218
3219 if trimmed.starts_with('>') {
3221 let gt_pos = line.find('>').expect("'>' must exist since trimmed.starts_with('>')");
3224 let quote_prefix = line[0..=gt_pos].to_string();
3225 let quote_content = &line[quote_prefix.len()..].trim_start();
3226
3227 let reflowed = reflow_line(quote_content, options);
3228 for reflowed_line in &reflowed {
3229 result.push(format!("{quote_prefix} {reflowed_line}"));
3230 }
3231 i += 1;
3232 continue;
3233 }
3234
3235 if is_horizontal_rule(trimmed) {
3237 result.push(line.to_string());
3238 i += 1;
3239 continue;
3240 }
3241
3242 if is_unordered_list_marker(trimmed) || is_numbered_list_item(trimmed) {
3244 let indent = line.len() - line.trim_start().len();
3246 let indent_str = " ".repeat(indent);
3247
3248 let mut marker_end = indent;
3251 let mut content_start = indent;
3252
3253 if trimmed.chars().next().is_some_and(char::is_numeric) {
3254 if let Some(period_pos) = line[indent..].find('.') {
3256 marker_end = indent + period_pos + 1; content_start = marker_end;
3258 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
3262 content_start += 1;
3263 }
3264 }
3265 } else {
3266 marker_end = indent + 1; content_start = marker_end;
3269 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
3273 content_start += 1;
3274 }
3275 }
3276
3277 let min_continuation_indent = content_start;
3279
3280 let rest = &line[content_start..];
3283 if rest.starts_with("[ ] ") || rest.starts_with("[x] ") || rest.starts_with("[X] ") {
3284 marker_end = content_start + 3; content_start += 4; }
3287
3288 let marker = &line[indent..marker_end];
3289
3290 let mut list_content = vec![trim_preserving_hard_break(&line[content_start..])];
3293 i += 1;
3294
3295 while i < lines.len() {
3299 let next_line = lines[i];
3300 let next_trimmed = next_line.trim();
3301
3302 if is_block_boundary(next_trimmed) {
3304 break;
3305 }
3306
3307 let next_indent = next_line.len() - next_line.trim_start().len();
3309 if next_indent >= min_continuation_indent {
3310 let trimmed_start = next_line.trim_start();
3313 list_content.push(trim_preserving_hard_break(trimmed_start));
3314 i += 1;
3315 } else {
3316 break;
3318 }
3319 }
3320
3321 let combined_content = if options.preserve_breaks {
3324 list_content[0].clone()
3325 } else {
3326 let has_hard_breaks = list_content.iter().any(|line| has_hard_break(line));
3328 if has_hard_breaks {
3329 list_content.join("\n")
3331 } else {
3332 list_content.join(" ")
3334 }
3335 };
3336
3337 let trimmed_marker = marker;
3339 let continuation_spaces = if let Some(max_indent) = options.max_list_continuation_indent {
3340 indent + (content_start - indent).min(max_indent)
3343 } else {
3344 content_start
3345 };
3346
3347 let prefix_length = indent + trimmed_marker.len() + 1;
3349
3350 let adjusted_options = ReflowOptions {
3352 line_length: options.line_length.saturating_sub(prefix_length),
3353 ..options.clone()
3354 };
3355
3356 let reflowed = reflow_line(&combined_content, &adjusted_options);
3357 for (j, reflowed_line) in reflowed.iter().enumerate() {
3358 if j == 0 {
3359 result.push(format!("{indent_str}{trimmed_marker} {reflowed_line}"));
3360 } else {
3361 let continuation_indent = " ".repeat(continuation_spaces);
3363 result.push(format!("{continuation_indent}{reflowed_line}"));
3364 }
3365 }
3366 continue;
3367 }
3368
3369 if crate::utils::table_utils::TableUtils::is_potential_table_row(line) {
3371 result.push(line.to_string());
3372 i += 1;
3373 continue;
3374 }
3375
3376 if trimmed.starts_with('[') && line.contains("]:") {
3378 result.push(line.to_string());
3379 i += 1;
3380 continue;
3381 }
3382
3383 if is_definition_list_item(trimmed) {
3385 result.push(line.to_string());
3386 i += 1;
3387 continue;
3388 }
3389
3390 let mut is_single_line_paragraph = true;
3392 if i + 1 < lines.len() {
3393 let next_trimmed = lines[i + 1].trim();
3394 if !is_block_boundary(next_trimmed) {
3396 is_single_line_paragraph = false;
3397 }
3398 }
3399
3400 if is_single_line_paragraph && display_len(line, options.length_mode) <= options.line_length {
3402 result.push(line.to_string());
3403 i += 1;
3404 continue;
3405 }
3406
3407 let mut paragraph_parts = Vec::new();
3409 let mut current_part = vec![line];
3410 i += 1;
3411
3412 if options.preserve_breaks {
3414 let hard_break_type = if line.strip_suffix('\r').unwrap_or(line).ends_with('\\') {
3416 Some("\\")
3417 } else if line.ends_with(" ") {
3418 Some(" ")
3419 } else {
3420 None
3421 };
3422 let reflowed = reflow_line(line, options);
3423
3424 if let Some(break_marker) = hard_break_type {
3426 if !reflowed.is_empty() {
3427 let mut reflowed_with_break = reflowed;
3428 let last_idx = reflowed_with_break.len() - 1;
3429 if !has_hard_break(&reflowed_with_break[last_idx]) {
3430 reflowed_with_break[last_idx].push_str(break_marker);
3431 }
3432 result.extend(reflowed_with_break);
3433 }
3434 } else {
3435 result.extend(reflowed);
3436 }
3437 } else {
3438 while i < lines.len() {
3440 let prev_line = if !current_part.is_empty() {
3441 current_part.last().unwrap()
3442 } else {
3443 ""
3444 };
3445 let next_line = lines[i];
3446 let next_trimmed = next_line.trim();
3447
3448 if is_block_boundary(next_trimmed) {
3450 break;
3451 }
3452
3453 let prev_trimmed = prev_line.trim();
3456 let abbreviations = get_abbreviations(&options.abbreviations);
3457 let ends_with_sentence = (prev_trimmed.ends_with('.')
3458 || prev_trimmed.ends_with('!')
3459 || prev_trimmed.ends_with('?')
3460 || prev_trimmed.ends_with(".*")
3461 || prev_trimmed.ends_with("!*")
3462 || prev_trimmed.ends_with("?*")
3463 || prev_trimmed.ends_with("._")
3464 || prev_trimmed.ends_with("!_")
3465 || prev_trimmed.ends_with("?_")
3466 || prev_trimmed.ends_with(".\"")
3468 || prev_trimmed.ends_with("!\"")
3469 || prev_trimmed.ends_with("?\"")
3470 || prev_trimmed.ends_with(".'")
3471 || prev_trimmed.ends_with("!'")
3472 || prev_trimmed.ends_with("?'")
3473 || prev_trimmed.ends_with(".\u{201D}")
3474 || prev_trimmed.ends_with("!\u{201D}")
3475 || prev_trimmed.ends_with("?\u{201D}")
3476 || prev_trimmed.ends_with(".\u{2019}")
3477 || prev_trimmed.ends_with("!\u{2019}")
3478 || prev_trimmed.ends_with("?\u{2019}"))
3479 && !text_ends_with_abbreviation(
3480 prev_trimmed.trim_end_matches(['*', '_', '"', '\'', '\u{201D}', '\u{2019}']),
3481 &abbreviations,
3482 );
3483
3484 if has_hard_break(prev_line) || (options.sentence_per_line && ends_with_sentence) {
3485 paragraph_parts.push(current_part.join(" "));
3487 current_part = vec![next_line];
3488 } else {
3489 current_part.push(next_line);
3490 }
3491 i += 1;
3492 }
3493
3494 if !current_part.is_empty() {
3496 if current_part.len() == 1 {
3497 paragraph_parts.push(current_part[0].to_string());
3499 } else {
3500 paragraph_parts.push(current_part.join(" "));
3501 }
3502 }
3503
3504 for (j, part) in paragraph_parts.iter().enumerate() {
3506 let reflowed = reflow_line(part, options);
3507 result.extend(reflowed);
3508
3509 if j < paragraph_parts.len() - 1 && !result.is_empty() && !options.sentence_per_line {
3513 let last_idx = result.len() - 1;
3514 if !has_hard_break(&result[last_idx]) {
3515 result[last_idx].push_str(" ");
3516 }
3517 }
3518 }
3519 }
3520 }
3521
3522 let result_text = result.join("\n");
3524 if content.ends_with('\n') && !result_text.ends_with('\n') {
3525 format!("{result_text}\n")
3526 } else {
3527 result_text
3528 }
3529}
3530
3531#[derive(Debug, Clone)]
3533pub struct ParagraphReflow {
3534 pub start_byte: usize,
3536 pub end_byte: usize,
3538 pub reflowed_text: String,
3540}
3541
3542#[derive(Debug, Clone)]
3548pub struct BlockquoteLineData {
3549 pub(crate) content: String,
3551 pub(crate) is_explicit: bool,
3553 pub(crate) prefix: Option<String>,
3555}
3556
3557impl BlockquoteLineData {
3558 pub fn explicit(content: String, prefix: String) -> Self {
3560 Self {
3561 content,
3562 is_explicit: true,
3563 prefix: Some(prefix),
3564 }
3565 }
3566
3567 pub fn lazy(content: String) -> Self {
3569 Self {
3570 content,
3571 is_explicit: false,
3572 prefix: None,
3573 }
3574 }
3575}
3576
3577#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3579pub enum BlockquoteContinuationStyle {
3580 Explicit,
3581 Lazy,
3582}
3583
3584pub fn blockquote_continuation_style(lines: &[BlockquoteLineData]) -> BlockquoteContinuationStyle {
3592 let mut explicit_count = 0usize;
3593 let mut lazy_count = 0usize;
3594
3595 for line in lines.iter().skip(1) {
3596 if line.is_explicit {
3597 explicit_count += 1;
3598 } else {
3599 lazy_count += 1;
3600 }
3601 }
3602
3603 if explicit_count > 0 && lazy_count == 0 {
3604 BlockquoteContinuationStyle::Explicit
3605 } else if lazy_count > 0 && explicit_count == 0 {
3606 BlockquoteContinuationStyle::Lazy
3607 } else if explicit_count >= lazy_count {
3608 BlockquoteContinuationStyle::Explicit
3609 } else {
3610 BlockquoteContinuationStyle::Lazy
3611 }
3612}
3613
3614pub fn dominant_blockquote_prefix(lines: &[BlockquoteLineData], fallback: &str) -> String {
3619 let mut counts: std::collections::HashMap<String, (usize, usize)> = std::collections::HashMap::new();
3620
3621 for (idx, line) in lines.iter().enumerate() {
3622 let Some(prefix) = line.prefix.as_ref() else {
3623 continue;
3624 };
3625 counts
3626 .entry(prefix.clone())
3627 .and_modify(|entry| entry.0 += 1)
3628 .or_insert((1, idx));
3629 }
3630
3631 counts
3632 .into_iter()
3633 .max_by(|(_, (count_a, first_idx_a)), (_, (count_b, first_idx_b))| {
3634 count_a.cmp(count_b).then_with(|| first_idx_b.cmp(first_idx_a))
3635 })
3636 .map_or_else(|| fallback.to_string(), |(prefix, _)| prefix)
3637}
3638
3639pub(crate) fn should_force_explicit_blockquote_line(content_line: &str) -> bool {
3644 let trimmed = content_line.trim_start();
3645 trimmed.starts_with('>')
3646 || trimmed.starts_with('#')
3647 || trimmed.starts_with("```")
3648 || trimmed.starts_with("~~~")
3649 || is_unordered_list_marker(trimmed)
3650 || is_numbered_list_item(trimmed)
3651 || is_horizontal_rule(trimmed)
3652 || is_definition_list_item(trimmed)
3653 || (trimmed.starts_with('[') && trimmed.contains("]:"))
3654 || trimmed.starts_with(":::")
3655 || (trimmed.starts_with('<')
3656 && !trimmed.starts_with("<http")
3657 && !trimmed.starts_with("<https")
3658 && !trimmed.starts_with("<mailto:"))
3659}
3660
3661pub fn reflow_blockquote_content(
3670 lines: &[BlockquoteLineData],
3671 explicit_prefix: &str,
3672 continuation_style: BlockquoteContinuationStyle,
3673 options: &ReflowOptions,
3674) -> Vec<String> {
3675 let content_strs: Vec<&str> = lines.iter().map(|l| l.content.as_str()).collect();
3676 let segments = split_into_segments_strs(&content_strs);
3677 let mut reflowed_content_lines: Vec<String> = Vec::new();
3678
3679 for segment in segments {
3680 let hard_break_type = segment.last().and_then(|&line| {
3681 let line = line.strip_suffix('\r').unwrap_or(line);
3682 if line.ends_with('\\') {
3683 Some("\\")
3684 } else if line.ends_with(" ") {
3685 Some(" ")
3686 } else {
3687 None
3688 }
3689 });
3690
3691 let pieces: Vec<&str> = segment
3692 .iter()
3693 .map(|&line| {
3694 if let Some(l) = line.strip_suffix('\\') {
3695 l.trim_end()
3696 } else if let Some(l) = line.strip_suffix(" ") {
3697 l.trim_end()
3698 } else {
3699 line.trim_end()
3700 }
3701 })
3702 .collect();
3703
3704 let segment_text = pieces.join(" ");
3705 let segment_text = segment_text.trim();
3706 if segment_text.is_empty() {
3707 continue;
3708 }
3709
3710 let mut reflowed = reflow_line(segment_text, options);
3711 if let Some(break_marker) = hard_break_type
3712 && !reflowed.is_empty()
3713 {
3714 let last_idx = reflowed.len() - 1;
3715 if !has_hard_break(&reflowed[last_idx]) {
3716 reflowed[last_idx].push_str(break_marker);
3717 }
3718 }
3719 reflowed_content_lines.extend(reflowed);
3720 }
3721
3722 let mut styled_lines: Vec<String> = Vec::new();
3723 for (idx, line) in reflowed_content_lines.iter().enumerate() {
3724 let force_explicit = idx == 0
3725 || continuation_style == BlockquoteContinuationStyle::Explicit
3726 || should_force_explicit_blockquote_line(line);
3727 if force_explicit {
3728 styled_lines.push(format!("{explicit_prefix}{line}"));
3729 } else {
3730 styled_lines.push(line.clone());
3731 }
3732 }
3733
3734 styled_lines
3735}
3736
3737fn is_blockquote_content_boundary(content: &str) -> bool {
3738 let trimmed = content.trim();
3739 trimmed.is_empty()
3740 || is_block_boundary(trimmed)
3741 || crate::utils::table_utils::TableUtils::is_potential_table_row(content)
3742 || trimmed.starts_with(":::")
3743 || crate::utils::is_template_directive_only(content)
3744 || is_standalone_attr_list(content)
3745 || is_snippet_block_delimiter(content)
3746}
3747
3748fn split_into_segments_strs<'a>(lines: &[&'a str]) -> Vec<Vec<&'a str>> {
3749 let mut segments = Vec::new();
3750 let mut current = Vec::new();
3751
3752 for &line in lines {
3753 current.push(line);
3754 if has_hard_break(line) {
3755 segments.push(current);
3756 current = Vec::new();
3757 }
3758 }
3759
3760 if !current.is_empty() {
3761 segments.push(current);
3762 }
3763
3764 segments
3765}
3766
3767fn reflow_blockquote_paragraph_at_line(
3768 content: &str,
3769 lines: &[&str],
3770 target_idx: usize,
3771 options: &ReflowOptions,
3772) -> Option<ParagraphReflow> {
3773 let mut anchor_idx = target_idx;
3774 let mut target_level = if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[target_idx]) {
3775 parsed.nesting_level
3776 } else {
3777 let mut found = None;
3778 let mut idx = target_idx;
3779 loop {
3780 if lines[idx].trim().is_empty() {
3781 break;
3782 }
3783 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[idx]) {
3784 found = Some((idx, parsed.nesting_level));
3785 break;
3786 }
3787 if idx == 0 {
3788 break;
3789 }
3790 idx -= 1;
3791 }
3792 let (idx, level) = found?;
3793 anchor_idx = idx;
3794 level
3795 };
3796
3797 let mut para_start = anchor_idx;
3799 while para_start > 0 {
3800 let prev_idx = para_start - 1;
3801 let prev_line = lines[prev_idx];
3802
3803 if prev_line.trim().is_empty() {
3804 break;
3805 }
3806
3807 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(prev_line) {
3808 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
3809 break;
3810 }
3811 para_start = prev_idx;
3812 continue;
3813 }
3814
3815 let prev_lazy = prev_line.trim_start();
3816 if is_blockquote_content_boundary(prev_lazy) {
3817 break;
3818 }
3819 para_start = prev_idx;
3820 }
3821
3822 while para_start < lines.len() {
3824 let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[para_start]) else {
3825 para_start += 1;
3826 continue;
3827 };
3828 target_level = parsed.nesting_level;
3829 break;
3830 }
3831
3832 if para_start >= lines.len() || para_start > target_idx {
3833 return None;
3834 }
3835
3836 let mut collected: Vec<(usize, BlockquoteLineData)> = Vec::new();
3839 let mut idx = para_start;
3840 while idx < lines.len() {
3841 if !collected.is_empty() && has_hard_break(&collected[collected.len() - 1].1.content) {
3842 break;
3843 }
3844
3845 let line = lines[idx];
3846 if line.trim().is_empty() {
3847 break;
3848 }
3849
3850 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(line) {
3851 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
3852 break;
3853 }
3854 collected.push((
3855 idx,
3856 BlockquoteLineData::explicit(trim_preserving_hard_break(parsed.content), parsed.prefix.to_string()),
3857 ));
3858 idx += 1;
3859 continue;
3860 }
3861
3862 let lazy_content = line.trim_start();
3863 if is_blockquote_content_boundary(lazy_content) {
3864 break;
3865 }
3866
3867 collected.push((idx, BlockquoteLineData::lazy(trim_preserving_hard_break(lazy_content))));
3868 idx += 1;
3869 }
3870
3871 if collected.is_empty() {
3872 return None;
3873 }
3874
3875 let para_end = collected[collected.len() - 1].0;
3876 if target_idx < para_start || target_idx > para_end {
3877 return None;
3878 }
3879
3880 let line_data: Vec<BlockquoteLineData> = collected.iter().map(|(_, d)| d.clone()).collect();
3881
3882 let fallback_prefix = line_data
3883 .iter()
3884 .find_map(|d| d.prefix.clone())
3885 .unwrap_or_else(|| "> ".to_string());
3886 let explicit_prefix = dominant_blockquote_prefix(&line_data, &fallback_prefix);
3887 let continuation_style = blockquote_continuation_style(&line_data);
3888
3889 let adjusted_line_length = options
3890 .line_length
3891 .saturating_sub(display_len(&explicit_prefix, options.length_mode))
3892 .max(1);
3893
3894 let adjusted_options = ReflowOptions {
3895 line_length: adjusted_line_length,
3896 ..options.clone()
3897 };
3898
3899 let styled_lines = reflow_blockquote_content(&line_data, &explicit_prefix, continuation_style, &adjusted_options);
3900
3901 if styled_lines.is_empty() {
3902 return None;
3903 }
3904
3905 let mut start_byte = 0;
3907 for line in lines.iter().take(para_start) {
3908 start_byte += line.len() + 1;
3909 }
3910
3911 let mut end_byte = start_byte;
3912 for line in lines.iter().take(para_end + 1).skip(para_start) {
3913 end_byte += line.len() + 1;
3914 }
3915
3916 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
3917 if !includes_trailing_newline {
3918 end_byte -= 1;
3919 }
3920
3921 let reflowed_joined = styled_lines.join("\n");
3922 let reflowed_text = if includes_trailing_newline {
3923 if reflowed_joined.ends_with('\n') {
3924 reflowed_joined
3925 } else {
3926 format!("{reflowed_joined}\n")
3927 }
3928 } else if reflowed_joined.ends_with('\n') {
3929 reflowed_joined.trim_end_matches('\n').to_string()
3930 } else {
3931 reflowed_joined
3932 };
3933
3934 Some(ParagraphReflow {
3935 start_byte,
3936 end_byte,
3937 reflowed_text,
3938 })
3939}
3940
3941pub fn reflow_paragraph_at_line(content: &str, line_number: usize, line_length: usize) -> Option<ParagraphReflow> {
3959 reflow_paragraph_at_line_with_mode(content, line_number, line_length, ReflowLengthMode::default())
3960}
3961
3962pub fn reflow_paragraph_at_line_with_mode(
3964 content: &str,
3965 line_number: usize,
3966 line_length: usize,
3967 length_mode: ReflowLengthMode,
3968) -> Option<ParagraphReflow> {
3969 let options = ReflowOptions {
3970 line_length,
3971 length_mode,
3972 ..Default::default()
3973 };
3974 reflow_paragraph_at_line_with_options(content, line_number, &options)
3975}
3976
3977pub fn reflow_paragraph_at_line_with_options(
3988 content: &str,
3989 line_number: usize,
3990 options: &ReflowOptions,
3991) -> Option<ParagraphReflow> {
3992 if line_number == 0 {
3993 return None;
3994 }
3995
3996 let lines: Vec<&str> = content.lines().collect();
3997
3998 if line_number > lines.len() {
4000 return None;
4001 }
4002
4003 let target_idx = line_number - 1; let target_line = lines[target_idx];
4005 let trimmed = target_line.trim();
4006
4007 if let Some(blockquote_reflow) = reflow_blockquote_paragraph_at_line(content, &lines, target_idx, options) {
4010 return Some(blockquote_reflow);
4011 }
4012
4013 if is_paragraph_boundary(trimmed, target_line) {
4015 return None;
4016 }
4017
4018 let mut para_start = target_idx;
4020 while para_start > 0 {
4021 let prev_idx = para_start - 1;
4022 let prev_line = lines[prev_idx];
4023 let prev_trimmed = prev_line.trim();
4024
4025 if is_paragraph_boundary(prev_trimmed, prev_line) {
4027 break;
4028 }
4029
4030 para_start = prev_idx;
4031 }
4032
4033 let mut para_end = target_idx;
4035 while para_end + 1 < lines.len() {
4036 let next_idx = para_end + 1;
4037 let next_line = lines[next_idx];
4038 let next_trimmed = next_line.trim();
4039
4040 if is_paragraph_boundary(next_trimmed, next_line) {
4042 break;
4043 }
4044
4045 para_end = next_idx;
4046 }
4047
4048 let paragraph_lines = &lines[para_start..=para_end];
4050
4051 let mut start_byte = 0;
4053 for line in lines.iter().take(para_start) {
4054 start_byte += line.len() + 1; }
4056
4057 let mut end_byte = start_byte;
4058 for line in paragraph_lines {
4059 end_byte += line.len() + 1; }
4061
4062 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
4065
4066 if !includes_trailing_newline {
4068 end_byte -= 1;
4069 }
4070
4071 let paragraph_text = paragraph_lines.join("\n");
4073
4074 let reflowed = reflow_markdown(¶graph_text, options);
4076
4077 let reflowed_text = if includes_trailing_newline {
4081 if reflowed.ends_with('\n') {
4083 reflowed
4084 } else {
4085 format!("{reflowed}\n")
4086 }
4087 } else {
4088 if reflowed.ends_with('\n') {
4090 reflowed.trim_end_matches('\n').to_string()
4091 } else {
4092 reflowed
4093 }
4094 };
4095
4096 Some(ParagraphReflow {
4097 start_byte,
4098 end_byte,
4099 reflowed_text,
4100 })
4101}
4102fn decompose_code_span(raw: &str) -> Option<(&str, &str)> {
4108 let marker_len = raw.bytes().take_while(|&b| b == b'`').count();
4109 if marker_len == 0 {
4110 return None;
4111 }
4112 let marker = &raw[..marker_len];
4113 if raw.len() < marker_len * 2 {
4114 return None;
4115 }
4116 let content = &raw[marker_len..raw.len() - marker_len];
4117 Some((content, marker))
4118}
4119
4120#[cfg(test)]
4121mod tests {
4122 use super::*;
4123
4124 #[test]
4125 fn cascade_split_line_handles_a_very_long_line_without_overflowing() {
4126 let words: Vec<String> = (0..4000).map(|i| format!("word{i}")).collect();
4132 let line = words.join(" ");
4133
4134 let options = ReflowOptions {
4135 line_length: 80,
4136 length_mode: ReflowLengthMode::Chars,
4137 ..Default::default()
4138 };
4139 let out = cascade_split_line(&line, &options);
4140
4141 assert!(out.len() > 1, "a very long line should split into many lines");
4142 for segment in &out {
4143 assert!(
4144 display_len(segment, ReflowLengthMode::Chars) <= 80 || !segment.contains(' '),
4145 "each wrapped line should fit the width (or be a single unbreakable token)"
4146 );
4147 }
4148 let rejoined = out.join(" ");
4150 let original_words: Vec<&str> = line.split(' ').collect();
4151 let result_words: Vec<&str> = rejoined.split_whitespace().collect();
4152 assert_eq!(original_words, result_words, "reflow must preserve all words in order");
4153 }
4154
4155 #[test]
4160 fn test_helper_function_text_ends_with_abbreviation() {
4161 let abbreviations = get_abbreviations(&None);
4163
4164 assert!(text_ends_with_abbreviation("Dr.", &abbreviations));
4166 assert!(text_ends_with_abbreviation("word Dr.", &abbreviations));
4167 assert!(text_ends_with_abbreviation("e.g.", &abbreviations));
4168 assert!(text_ends_with_abbreviation("i.e.", &abbreviations));
4169 assert!(text_ends_with_abbreviation("Mr.", &abbreviations));
4170 assert!(text_ends_with_abbreviation("Mrs.", &abbreviations));
4171 assert!(text_ends_with_abbreviation("Ms.", &abbreviations));
4172 assert!(text_ends_with_abbreviation("Prof.", &abbreviations));
4173
4174 assert!(!text_ends_with_abbreviation("etc.", &abbreviations));
4176 assert!(!text_ends_with_abbreviation("paradigms.", &abbreviations));
4177 assert!(!text_ends_with_abbreviation("programs.", &abbreviations));
4178 assert!(!text_ends_with_abbreviation("items.", &abbreviations));
4179 assert!(!text_ends_with_abbreviation("systems.", &abbreviations));
4180 assert!(!text_ends_with_abbreviation("Dr?", &abbreviations)); assert!(!text_ends_with_abbreviation("Mr!", &abbreviations)); assert!(!text_ends_with_abbreviation("paradigms?", &abbreviations)); assert!(!text_ends_with_abbreviation("word", &abbreviations)); assert!(!text_ends_with_abbreviation("", &abbreviations)); }
4186
4187 #[test]
4188 fn test_footnote_after_period_splits_sentence() {
4189 let text = "First sentence.[^1] Second sentence.";
4193 let sentences = split_into_sentences(text);
4194 assert_eq!(
4195 sentences,
4196 vec!["First sentence.[^1]".to_string(), "Second sentence.".to_string()],
4197 "footnote glued to the period should keep the boundary and stay attached to the first sentence"
4198 );
4199 }
4200
4201 #[test]
4202 fn test_multiple_consecutive_footnotes_after_period_splits_sentence() {
4203 let text = "Notes here.[^1][^2] Second sentence.";
4205 let sentences = split_into_sentences(text);
4206 assert_eq!(
4207 sentences,
4208 vec!["Notes here.[^1][^2]".to_string(), "Second sentence.".to_string()]
4209 );
4210 }
4211
4212 #[test]
4213 fn test_footnote_before_period_still_splits_sentence() {
4214 let text = "Annotation here[^1]. Second sentence.";
4218 let sentences = split_into_sentences(text);
4219 assert_eq!(
4220 sentences,
4221 vec!["Annotation here[^1].".to_string(), "Second sentence.".to_string()]
4222 );
4223 }
4224
4225 #[test]
4226 fn test_mid_sentence_footnote_does_not_split() {
4227 let text = "The system word[^1] more words. Next sentence.";
4230 let sentences = split_into_sentences(text);
4231 assert_eq!(
4232 sentences,
4233 vec![
4234 "The system word[^1] more words.".to_string(),
4235 "Next sentence.".to_string()
4236 ]
4237 );
4238 }
4239
4240 #[test]
4241 fn test_bare_numeric_bracket_after_period_does_not_split() {
4242 let text = "Citation here.[1] Second sentence.";
4245 let sentences = split_into_sentences(text);
4246 assert_eq!(
4247 sentences,
4248 vec![text.to_string()],
4249 "a bare numeric bracket must not be treated as a sentence boundary"
4250 );
4251 }
4252
4253 #[test]
4254 fn test_footnote_glued_to_following_word_does_not_split() {
4255 let text = "First sentence.[^1]Continued glued text.";
4258 let sentences = split_into_sentences(text);
4259 assert_eq!(sentences, vec![text.to_string()]);
4260 }
4261
4262 #[test]
4263 fn test_footnote_at_end_of_text_is_preserved() {
4264 let text = "Sentence.[^1]";
4267 let sentences = split_into_sentences(text);
4268 assert_eq!(sentences, vec![text.to_string()]);
4269 }
4270
4271 #[test]
4272 fn test_abbreviation_before_footnote_does_not_split() {
4273 let text = "See the notes, e.g.[^1] this one.";
4276 let sentences = split_into_sentences(text);
4277 assert_eq!(
4278 sentences,
4279 vec![text.to_string()],
4280 "e.g. is an abbreviation, not a sentence boundary"
4281 );
4282 }
4283
4284 #[test]
4285 fn test_is_unordered_list_marker() {
4286 assert!(is_unordered_list_marker("- item"));
4288 assert!(is_unordered_list_marker("* item"));
4289 assert!(is_unordered_list_marker("+ item"));
4290 assert!(is_unordered_list_marker("-")); assert!(is_unordered_list_marker("*"));
4292 assert!(is_unordered_list_marker("+"));
4293
4294 assert!(!is_unordered_list_marker("---")); assert!(!is_unordered_list_marker("***")); assert!(!is_unordered_list_marker("- - -")); assert!(!is_unordered_list_marker("* * *")); assert!(!is_unordered_list_marker("*emphasis*")); assert!(!is_unordered_list_marker("-word")); assert!(!is_unordered_list_marker("")); assert!(!is_unordered_list_marker("text")); assert!(!is_unordered_list_marker("# heading")); }
4305
4306 #[test]
4307 fn test_is_block_boundary() {
4308 assert!(is_block_boundary("")); assert!(is_block_boundary("# Heading")); assert!(is_block_boundary("## Level 2")); assert!(is_block_boundary("```rust")); assert!(is_block_boundary("~~~")); assert!(is_block_boundary("> quote")); assert!(is_block_boundary("| cell |")); assert!(is_block_boundary("[link]: http://example.com")); assert!(is_block_boundary("---")); assert!(is_block_boundary("***")); assert!(is_block_boundary("- item")); assert!(is_block_boundary("* item")); assert!(is_block_boundary("+ item")); assert!(is_block_boundary("1. item")); assert!(is_block_boundary("10. item")); assert!(is_block_boundary(": definition")); assert!(is_block_boundary(":::")); assert!(is_block_boundary("::::: {.callout-note}")); assert!(!is_block_boundary("regular text"));
4330 assert!(!is_block_boundary("*emphasis*")); assert!(!is_block_boundary("[link](url)")); assert!(!is_block_boundary("some words here"));
4333 }
4334
4335 #[test]
4336 fn test_definition_list_boundary_in_single_line_paragraph() {
4337 let options = ReflowOptions {
4340 line_length: 80,
4341 ..Default::default()
4342 };
4343 let input = "Term\n: Definition of the term";
4344 let result = reflow_markdown(input, &options);
4345 assert!(
4347 result.contains(": Definition"),
4348 "Definition list item should not be merged into previous line. Got: {result:?}"
4349 );
4350 let lines: Vec<&str> = result.lines().collect();
4351 assert_eq!(lines.len(), 2, "Should remain two separate lines. Got: {lines:?}");
4352 assert_eq!(lines[0], "Term");
4353 assert_eq!(lines[1], ": Definition of the term");
4354 }
4355
4356 #[test]
4357 fn test_is_paragraph_boundary() {
4358 assert!(is_paragraph_boundary("# Heading", "# Heading"));
4360 assert!(is_paragraph_boundary("- item", "- item"));
4361 assert!(is_paragraph_boundary(":::", ":::"));
4362 assert!(is_paragraph_boundary(": definition", ": definition"));
4363
4364 assert!(is_paragraph_boundary("code", " code"));
4366 assert!(is_paragraph_boundary("code", "\tcode"));
4367
4368 assert!(is_paragraph_boundary("| a | b |", "| a | b |"));
4370 assert!(is_paragraph_boundary("a | b", "a | b")); assert!(!is_paragraph_boundary("regular text", "regular text"));
4374 assert!(!is_paragraph_boundary("text", " text")); }
4376
4377 #[test]
4378 fn test_div_marker_boundary_in_reflow_paragraph_at_line() {
4379 let content = "Some paragraph text here.\n\n::: {.callout-note}\nThis is a callout.\n:::\n";
4382 let result = reflow_paragraph_at_line(content, 3, 80);
4384 assert!(result.is_none(), "Div marker line should not be reflowed");
4385 }
4386
4387 #[test]
4388 fn starts_block_construct_detects_block_openers() {
4389 for case in ["- item", "-", "* item", "*", "+ item", "+", "-\titem"] {
4391 assert!(starts_block_construct(case), "bullet: {case:?}");
4392 }
4393 for case in ["1. item", "1) item", "01. x", "000000001. x", "1.\titem"] {
4396 assert!(starts_block_construct(case), "ordered: {case:?}");
4397 }
4398 for case in ["> quote", ">quote", ">"] {
4400 assert!(starts_block_construct(case), "blockquote: {case:?}");
4401 }
4402 for case in ["# heading", "###### h6", "#", "##"] {
4404 assert!(starts_block_construct(case), "heading: {case:?}");
4405 }
4406 for case in ["```", "```rust", "````", "~~~", "~~~text"] {
4408 assert!(starts_block_construct(case), "fence: {case:?}");
4409 }
4410 for case in ["---", "--", "===", "=", "***", "___", "_ _ _", "- - -"] {
4412 assert!(starts_block_construct(case), "setext/thematic: {case:?}");
4413 }
4414 for case in [
4417 "[^1]: text",
4418 "[^note]:",
4419 "[ref]: http://example.com",
4420 "[wat]: url follows",
4421 ] {
4422 assert!(starts_block_construct(case), "definition: {case:?}");
4423 }
4424 for case in ["<div>content", "</div>", "<p>text", "<table>", "<pre>code", "<h1>x"] {
4426 assert!(starts_block_construct(case), "html block: {case:?}");
4427 }
4428 }
4429
4430 #[test]
4431 fn starts_block_construct_allows_ordinary_prose() {
4432 for case in [
4433 "",
4434 "word",
4435 "-5 degrees",
4436 "--flag",
4437 "-item",
4438 "#hashtag",
4439 "####### seven hashes is not a heading",
4440 "1.5 million",
4441 "1234567890. ten digits is not a list marker",
4442 "0000000001. ten digits is not a list marker either",
4443 "2. item",
4446 "7. item",
4447 "0. item",
4448 "42) x",
4449 "123456. item",
4450 "1.",
4451 "1)",
4452 "123456.",
4453 "123456)",
4454 "1.item",
4455 "1:30 pm",
4456 "*emphasis*",
4457 "**bold** text",
4458 "__bold__ text",
4459 "_emphasis_ text",
4460 "`code` span",
4461 "`` double backtick span ``",
4462 "~~strikethrough~~",
4463 "=x",
4464 "== ==",
4465 "(parenthetical)",
4466 "[link](url)",
4467 "[text][ref] more",
4468 "[bracketed] aside",
4469 "[a](b) [ref]: first bracket is a link, not a label",
4470 "[esc\\]: not a close] text",
4471 "<span>inline</span>",
4472 "<b>bold</b>",
4473 "<https://example.com> autolink",
4474 "<mailto:a@b.com>",
4475 "<notarealtag>",
4476 ] {
4477 assert!(!starts_block_construct(case), "prose: {case:?}");
4478 }
4479 }
4480
4481 #[test]
4482 fn merge_block_construct_continuations_merges_marker_led_lines() {
4483 let lines = vec![
4484 "First sentence?".to_string(),
4485 "- looks like a list item".to_string(),
4486 "Second sentence.".to_string(),
4487 ];
4488 assert_eq!(
4489 merge_block_construct_continuations(lines),
4490 vec![
4491 "First sentence? - looks like a list item".to_string(),
4492 "Second sentence.".to_string(),
4493 ]
4494 );
4495
4496 let lines = vec!["- real list content".to_string(), "continuation".to_string()];
4499 assert_eq!(
4500 merge_block_construct_continuations(lines.clone()),
4501 lines,
4502 "first line must never be merged"
4503 );
4504
4505 let lines = vec!["prose".to_string(), "1.".to_string(), "[ref]:".to_string()];
4508 assert_eq!(
4509 merge_block_construct_continuations(lines),
4510 vec!["prose 1. [ref]:".to_string()],
4511 "a merge that creates an opener must fold again"
4512 );
4513 }
4514
4515 #[test]
4516 fn wrap_never_starts_a_line_with_a_block_marker() {
4517 let options = ReflowOptions {
4518 line_length: 25,
4519 ..Default::default()
4520 };
4521 let lines = reflow_line(
4524 "Some words here and then - a dash clause that wraps around the limit.",
4525 &options,
4526 );
4527 assert_eq!(
4528 lines,
4529 vec![
4530 "Some words here and",
4531 "then - a dash clause that",
4532 "wraps around the limit."
4533 ]
4534 );
4535
4536 for input in [
4538 "Alpha beta gamma delta epsilon - dash clause here to wrap",
4539 "Alpha beta gamma delta epsilon > quote lookalike here to wrap",
4540 "Alpha beta gamma delta epsilon # heading lookalike here to wrap",
4541 "Alpha beta gamma delta epsilon 1. ordered lookalike here to wrap",
4542 "Alpha beta gamma delta epsilon * star clause here to wrap",
4543 "Alpha beta gamma delta epsilon + plus clause here to wrap",
4544 ] {
4545 for width in 10..40 {
4546 let options = ReflowOptions {
4547 line_length: width,
4548 ..Default::default()
4549 };
4550 for line in reflow_line(input, &options) {
4551 assert!(
4552 !starts_block_construct(&line),
4553 "width {width}: wrapped line opens a block construct: {line:?} (input {input:?})"
4554 );
4555 }
4556 }
4557 }
4558 }
4559
4560 #[test]
4561 fn sentence_per_line_keeps_block_markers_mid_line() {
4562 let options = ReflowOptions {
4563 line_length: 80,
4564 sentence_per_line: true,
4565 ..Default::default()
4566 };
4567 let lines = reflow_line(
4570 "Google Calendar (Can't we get rid of this dependency? - I don't really see the need)",
4571 &options,
4572 );
4573 assert_eq!(
4574 lines,
4575 vec!["Google Calendar (Can't we get rid of this dependency? - I don't really see the need)".to_string()]
4576 );
4577
4578 let lines = reflow_line("See section 4? # is the marker we use. Fine.", &options);
4580 assert_eq!(lines, vec!["See section 4? # is the marker we use.", "Fine."]);
4581
4582 let lines = reflow_line("Is this a problem? > I quote someone here.", &options);
4583 assert_eq!(lines, vec!["Is this a problem? > I quote someone here."]);
4584
4585 let lines = reflow_line("Another case! 1. Not a list. More text follows here.", &options);
4586 for line in &lines {
4587 assert!(
4588 !starts_block_construct(line),
4589 "sentence-per-line output opens a block construct: {line:?}"
4590 );
4591 }
4592 }
4593
4594 #[test]
4595 fn inline_math_directly_after_display_math_stays_atomic() {
4596 let options = ReflowOptions {
4604 line_length: 8,
4605 ..Default::default()
4606 };
4607 let lines = reflow_line("$$a$$$bb cc dd$ x", &options);
4608 assert_eq!(lines, vec!["$$a$$$bb cc dd$".to_string(), "x".to_string()]);
4609 }
4610
4611 #[test]
4612 fn test_code_span_parsing() {
4613 let elements = parse_markdown_elements_inner("`code`", false, false, None);
4615 assert_eq!(elements.len(), 1);
4616 assert!(matches!(&elements[0], Element::Code { content, marker } if content == "code" && marker == "`"));
4617
4618 let elements = parse_markdown_elements_inner("``code``", false, false, None);
4620 assert_eq!(elements.len(), 1);
4621 assert!(matches!(&elements[0], Element::Code { content, marker } if content == "code" && marker == "``"));
4622
4623 let elements = parse_markdown_elements_inner("``code`inside``", false, false, None);
4625 assert_eq!(elements.len(), 1);
4626 assert!(
4627 matches!(&elements[0], Element::Code { content, marker } if content == "code`inside" && marker == "``")
4628 );
4629
4630 let elements = parse_markdown_elements_inner("`` code ``", false, false, None);
4632 assert_eq!(elements.len(), 1);
4633 assert!(matches!(&elements[0], Element::Code { content, marker } if content == " code " && marker == "``"));
4634
4635 let elements = parse_markdown_elements_inner("`unclosed", false, false, None);
4637 assert_eq!(elements.len(), 1);
4638 assert!(matches!(&elements[0], Element::Text(s) if s == "`unclosed"));
4639
4640 let elements = parse_markdown_elements_inner("`unclosed [link](url)", false, false, None);
4642 assert_eq!(elements.len(), 2);
4644 assert!(matches!(&elements[0], Element::Text(s) if s == "`unclosed "));
4645 assert!(matches!(&elements[1], Element::Link(s) if s == "[link](url)"));
4646 }
4647
4648 #[test]
4649 fn test_reflow_performance_long_input() {
4650 let mut text = String::new();
4653 for i in 1..400 {
4654 let backticks = "`".repeat(i);
4655 text.push_str(&backticks);
4656 text.push(' ');
4657 }
4658
4659 let start = std::time::Instant::now();
4660 let elements = parse_markdown_elements_inner(&text, false, false, None);
4661 let duration = start.elapsed();
4662
4663 assert!(duration.as_millis() < 100, "Parsing took too long: {duration:?}");
4665 assert!(!elements.is_empty());
4666 }
4667
4668 #[test]
4669 fn test_reflow_performance_display_math_heavy() {
4670 let text = "$$a$$".repeat(4000);
4675
4676 let start = std::time::Instant::now();
4677 let elements = parse_markdown_elements_inner(&text, false, false, None);
4678 let duration = start.elapsed();
4679
4680 assert!(duration.as_millis() < 100, "Parsing took too long: {duration:?}");
4681 assert_eq!(elements.len(), 4000);
4682 }
4683
4684 #[test]
4685 fn inline_math_len_at_start_matches_regex_at_slice_start() {
4686 let alphabet = ['$', 'a', ' '];
4691 let mut inputs: Vec<String> = vec![String::new()];
4692 let mut frontier: Vec<String> = vec![String::new()];
4693 for _ in 0..6 {
4694 let mut longer = Vec::new();
4695 for prefix in &frontier {
4696 for ch in alphabet {
4697 let mut s = prefix.clone();
4698 s.push(ch);
4699 longer.push(s);
4700 }
4701 }
4702 inputs.extend(longer.iter().cloned());
4703 frontier = longer;
4704 }
4705 inputs.push("$αβ$x".to_string());
4707 inputs.push("$α$$".to_string());
4708
4709 for s in &inputs {
4710 let expected = INLINE_MATH_REGEX
4711 .find(s)
4712 .ok()
4713 .flatten()
4714 .filter(|m| m.start() == 0)
4715 .map(|m| m.end());
4716 assert_eq!(inline_math_len_at_start(s), expected, "input: {s:?}");
4717 }
4718 }
4719
4720 #[test]
4721 fn inline_math_probe_after_dollar_matches_uncached_parse() {
4722 let cases = [
4728 ("$$a$$$b c$ x", r#"[DisplayMath("a"), InlineMath("b c"), Text(" x")]"#),
4729 (
4730 "$$a$$$b$ $$a$$$b$",
4731 r#"[DisplayMath("a"), InlineMath("b"), Text(" "), DisplayMath("a"), InlineMath("b")]"#,
4732 ),
4733 (
4735 "$$a$$$ x $y z$",
4736 r#"[DisplayMath("a"), InlineMath(" x "), Text("y z$")]"#,
4737 ),
4738 ("$$a$$$$ x", r#"[DisplayMath("a"), Text("$$ x")]"#),
4740 ("$$a$$$$b$$ x", r#"[DisplayMath("a"), DisplayMath("b"), Text(" x")]"#),
4741 (
4743 "$a$$b$$c$$d$ tail",
4744 r#"[Text("$a"), DisplayMath("b"), Text("c$$d$ tail")]"#,
4745 ),
4746 ];
4747 for (input, expected) in cases {
4748 let elements = parse_markdown_elements_inner(input, false, false, None);
4749 assert_eq!(format!("{elements:?}"), expected, "input: {input:?}");
4750 }
4751 }
4752
4753 #[test]
4754 fn test_atomic_spans() {
4755 let text_emphasis = "hello **word1 word2**";
4757
4758 let options_disabled = ReflowOptions {
4759 line_length: 18,
4760 atomic_spans: true,
4761 ..Default::default()
4762 };
4763 let lines_disabled = reflow_line(text_emphasis, &options_disabled);
4764 assert_eq!(lines_disabled, vec!["hello", "**word1 word2**"]);
4765
4766 let options_enabled = ReflowOptions {
4767 line_length: 18,
4768 atomic_spans: false,
4769 ..Default::default()
4770 };
4771 let lines_enabled = reflow_line(text_emphasis, &options_enabled);
4772 assert_eq!(lines_enabled, vec!["hello **word1", "word2**"]);
4773
4774 let text_code = "hello `word1 word2`";
4776
4777 let lines_code_disabled = reflow_line(text_code, &options_disabled);
4778 assert_eq!(lines_code_disabled, vec!["hello", "`word1 word2`"]);
4779
4780 let lines_code_enabled = reflow_line(text_code, &options_enabled);
4781 assert_eq!(lines_code_enabled, vec!["hello `word1", "word2`"]);
4782
4783 let text_code_padding = "hello `` `word1` `word2` ``";
4785 let lines_padding_enabled = reflow_line(text_code_padding, &options_enabled);
4786 assert_eq!(lines_padding_enabled, vec![r#"hello `` `word1`"#, r#"`word2` ``"#]);
4787 }
4788
4789 #[test]
4790 fn test_emphasis_containing_markers_is_not_split() {
4791 let options = ReflowOptions {
4792 line_length: 5,
4793 atomic_spans: false,
4794 ..Default::default()
4795 };
4796 let lines = reflow_line(r#"*foo \*bar*"#, &options);
4798 assert_eq!(lines, vec![r#"*foo \*bar*"#.to_string()]);
4799 }
4800
4801 fn semantic_shape(markdown: &str) -> String {
4806 let mut options = Options::empty();
4807 options.insert(Options::ENABLE_STRIKETHROUGH);
4808 let mut out = String::new();
4809 let push_prose = |out: &mut String, text: &str| {
4810 for c in text.chars() {
4811 if c.is_whitespace() {
4812 if !out.ends_with(char::is_whitespace) {
4813 out.push(' ');
4814 }
4815 } else {
4816 out.push(c);
4817 }
4818 }
4819 };
4820 for event in Parser::new_ext(markdown, options) {
4821 match event {
4822 Event::Text(text) => push_prose(&mut out, &text),
4823 Event::SoftBreak | Event::HardBreak => push_prose(&mut out, " "),
4824 Event::Code(code) => out.push_str(&format!("<code>{code}</code>")),
4826 Event::Start(tag) => out.push_str(&format!("<{tag:?}>")),
4827 Event::End(tag) => out.push_str(&format!("</{tag:?}>")),
4828 other => out.push_str(&format!("{other:?}")),
4829 }
4830 }
4831 out.trim().to_string()
4832 }
4833
4834 #[test]
4835 fn test_wrapping_a_span_never_changes_what_it_parses_to() {
4836 let corpus = [
4840 "_This is a very, very, very, very, very long line with some `code` inside._",
4841 "_alpha beta gamma delta epsilon `a b` zeta eta theta iota kappa lambda_",
4842 "**strong text with `code` and more words than fit on one single line**",
4843 "~~struck text with `code` and more words than fit on one single line~~",
4844 "_emphasis with **nested strong that is quite long** and trailing words_",
4845 "***A doubly nested bold italic span with more words than fit on a line***",
4848 "___Another doubly nested span with more words than fit on a single line___",
4849 "**_mixed strong then emphasis with more words than fit on a single line_**",
4850 "*__mixed emphasis then strong with more words than fit on a single line__*",
4851 "**~~strong strikethrough with more words than fit on a single line here~~**",
4852 "**a * b with a stray marker and plenty more words to pass the budget**",
4855 "_foo `a` bar `b` baz qux quux corge grault garply waldo fred plugh xyzzy_",
4856 "text before _a long emphasis with `code` inside of it here_ and after",
4857 "(_a parenthesized long emphasis with `code` inside of it right here_)",
4858 r#"*foo \*bar baz qux quux corge grault garply waldo fred plugh xyzzy*"#,
4859 "_tab\tseparated `a\tb` words spread out over quite a long emphasis span_",
4860 "_This has [text](<a b c d e f g h i j k>) and trailing words to wrap._",
4863 "_This has [`code`](<a b c d e f g h i j k>) and trailing words to wrap._",
4864 r#"_See [x](https://example.com "a long link title here") and `code` too._"#,
4865 "_A [link with a long label](https://example.com/path) and `code` here._",
4866 "_An image  plus `code` and more text_",
4867 ];
4868 for text in corpus {
4869 let expected = semantic_shape(text);
4870 for line_length in [20, 30, 40, 80] {
4871 for atomic_spans in [true, false] {
4872 let options = ReflowOptions {
4873 line_length,
4874 atomic_spans,
4875 ..Default::default()
4876 };
4877 let wrapped = reflow_line(text, &options).join("\n");
4878 assert_eq!(
4879 semantic_shape(&wrapped),
4880 expected,
4881 "reflow changed the parse of {text:?} at line_length={line_length} \
4882 atomic_spans={atomic_spans}\n wrapped: {wrapped:?}"
4883 );
4884 }
4885 }
4886 }
4887 }
4888
4889 #[test]
4890 fn test_wrapping_a_span_keeps_whole_the_constructs_pulldown_cannot_see() {
4891 let cases = [
4895 (
4896 "_alpha beta gamma [[a wiki link]] delta epsilon zeta eta_",
4897 "[[a wiki link]]",
4898 ),
4899 (
4900 "_alpha beta gamma {{< foo bar >}} delta epsilon zeta eta_",
4901 "{{< foo bar >}}",
4902 ),
4903 ("_alpha beta gamma $a + b$ delta epsilon zeta eta theta_", "$a + b$"),
4904 ("_alpha beta gamma $$a + b$$ delta epsilon zeta eta theta_", "$$a + b$$"),
4905 ];
4906 for (text, construct) in cases {
4907 for line_length in [12, 20, 30] {
4908 for atomic_spans in [true, false] {
4909 let options = ReflowOptions {
4910 line_length,
4911 atomic_spans,
4912 ..Default::default()
4913 };
4914 let wrapped = reflow_line(text, &options).join("\n");
4915 assert!(
4916 wrapped.contains(construct),
4917 "{construct} was broken at line_length={line_length} \
4918 atomic_spans={atomic_spans}: {wrapped:?}"
4919 );
4920 }
4921 }
4922 }
4923 }
4924
4925 #[test]
4926 fn test_overlong_emphasis_with_nested_code_span_wraps() {
4927 let options = ReflowOptions {
4931 line_length: 80,
4932 atomic_spans: true,
4933 ..Default::default()
4934 };
4935 let text = "_This is a very, very, very, very, very, very, very long line that exceeds 80 characters with some `code` inside._";
4936 let lines = reflow_line(text, &options);
4937 assert_eq!(
4938 lines,
4939 vec![
4940 "_This is a very, very, very, very, very, very, very long line that exceeds 80",
4941 "characters with some `code` inside._",
4942 ]
4943 );
4944 }
4945
4946 #[test]
4947 fn test_overlong_emphasis_with_nested_strong_wraps() {
4948 let options = ReflowOptions {
4950 line_length: 80,
4951 atomic_spans: true,
4952 ..Default::default()
4953 };
4954 let text = "_This is a very, very, very, very, very, very, very long line that exceeds 80 characters with some **bold** inside._";
4955 let lines = reflow_line(text, &options);
4956 assert_eq!(
4957 lines,
4958 vec![
4959 "_This is a very, very, very, very, very, very, very long line that exceeds 80",
4960 "characters with some **bold** inside._",
4961 ]
4962 );
4963 }
4964
4965 #[test]
4966 fn test_overlong_doubly_nested_span_wraps() {
4967 let options = ReflowOptions {
4972 line_length: 80,
4973 atomic_spans: true,
4974 ..Default::default()
4975 };
4976 let body = "This is a very, very, very, very, very, very, very, very, very, very long line that is emphasised.";
4977 for (open, close) in [
4978 ("***", "***"),
4979 ("___", "___"),
4980 ("**_", "_**"),
4981 ("*__", "__*"),
4982 ("**~~", "~~**"),
4983 ] {
4984 let text = format!("{open}{body}{close}");
4985 assert!(text.len() > options.line_length, "case must start over budget");
4986 let lines = reflow_line(&text, &options);
4987 assert!(
4988 lines.len() > 1,
4989 "{open}...{close} should wrap but stayed on one line: {lines:?}"
4990 );
4991 assert!(
4992 lines.iter().all(|line| line.len() <= options.line_length),
4993 "{open}...{close} left a line over the budget: {lines:?}"
4994 );
4995 assert_eq!(
4996 lines.join(" "),
4997 text,
4998 "{open}...{close} wrapping must only replace a space with a newline"
4999 );
5000 }
5001 }
5002
5003 #[test]
5004 fn test_overlong_span_with_stray_marker_stays_whole() {
5005 let options = ReflowOptions {
5009 line_length: 40,
5010 atomic_spans: true,
5011 ..Default::default()
5012 };
5013 let text = "**alpha * beta gamma delta epsilon zeta eta theta iota kappa**";
5014 let lines = reflow_line(text, &options);
5015 assert_eq!(lines, vec![text], "stray marker must keep the span whole");
5016 }
5017
5018 #[test]
5019 fn test_overlong_span_never_breaks_inside_a_nested_reference_link() {
5020 let options = ReflowOptions {
5026 line_length: 30,
5027 atomic_spans: true,
5028 defined_references: Some(HashSet::from([
5029 "ref".to_string(),
5030 "one two three four five six seven".to_string(),
5032 ])),
5033 ..Default::default()
5034 };
5035 for (text, link) in [
5036 (
5037 "_**alpha [one two three four five six seven][ref] beta gamma delta**_",
5038 "[one two three four five six seven][ref]",
5039 ),
5040 (
5041 "**alpha [one two three four five six seven][ref] beta gamma delta**",
5042 "[one two three four five six seven][ref]",
5043 ),
5044 (
5045 "_**alpha ![one two three four five six seven][ref] beta gamma delta**_",
5046 "![one two three four five six seven][ref]",
5047 ),
5048 (
5049 "_**alpha [one two three four five six seven][] beta gamma delta**_",
5050 "[one two three four five six seven][]",
5051 ),
5052 (
5053 "_**alpha [one two three four five six seven] beta gamma delta**_",
5054 "[one two three four five six seven]",
5055 ),
5056 ] {
5057 let lines = reflow_line(text, &options);
5058 assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
5059 assert!(
5060 lines.iter().any(|line| line.contains(link)),
5061 "{link} must stay on one line: {lines:?}"
5062 );
5063 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
5064 }
5065 }
5066
5067 #[test]
5068 fn test_overlong_span_breaks_inside_an_undefined_shortcut_reference() {
5069 let options = ReflowOptions {
5073 line_length: 30,
5074 atomic_spans: true,
5075 defined_references: Some(HashSet::new()),
5076 ..Default::default()
5077 };
5078 let text = "_**alpha [one two three four five six seven] beta gamma delta**_";
5079 let lines = reflow_line(text, &options);
5080 assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
5081 assert!(
5082 !lines
5083 .iter()
5084 .any(|line| line.contains("[one two three four five six seven]")),
5085 "an undefined shortcut is prose and should break: {lines:?}"
5086 );
5087 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
5088 }
5089
5090 #[test]
5091 fn test_overlong_span_never_breaks_inside_a_nested_attr_list() {
5092 let attr = "{.highlight key=\"a b c\"}";
5096 let text = format!("_**alpha beta gamma delta epsilon zeta{attr} eta theta iota kappa**_");
5097 let options = ReflowOptions {
5098 line_length: 20,
5099 atomic_spans: true,
5100 attr_lists: true,
5101 ..Default::default()
5102 };
5103 let lines = reflow_line(&text, &options);
5104 assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
5105 assert!(
5106 lines.iter().any(|line| line.contains(attr)),
5107 "attr list must stay on one line: {lines:?}"
5108 );
5109 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
5110
5111 let plain = ReflowOptions {
5114 attr_lists: false,
5115 ..options
5116 };
5117 let lines = reflow_line(&text, &plain);
5118 assert!(
5119 !lines.iter().any(|line| line.contains(attr)),
5120 "without the flavor the braces are prose and should break: {lines:?}"
5121 );
5122 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
5123 }
5124
5125 #[test]
5126 fn test_overlong_emphasis_never_breaks_inside_nested_code_span() {
5127 let options = ReflowOptions {
5131 line_length: 30,
5132 atomic_spans: true,
5133 ..Default::default()
5134 };
5135 let text = "_alpha beta gamma delta epsilon `a b` zeta eta theta iota kappa_";
5136 let lines = reflow_line(text, &options);
5137 assert!(lines.len() > 1, "over-long emphasis should wrap: {lines:?}");
5138 assert!(
5139 lines.iter().any(|line| line.contains("`a b`")),
5140 "nested code span must stay whole with its interior spaces: {lines:?}"
5141 );
5142 for line in &lines {
5143 assert_eq!(
5144 line.matches('`').count() % 2,
5145 0,
5146 "no line may contain half a code span: {line:?}"
5147 );
5148 }
5149 }
5150
5151 #[test]
5152 fn test_definition_list_marker_does_not_start_line() {
5153 let options = ReflowOptions {
5154 line_length: 20,
5155 ..Default::default()
5156 };
5157 let lines = reflow_line("This is a term and : definition here.", &options);
5159 for line in &lines {
5160 assert!(
5161 !line.trim_start().starts_with(": "),
5162 "Wrapped line should not start with definition marker: {line}"
5163 );
5164 }
5165 }
5166
5167 #[test]
5168 fn test_div_marker_does_not_start_line() {
5169 let options = ReflowOptions {
5170 line_length: 20,
5171 ..Default::default()
5172 };
5173 let lines = reflow_line("This is some text with ::: class marker.", &options);
5175 for line in &lines {
5176 assert!(
5177 !line.trim_start().starts_with(":::"),
5178 "Wrapped line should not start with div marker: {line}"
5179 );
5180 }
5181 }
5182}