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' => {
1923 let digits = bytes.iter().take_while(|b| b.is_ascii_digit()).count();
1924 digits <= 9
1925 && bytes.len() > digits
1926 && (bytes[digits] == b'.' || bytes[digits] == b')')
1927 && marker_then_boundary(digits + 1)
1928 }
1929 b'[' => {
1937 let mut escaped = false;
1938 let mut label_close = None;
1939 for (i, &b) in bytes.iter().enumerate().skip(1) {
1940 if escaped {
1941 escaped = false;
1942 } else if b == b'\\' {
1943 escaped = true;
1944 } else if b == b']' {
1945 label_close = Some(i);
1946 break;
1947 }
1948 }
1949 label_close.is_some_and(|i| bytes.get(i + 1) == Some(&b':'))
1950 }
1951 b'<' => crate::utils::html_block::parse_html_block_start(text).is_some(),
1954 _ => false,
1955 }
1956}
1957
1958fn merge_block_construct_continuations(lines: Vec<String>) -> Vec<String> {
1967 let mut merged: Vec<String> = Vec::with_capacity(lines.len());
1968 for line in lines {
1969 match merged.last_mut() {
1970 Some(prev) if starts_block_construct(&line) => {
1971 prev.push(' ');
1972 prev.push_str(line.trim_start());
1973 }
1974 _ => merged.push(line),
1975 }
1976 }
1977 merged
1978}
1979
1980fn reflow_elements_sentence_per_line(
1982 elements: &[Element],
1983 custom_abbreviations: &Option<Vec<String>>,
1984 require_sentence_capital: bool,
1985) -> Vec<String> {
1986 let abbreviations = get_abbreviations(custom_abbreviations);
1987 let mut lines = Vec::new();
1988 let mut current_line = String::new();
1989
1990 for (idx, element) in elements.iter().enumerate() {
1991 if let Element::Text(text) = element {
1993 let combined = format!("{current_line}{text}");
1995 let sentences = split_into_sentences_with_set(&combined, &abbreviations, require_sentence_capital);
1997
1998 if sentences.len() > 1 {
1999 for (i, sentence) in sentences.iter().enumerate() {
2001 if i == 0 {
2002 let trimmed = sentence.trim();
2005
2006 if text_ends_with_abbreviation(trimmed, &abbreviations) {
2007 current_line.clone_from(sentence);
2009 } else {
2010 lines.push(sentence.clone());
2012 current_line.clear();
2013 }
2014 } else if i == sentences.len() - 1 {
2015 let trimmed = sentence.trim();
2017 let ends_with_sentence_punct = ends_with_sentence_punct(trimmed);
2018
2019 if ends_with_sentence_punct && !text_ends_with_abbreviation(trimmed, &abbreviations) {
2020 lines.push(sentence.clone());
2022 current_line.clear();
2023 } else {
2024 current_line.clone_from(sentence);
2026 }
2027 } else {
2028 lines.push(sentence.clone());
2030 }
2031 }
2032 } else {
2033 let trimmed = combined.trim();
2035
2036 if trimmed.is_empty() {
2040 continue;
2041 }
2042
2043 let ends_with_sentence_punct = ends_with_sentence_punct(trimmed);
2044
2045 if ends_with_sentence_punct && !text_ends_with_abbreviation(trimmed, &abbreviations) {
2046 lines.push(combined.trim_matches(is_breakable_whitespace).to_string());
2049 current_line.clear();
2050 } else {
2051 current_line = combined;
2053 }
2054 }
2055 } else if let Element::Italic { content, underscore } = element {
2056 let marker = if *underscore { "_" } else { "*" };
2058 handle_emphasis_sentence_split(
2059 content,
2060 marker,
2061 &abbreviations,
2062 require_sentence_capital,
2063 &mut current_line,
2064 &mut lines,
2065 );
2066 } else if let Element::Bold { content, underscore } = element {
2067 let marker = if *underscore { "__" } else { "**" };
2069 handle_emphasis_sentence_split(
2070 content,
2071 marker,
2072 &abbreviations,
2073 require_sentence_capital,
2074 &mut current_line,
2075 &mut lines,
2076 );
2077 } else if let Element::Strikethrough { content, double } = element {
2078 handle_emphasis_sentence_split(
2080 content,
2081 if *double { "~~" } else { "~" },
2082 &abbreviations,
2083 require_sentence_capital,
2084 &mut current_line,
2085 &mut lines,
2086 );
2087 } else {
2088 let element_str = format!("{element}");
2090 let is_adjacent = if idx > 0 {
2094 match &elements[idx - 1] {
2095 Element::Text(t) => !t.is_empty() && !t.ends_with(is_breakable_whitespace),
2096 _ => true,
2097 }
2098 } else {
2099 false
2100 };
2101
2102 if !is_adjacent && should_insert_space_before_join(¤t_line) {
2104 current_line.push(' ');
2105 }
2106 current_line.push_str(&element_str);
2107 }
2108 }
2109
2110 if !current_line.is_empty() {
2112 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
2113 }
2114 lines
2115}
2116
2117fn handle_emphasis_sentence_split(
2119 content: &str,
2120 marker: &str,
2121 abbreviations: &HashSet<String>,
2122 require_sentence_capital: bool,
2123 current_line: &mut String,
2124 lines: &mut Vec<String>,
2125) {
2126 let sentences = split_into_sentences_with_set(content, abbreviations, require_sentence_capital);
2128
2129 if sentences.len() <= 1 {
2130 if should_insert_space_before_join(current_line) {
2132 current_line.push(' ');
2133 }
2134 current_line.push_str(marker);
2135 current_line.push_str(content);
2136 current_line.push_str(marker);
2137
2138 let trimmed = content.trim();
2140 let ends_with_punct = ends_with_sentence_punct(trimmed);
2141 if ends_with_punct && !text_ends_with_abbreviation(trimmed, abbreviations) {
2142 lines.push(current_line.clone());
2143 current_line.clear();
2144 }
2145 } else {
2146 for (i, sentence) in sentences.iter().enumerate() {
2148 let trimmed = sentence.trim();
2149 if trimmed.is_empty() {
2150 continue;
2151 }
2152
2153 if i == 0 {
2154 if should_insert_space_before_join(current_line) {
2156 current_line.push(' ');
2157 }
2158 current_line.push_str(marker);
2159 current_line.push_str(trimmed);
2160 current_line.push_str(marker);
2161
2162 let ends_with_punct = ends_with_sentence_punct(trimmed);
2164 if ends_with_punct && !text_ends_with_abbreviation(trimmed, abbreviations) {
2165 lines.push(current_line.clone());
2166 current_line.clear();
2167 }
2168 } else if i == sentences.len() - 1 {
2169 let ends_with_punct = ends_with_sentence_punct(trimmed);
2171
2172 let mut line = String::new();
2173 line.push_str(marker);
2174 line.push_str(trimmed);
2175 line.push_str(marker);
2176
2177 if ends_with_punct && !text_ends_with_abbreviation(trimmed, abbreviations) {
2178 lines.push(line);
2179 } else {
2180 *current_line = line;
2182 }
2183 } else {
2184 let mut line = String::new();
2186 line.push_str(marker);
2187 line.push_str(trimmed);
2188 line.push_str(marker);
2189 lines.push(line);
2190 }
2191 }
2192 }
2193}
2194
2195const BREAK_WORDS: &[&str] = &[
2199 "and",
2200 "or",
2201 "but",
2202 "nor",
2203 "yet",
2204 "so",
2205 "for",
2206 "which",
2207 "that",
2208 "because",
2209 "when",
2210 "if",
2211 "while",
2212 "where",
2213 "although",
2214 "though",
2215 "unless",
2216 "since",
2217 "after",
2218 "before",
2219 "until",
2220 "as",
2221 "once",
2222 "whether",
2223 "however",
2224 "therefore",
2225 "moreover",
2226 "furthermore",
2227 "nevertheless",
2228 "whereas",
2229];
2230
2231fn is_clause_punctuation(c: char) -> bool {
2233 matches!(c, ',' | ';' | ':' | '\u{2014}') }
2235
2236fn clause_break_allowed_after(chars: &[char], i: usize) -> bool {
2244 if chars[i] == '\u{2014}' {
2245 return true;
2246 }
2247 match chars.get(i + 1) {
2248 None => true,
2249 Some(next) => next.is_whitespace(),
2250 }
2251}
2252
2253fn paren_group_end<'a>(slice: &'a str, element_spans: &[(usize, usize)], offset: usize) -> Option<(usize, &'a str)> {
2267 debug_assert!(slice.starts_with('('));
2268 let mut depth: i32 = 0;
2269 for (local_byte, c) in slice.char_indices() {
2270 let global_byte = offset + local_byte;
2271 if depth > 0 && is_inside_element(global_byte, element_spans) {
2276 continue;
2277 }
2278 match c {
2279 '(' => depth += 1,
2280 ')' => {
2281 depth -= 1;
2282 if depth == 0 {
2283 let end = local_byte + 1;
2284 let inner = &slice[1..local_byte];
2285 return Some((end, inner));
2286 }
2287 }
2288 _ => {}
2289 }
2290 }
2291 None
2292}
2293
2294fn split_at_parenthetical(
2311 text: &str,
2312 line_length: usize,
2313 element_spans: &[(usize, usize)],
2314 length_mode: ReflowLengthMode,
2315) -> Option<(String, String)> {
2316 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
2317
2318 if text.starts_with('(')
2320 && let Some((end_local, inner)) = paren_group_end(text, element_spans, 0)
2321 && inner.contains(' ')
2322 {
2323 let tail = &text[end_local..];
2327 let attached_len = tail
2328 .char_indices()
2329 .take_while(|(_, c)| is_closing_quote(*c) || is_clause_punctuation(*c))
2330 .last()
2331 .map_or(0, |(idx, c)| idx + c.len_utf8());
2332 let first_end = end_local + attached_len;
2333 let rest_start = first_end;
2334 let first = &text[..first_end];
2335 let first_len = display_len(first, length_mode);
2336 if first_len <= line_length {
2339 let rest = text[rest_start..].trim_start();
2340 if !rest.is_empty() {
2341 return Some((first.to_string(), rest.to_string()));
2342 }
2343 }
2344 }
2345
2346 let mut best_open_byte: Option<usize> = None;
2348 let mut pos = 0usize;
2349 while pos < text.len() {
2350 if text.as_bytes()[pos] != b'(' {
2352 let c = text[pos..].chars().next().unwrap();
2353 pos += c.len_utf8();
2354 continue;
2355 }
2356 if is_inside_element(pos, element_spans) {
2358 pos += 1;
2359 continue;
2360 }
2361 if let Some((end_local, inner)) = paren_group_end(&text[pos..], element_spans, pos) {
2362 let first = text[..pos].trim_end();
2363 let first_len = display_len(first, length_mode);
2364 if !first.is_empty()
2365 && first_len >= min_first_len
2366 && first_len <= line_length
2367 && inner.contains(' ')
2368 && best_open_byte.is_none_or(|prev| pos > prev)
2369 {
2370 best_open_byte = Some(pos);
2371 }
2372 pos += end_local;
2373 } else {
2374 pos += 1;
2375 }
2376 }
2377
2378 let open_byte = best_open_byte?;
2379 let first = text[..open_byte].trim_end().to_string();
2380 let rest = text[open_byte..].to_string();
2381 if first.is_empty() || rest.trim().is_empty() {
2382 return None;
2383 }
2384 Some((first, rest))
2385}
2386
2387fn compute_element_spans(elements: &[Element]) -> Vec<(usize, usize)> {
2391 let mut spans = Vec::new();
2392 let mut offset = 0;
2393 for element in elements {
2394 let len = element.display_len(ReflowLengthMode::Bytes);
2395 if !matches!(element, Element::Text(_)) {
2396 spans.push((offset, offset + len));
2397 }
2398 offset += len;
2399 }
2400 spans
2401}
2402
2403fn is_inside_element(pos: usize, spans: &[(usize, usize)]) -> bool {
2405 spans.iter().any(|(start, end)| pos > *start && pos < *end)
2406}
2407
2408const MIN_SPLIT_RATIO: f64 = 0.3;
2411
2412fn split_at_clause_punctuation(
2416 text: &str,
2417 line_length: usize,
2418 element_spans: &[(usize, usize)],
2419 length_mode: ReflowLengthMode,
2420) -> Option<(String, String)> {
2421 let chars: Vec<char> = text.chars().collect();
2422 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
2423
2424 let mut width_acc = 0;
2426 let mut search_end_char = 0;
2427 for (idx, &c) in chars.iter().enumerate() {
2428 let c_width = display_len(&c.to_string(), length_mode);
2429 if width_acc + c_width > line_length {
2430 break;
2431 }
2432 width_acc += c_width;
2433 search_end_char = idx + 1;
2434 }
2435
2436 let mut paren_depth: i32 = 0;
2443 let mut best_pos = None;
2444 for i in (0..search_end_char).rev() {
2445 let byte_start: usize = chars[..i].iter().map(|c| c.len_utf8()).sum();
2447 let byte_after: usize = byte_start + chars[i].len_utf8();
2449
2450 if !is_inside_element(byte_start, element_spans) {
2451 match chars[i] {
2452 ')' => paren_depth += 1,
2453 '(' => paren_depth = paren_depth.saturating_sub(1),
2454 _ => {}
2455 }
2456 }
2457
2458 if paren_depth == 0
2459 && is_clause_punctuation(chars[i])
2460 && clause_break_allowed_after(&chars, i)
2461 && !is_inside_element(byte_after, element_spans)
2462 {
2463 best_pos = Some(i);
2464 break;
2465 }
2466 }
2467
2468 let pos = best_pos?;
2469
2470 let first: String = chars[..=pos].iter().collect();
2472 let first_display_len = display_len(&first, length_mode);
2473 if first_display_len < min_first_len {
2474 return None;
2475 }
2476
2477 let rest: String = chars[pos + 1..].iter().collect();
2479 let rest = rest.trim_start().to_string();
2480
2481 if rest.is_empty() {
2482 return None;
2483 }
2484
2485 Some((first, rest))
2486}
2487
2488fn paren_depth_map(text: &str, element_spans: &[(usize, usize)]) -> Vec<i32> {
2495 let mut map = vec![0i32; text.len()];
2496 let mut depth = 0i32;
2497 for (byte, c) in text.char_indices() {
2498 if !is_inside_element(byte, element_spans) {
2499 match c {
2500 '(' => depth += 1,
2501 ')' => depth = depth.saturating_sub(1),
2502 _ => {}
2503 }
2504 }
2505 let end = (byte + c.len_utf8()).min(map.len());
2507 for slot in &mut map[byte..end] {
2508 *slot = depth;
2509 }
2510 }
2511 map
2512}
2513
2514fn is_standalone_parenthetical(line: &str) -> bool {
2523 let trimmed = line.trim();
2524 if !trimmed.starts_with('(') {
2525 return false;
2526 }
2527 let core = trimmed.trim_end_matches(|c: char| is_clause_punctuation(c));
2529 if !core.ends_with(')') {
2530 return false;
2531 }
2532 let inner = &core[1..core.len() - 1];
2534 if !inner.contains(' ') {
2535 return false;
2536 }
2537 let mut depth = 0i32;
2539 for c in core.chars() {
2540 match c {
2541 '(' => depth += 1,
2542 ')' => depth -= 1,
2543 _ => {}
2544 }
2545 if depth < 0 {
2546 return false;
2547 }
2548 }
2549 depth == 0
2550}
2551
2552fn split_at_break_word(
2556 text: &str,
2557 line_length: usize,
2558 element_spans: &[(usize, usize)],
2559 length_mode: ReflowLengthMode,
2560) -> Option<(String, String)> {
2561 let lower = text.to_lowercase();
2562 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
2563 let mut best_split: Option<(usize, usize)> = None; let depth_map = paren_depth_map(text, element_spans);
2568
2569 for &word in BREAK_WORDS {
2570 let mut search_start = 0;
2571 while let Some(pos) = lower[search_start..].find(word) {
2572 let abs_pos = search_start + pos;
2573
2574 let preceded_by_space = abs_pos == 0 || text.as_bytes().get(abs_pos - 1) == Some(&b' ');
2576 let followed_by_space = text.as_bytes().get(abs_pos + word.len()) == Some(&b' ');
2577
2578 if preceded_by_space && followed_by_space {
2579 let first_part = text[..abs_pos].trim_end();
2581 let first_part_len = display_len(first_part, length_mode);
2582
2583 let inside_paren = depth_map.get(abs_pos).is_some_and(|&d| d > 0);
2585
2586 if first_part_len >= min_first_len
2587 && first_part_len <= line_length
2588 && !is_inside_element(abs_pos, element_spans)
2589 && !inside_paren
2590 {
2591 if best_split.is_none_or(|(prev_pos, _)| abs_pos > prev_pos) {
2593 best_split = Some((abs_pos, word.len()));
2594 }
2595 }
2596 }
2597
2598 search_start = abs_pos + word.len();
2599 }
2600 }
2601
2602 let (byte_start, _word_len) = best_split?;
2603
2604 let first = text[..byte_start].trim_end().to_string();
2605 let rest = text[byte_start..].to_string();
2606
2607 if first.is_empty() || rest.trim().is_empty() {
2608 return None;
2609 }
2610
2611 Some((first, rest))
2612}
2613
2614fn cascade_split_line(text: &str, options: &ReflowOptions) -> Vec<String> {
2625 let line_length = options.line_length;
2626 let length_mode = options.length_mode;
2627 let attr_lists = options.attr_lists;
2628 let myst_roles = options.myst_roles;
2629 let defined_references = options.defined_references.as_ref();
2630 if line_length == 0 || display_len(text, length_mode) <= line_length {
2631 return vec![text.to_string()];
2632 }
2633
2634 let elements = parse_markdown_elements_inner(text, attr_lists, myst_roles, defined_references);
2635 let element_spans = compute_element_spans(&elements);
2636
2637 let rebased_spans = |start: usize| -> Vec<(usize, usize)> {
2641 if start == 0 {
2642 return element_spans.clone();
2643 }
2644 element_spans
2645 .iter()
2646 .filter(|&&(_, end)| end > start)
2647 .map(|&(s, e)| (s.saturating_sub(start), e.saturating_sub(start)))
2648 .collect()
2649 };
2650
2651 let mut result = Vec::new();
2652 let mut start = 0usize;
2653
2654 loop {
2655 let remaining = &text[start..];
2656 if display_len(remaining, length_mode) <= line_length {
2657 result.push(remaining.to_string());
2658 return result;
2659 }
2660
2661 let spans = rebased_spans(start);
2662
2663 let split = split_at_parenthetical(remaining, line_length, &spans, length_mode)
2667 .or_else(|| split_at_clause_punctuation(remaining, line_length, &spans, length_mode))
2668 .or_else(|| split_at_break_word(remaining, line_length, &spans, length_mode));
2669
2670 if let Some((first, rest)) = split {
2671 let consumed = remaining.len().saturating_sub(rest.len());
2672 if consumed == 0 {
2675 break;
2676 }
2677 result.push(first);
2678 start += consumed;
2679 continue;
2680 }
2681
2682 break;
2684 }
2685
2686 let mut fallback_options = options.clone();
2688 fallback_options.break_on_sentences = false;
2689 fallback_options.preserve_breaks = false;
2690 fallback_options.sentence_per_line = false;
2691 fallback_options.semantic_line_breaks = false;
2692 fallback_options.require_sentence_capital = true;
2693 fallback_options.max_list_continuation_indent = None;
2694 fallback_options.defined_references = None;
2695 let remaining = &text[start..];
2696 let tail_elements = if start == 0 {
2697 elements
2698 } else {
2699 parse_markdown_elements_inner(remaining, attr_lists, myst_roles, defined_references)
2700 };
2701 result.extend(reflow_elements(&tail_elements, &fallback_options));
2702 result
2703}
2704
2705fn reflow_elements_semantic(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
2709 let sentence_lines =
2711 reflow_elements_sentence_per_line(elements, &options.abbreviations, options.require_sentence_capital);
2712
2713 if options.line_length == 0 {
2716 return sentence_lines;
2717 }
2718
2719 let length_mode = options.length_mode;
2720 let mut result = Vec::new();
2721 for line in sentence_lines {
2722 if display_len(&line, length_mode) <= options.line_length {
2723 result.push(line);
2724 } else {
2725 result.extend(cascade_split_line(&line, options));
2726 }
2727 }
2728
2729 let min_line_len = ((options.line_length as f64) * MIN_SPLIT_RATIO) as usize;
2732 let mut merged: Vec<String> = Vec::with_capacity(result.len());
2733 for line in result {
2734 if !merged.is_empty() && display_len(&line, length_mode) < min_line_len && !line.trim().is_empty() {
2735 if is_standalone_parenthetical(&line) {
2738 merged.push(line);
2739 continue;
2740 }
2741
2742 let prev_ends_at_sentence = {
2744 let trimmed = merged.last().unwrap().trim_end();
2745 trimmed
2746 .chars()
2747 .rev()
2748 .find(|c| !matches!(c, '"' | '\'' | '\u{201D}' | '\u{2019}' | ')' | ']'))
2749 .is_some_and(|c| matches!(c, '.' | '!' | '?'))
2750 };
2751
2752 if !prev_ends_at_sentence {
2753 let prev = merged.last_mut().unwrap();
2754 let combined = format!("{prev} {line}");
2755 if display_len(&combined, length_mode) <= options.line_length {
2757 *prev = combined;
2758 continue;
2759 }
2760 }
2761 }
2762 merged.push(line);
2763 }
2764 merged
2765}
2766
2767fn rfind_safe_space(line: &str, element_spans: &[(usize, usize)]) -> Option<usize> {
2777 line.char_indices().rev().map(|(pos, _)| pos).find(|&pos| {
2778 line.as_bytes()[pos] == b' '
2779 && !element_spans.iter().any(|(s, e)| pos > *s && pos < *e)
2780 && !starts_block_construct(&line[pos + 1..])
2781 })
2782}
2783
2784fn break_before_attached(
2791 lines: &mut Vec<String>,
2792 current_line: &mut String,
2793 current_length: &mut usize,
2794 element_spans: &mut Vec<(usize, usize)>,
2795 attach: &str,
2796 separator: &str,
2797 length_mode: ReflowLengthMode,
2798) -> Option<usize> {
2799 let last_space = rfind_safe_space(current_line, element_spans)?;
2800 let before = current_line[..last_space]
2801 .trim_end_matches(is_breakable_whitespace)
2802 .to_string();
2803 let after = current_line[last_space + 1..].to_string();
2804 lines.push(before);
2805 let carried = after.len();
2806 *current_line = format!("{after}{separator}{attach}");
2807 *current_length = display_len(current_line, length_mode);
2808 element_spans.clear();
2809 Some(carried)
2810}
2811
2812fn reflow_elements(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
2814 let mut lines = Vec::new();
2815 let mut current_line = String::new();
2816 let mut current_length = 0;
2817 let mut current_line_element_spans: Vec<(usize, usize)> = Vec::new();
2819 let length_mode = options.length_mode;
2820
2821 for (idx, element) in elements.iter().enumerate() {
2822 let element_len = element.display_len(length_mode);
2823
2824 let is_adjacent_to_prev = if idx > 0 {
2833 match (&elements[idx - 1], element) {
2834 (Element::Text(t), _) => !t.is_empty() && !t.ends_with(is_breakable_whitespace),
2835 (_, Element::Text(t)) => !t.is_empty() && !t.starts_with(is_breakable_whitespace),
2836 _ => true,
2837 }
2838 } else {
2839 false
2840 };
2841
2842 if let Element::Text(text) = element {
2844 let has_leading_space = text.starts_with(is_breakable_whitespace);
2846 let words: Vec<&str> = split_breakable_words(text).collect();
2848
2849 for (i, word) in words.iter().enumerate() {
2850 let word_len = display_len(word, length_mode);
2851 let is_trailing_punct = word.chars().all(|c| {
2857 matches!(c, ',' | '.' | ':' | ';' | '!' | '?' | ')' | ']' | '}') || is_non_breaking_space(c)
2858 });
2859
2860 let is_first_adjacent = i == 0 && is_adjacent_to_prev;
2863
2864 if is_first_adjacent {
2865 if current_length + word_len > options.line_length
2867 && current_length > 0
2868 && break_before_attached(
2869 &mut lines,
2870 &mut current_line,
2871 &mut current_length,
2872 &mut current_line_element_spans,
2873 word,
2874 "",
2875 length_mode,
2876 )
2877 .is_some()
2878 {
2879 } else {
2884 current_line.push_str(word);
2885 current_length += word_len;
2886 }
2887 } else if current_length > 0 && current_length + 1 + word_len > options.line_length {
2888 if is_trailing_punct {
2889 if break_before_attached(
2896 &mut lines,
2897 &mut current_line,
2898 &mut current_length,
2899 &mut current_line_element_spans,
2900 word,
2901 " ",
2902 length_mode,
2903 )
2904 .is_none()
2905 {
2906 current_line.push(' ');
2907 current_line.push_str(word);
2908 current_length += 1 + word_len;
2909 }
2910 } else if !starts_block_construct(word) {
2911 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
2913 current_line = word.to_string();
2914 current_length = word_len;
2915 current_line_element_spans.clear();
2916 } else if break_before_attached(
2917 &mut lines,
2918 &mut current_line,
2919 &mut current_length,
2920 &mut current_line_element_spans,
2921 word,
2922 " ",
2923 length_mode,
2924 )
2925 .is_some()
2926 {
2927 } else {
2932 if i > 0 || has_leading_space {
2935 current_line.push(' ');
2936 current_length += 1;
2937 }
2938 current_line.push_str(word);
2939 current_length += word_len;
2940 }
2941 } else {
2942 let add_space = current_length > 0 && (i > 0 || has_leading_space);
2954 if add_space {
2955 current_line.push(' ');
2956 current_length += 1;
2957 }
2958 current_line.push_str(word);
2959 current_length += word_len;
2960 }
2961 }
2962 } else {
2963 let span_info = match element {
2964 Element::Italic { content, underscore } => {
2965 Some((content.as_str(), if *underscore { "_" } else { "*" }, false))
2966 }
2967 Element::Bold { content, underscore } => {
2968 Some((content.as_str(), if *underscore { "__" } else { "**" }, false))
2969 }
2970 Element::Strikethrough { content, double } => {
2971 Some((content.as_str(), if *double { "~~" } else { "~" }, false))
2972 }
2973 Element::Code { content, marker } => Some((content.as_str(), marker.as_str(), true)),
2974 _ => None,
2975 };
2976
2977 let breakable: Option<Vec<&str>> = match span_info {
2981 Some((content, _, is_code)) => {
2982 if is_code {
2983 (!options.atomic_spans && code_span_wraps_losslessly(content))
2984 .then(|| split_breakable_words(content).collect())
2985 } else {
2986 (!options.atomic_spans || element_len > options.line_length)
2987 .then(|| breakable_units(content, options.defined_references.as_ref(), options.attr_lists))
2988 .flatten()
2989 }
2990 }
2991 None => None,
2992 };
2993
2994 if let Some(words) = breakable {
2995 let (_, marker, is_code) = span_info.expect("breakable implies a span");
2996 let n = words.len();
2997 if n == 0 {
2998 let full = format!("{marker}{marker}");
3000 let full_len = display_len(&full, length_mode);
3001 if !is_adjacent_to_prev && current_length > 0 {
3002 current_line.push(' ');
3003 current_length += 1;
3004 }
3005 current_line.push_str(&full);
3006 current_length += full_len;
3007 } else {
3008 for (i, word) in words.iter().enumerate() {
3009 let is_first = i == 0;
3010 let is_last = i == n - 1;
3011
3012 let space_start = if is_first && is_code && word.starts_with('`') {
3013 " "
3014 } else {
3015 ""
3016 };
3017 let space_end = if is_last && is_code && word.ends_with('`') {
3018 " "
3019 } else {
3020 ""
3021 };
3022
3023 let word_str: String = match (is_first, is_last) {
3024 (true, true) => format!("{marker}{space_start}{word}{space_end}{marker}"),
3025 (true, false) => format!("{marker}{space_start}{word}"),
3026 (false, true) => format!("{word}{space_end}{marker}"),
3027 (false, false) => word.to_string(),
3028 };
3029 let word_len = display_len(&word_str, length_mode);
3030
3031 let needs_space = if is_first {
3032 !is_adjacent_to_prev && current_length > 0
3033 } else {
3034 current_length > 0
3035 };
3036
3037 if needs_space
3038 && current_length + 1 + word_len > options.line_length
3039 && !starts_block_construct(&word_str)
3040 {
3041 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
3042 current_line = word_str;
3043 current_length = word_len;
3044 current_line_element_spans.clear();
3045 } else {
3046 if needs_space {
3047 current_line.push(' ');
3048 current_length += 1;
3049 }
3050 current_line.push_str(&word_str);
3051 current_length += word_len;
3052 }
3053 }
3054 }
3055 } else {
3056 let element_str = format!("{element}");
3059
3060 if is_adjacent_to_prev {
3061 if current_length + element_len > options.line_length
3063 && let Some(carried) = break_before_attached(
3064 &mut lines,
3065 &mut current_line,
3066 &mut current_length,
3067 &mut current_line_element_spans,
3068 &element_str,
3069 "",
3070 length_mode,
3071 )
3072 {
3073 current_line_element_spans.push((carried, carried + element_str.len()));
3077 } else {
3078 let start = current_line.len();
3079 current_line.push_str(&element_str);
3080 current_length += element_len;
3081 current_line_element_spans.push((start, current_line.len()));
3082 }
3083 } else if current_length > 0 && current_length + 1 + element_len > options.line_length {
3084 if !starts_block_construct(&element_str) {
3085 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
3087 current_line.clone_from(&element_str);
3088 current_length = element_len;
3089 current_line_element_spans.clear();
3090 current_line_element_spans.push((0, element_str.len()));
3091 } else if let Some(carried) = break_before_attached(
3092 &mut lines,
3093 &mut current_line,
3094 &mut current_length,
3095 &mut current_line_element_spans,
3096 &element_str,
3097 " ",
3098 length_mode,
3099 ) {
3100 let start = carried + 1;
3104 current_line_element_spans.push((start, start + element_str.len()));
3105 } else {
3106 let ends_with_opener =
3109 current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
3110 if !ends_with_opener {
3111 current_line.push(' ');
3112 current_length += 1;
3113 }
3114 let start = current_line.len();
3115 current_line.push_str(&element_str);
3116 current_length += element_len;
3117 current_line_element_spans.push((start, current_line.len()));
3118 }
3119 } else {
3120 let ends_with_opener =
3122 current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
3123 if current_length > 0 && !ends_with_opener {
3124 current_line.push(' ');
3125 current_length += 1;
3126 }
3127 let start = current_line.len();
3128 current_line.push_str(&element_str);
3129 current_length += element_len;
3130 current_line_element_spans.push((start, current_line.len()));
3131 }
3132 }
3133 }
3134 }
3135
3136 if !current_line.is_empty() {
3138 lines.push(current_line.trim_end_matches(is_breakable_whitespace).to_string());
3139 }
3140
3141 lines
3142}
3143
3144pub fn reflow_markdown(content: &str, options: &ReflowOptions) -> String {
3146 let lines: Vec<&str> = content.lines().collect();
3147 let mut result = Vec::new();
3148 let mut i = 0;
3149
3150 while i < lines.len() {
3151 let line = lines[i];
3152 let trimmed = line.trim();
3153
3154 if trimmed.is_empty() {
3156 result.push(String::new());
3157 i += 1;
3158 continue;
3159 }
3160
3161 if trimmed.starts_with('#') {
3163 result.push(line.to_string());
3164 i += 1;
3165 continue;
3166 }
3167
3168 if trimmed.starts_with(":::") {
3170 result.push(line.to_string());
3171 i += 1;
3172 continue;
3173 }
3174
3175 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
3177 result.push(line.to_string());
3178 i += 1;
3179 while i < lines.len() {
3181 result.push(lines[i].to_string());
3182 if lines[i].trim().starts_with("```") || lines[i].trim().starts_with("~~~") {
3183 i += 1;
3184 break;
3185 }
3186 i += 1;
3187 }
3188 continue;
3189 }
3190
3191 if calculate_indentation_width_default(line) >= 4 {
3193 result.push(line.to_string());
3195 i += 1;
3196 while i < lines.len() {
3197 let next_line = lines[i];
3198 if calculate_indentation_width_default(next_line) >= 4 || next_line.trim().is_empty() {
3200 result.push(next_line.to_string());
3201 i += 1;
3202 } else {
3203 break;
3204 }
3205 }
3206 continue;
3207 }
3208
3209 if trimmed.starts_with('>') {
3211 let gt_pos = line.find('>').expect("'>' must exist since trimmed.starts_with('>')");
3214 let quote_prefix = line[0..=gt_pos].to_string();
3215 let quote_content = &line[quote_prefix.len()..].trim_start();
3216
3217 let reflowed = reflow_line(quote_content, options);
3218 for reflowed_line in &reflowed {
3219 result.push(format!("{quote_prefix} {reflowed_line}"));
3220 }
3221 i += 1;
3222 continue;
3223 }
3224
3225 if is_horizontal_rule(trimmed) {
3227 result.push(line.to_string());
3228 i += 1;
3229 continue;
3230 }
3231
3232 if is_unordered_list_marker(trimmed) || is_numbered_list_item(trimmed) {
3234 let indent = line.len() - line.trim_start().len();
3236 let indent_str = " ".repeat(indent);
3237
3238 let mut marker_end = indent;
3241 let mut content_start = indent;
3242
3243 if trimmed.chars().next().is_some_and(char::is_numeric) {
3244 if let Some(period_pos) = line[indent..].find('.') {
3246 marker_end = indent + period_pos + 1; content_start = marker_end;
3248 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
3252 content_start += 1;
3253 }
3254 }
3255 } else {
3256 marker_end = indent + 1; content_start = marker_end;
3259 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
3263 content_start += 1;
3264 }
3265 }
3266
3267 let min_continuation_indent = content_start;
3269
3270 let rest = &line[content_start..];
3273 if rest.starts_with("[ ] ") || rest.starts_with("[x] ") || rest.starts_with("[X] ") {
3274 marker_end = content_start + 3; content_start += 4; }
3277
3278 let marker = &line[indent..marker_end];
3279
3280 let mut list_content = vec![trim_preserving_hard_break(&line[content_start..])];
3283 i += 1;
3284
3285 while i < lines.len() {
3289 let next_line = lines[i];
3290 let next_trimmed = next_line.trim();
3291
3292 if is_block_boundary(next_trimmed) {
3294 break;
3295 }
3296
3297 let next_indent = next_line.len() - next_line.trim_start().len();
3299 if next_indent >= min_continuation_indent {
3300 let trimmed_start = next_line.trim_start();
3303 list_content.push(trim_preserving_hard_break(trimmed_start));
3304 i += 1;
3305 } else {
3306 break;
3308 }
3309 }
3310
3311 let combined_content = if options.preserve_breaks {
3314 list_content[0].clone()
3315 } else {
3316 let has_hard_breaks = list_content.iter().any(|line| has_hard_break(line));
3318 if has_hard_breaks {
3319 list_content.join("\n")
3321 } else {
3322 list_content.join(" ")
3324 }
3325 };
3326
3327 let trimmed_marker = marker;
3329 let continuation_spaces = if let Some(max_indent) = options.max_list_continuation_indent {
3330 indent + (content_start - indent).min(max_indent)
3333 } else {
3334 content_start
3335 };
3336
3337 let prefix_length = indent + trimmed_marker.len() + 1;
3339
3340 let adjusted_options = ReflowOptions {
3342 line_length: options.line_length.saturating_sub(prefix_length),
3343 ..options.clone()
3344 };
3345
3346 let reflowed = reflow_line(&combined_content, &adjusted_options);
3347 for (j, reflowed_line) in reflowed.iter().enumerate() {
3348 if j == 0 {
3349 result.push(format!("{indent_str}{trimmed_marker} {reflowed_line}"));
3350 } else {
3351 let continuation_indent = " ".repeat(continuation_spaces);
3353 result.push(format!("{continuation_indent}{reflowed_line}"));
3354 }
3355 }
3356 continue;
3357 }
3358
3359 if crate::utils::table_utils::TableUtils::is_potential_table_row(line) {
3361 result.push(line.to_string());
3362 i += 1;
3363 continue;
3364 }
3365
3366 if trimmed.starts_with('[') && line.contains("]:") {
3368 result.push(line.to_string());
3369 i += 1;
3370 continue;
3371 }
3372
3373 if is_definition_list_item(trimmed) {
3375 result.push(line.to_string());
3376 i += 1;
3377 continue;
3378 }
3379
3380 let mut is_single_line_paragraph = true;
3382 if i + 1 < lines.len() {
3383 let next_trimmed = lines[i + 1].trim();
3384 if !is_block_boundary(next_trimmed) {
3386 is_single_line_paragraph = false;
3387 }
3388 }
3389
3390 if is_single_line_paragraph && display_len(line, options.length_mode) <= options.line_length {
3392 result.push(line.to_string());
3393 i += 1;
3394 continue;
3395 }
3396
3397 let mut paragraph_parts = Vec::new();
3399 let mut current_part = vec![line];
3400 i += 1;
3401
3402 if options.preserve_breaks {
3404 let hard_break_type = if line.strip_suffix('\r').unwrap_or(line).ends_with('\\') {
3406 Some("\\")
3407 } else if line.ends_with(" ") {
3408 Some(" ")
3409 } else {
3410 None
3411 };
3412 let reflowed = reflow_line(line, options);
3413
3414 if let Some(break_marker) = hard_break_type {
3416 if !reflowed.is_empty() {
3417 let mut reflowed_with_break = reflowed;
3418 let last_idx = reflowed_with_break.len() - 1;
3419 if !has_hard_break(&reflowed_with_break[last_idx]) {
3420 reflowed_with_break[last_idx].push_str(break_marker);
3421 }
3422 result.extend(reflowed_with_break);
3423 }
3424 } else {
3425 result.extend(reflowed);
3426 }
3427 } else {
3428 while i < lines.len() {
3430 let prev_line = if !current_part.is_empty() {
3431 current_part.last().unwrap()
3432 } else {
3433 ""
3434 };
3435 let next_line = lines[i];
3436 let next_trimmed = next_line.trim();
3437
3438 if is_block_boundary(next_trimmed) {
3440 break;
3441 }
3442
3443 let prev_trimmed = prev_line.trim();
3446 let abbreviations = get_abbreviations(&options.abbreviations);
3447 let ends_with_sentence = (prev_trimmed.ends_with('.')
3448 || prev_trimmed.ends_with('!')
3449 || prev_trimmed.ends_with('?')
3450 || prev_trimmed.ends_with(".*")
3451 || prev_trimmed.ends_with("!*")
3452 || prev_trimmed.ends_with("?*")
3453 || prev_trimmed.ends_with("._")
3454 || prev_trimmed.ends_with("!_")
3455 || prev_trimmed.ends_with("?_")
3456 || 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(".\u{201D}")
3464 || prev_trimmed.ends_with("!\u{201D}")
3465 || prev_trimmed.ends_with("?\u{201D}")
3466 || prev_trimmed.ends_with(".\u{2019}")
3467 || prev_trimmed.ends_with("!\u{2019}")
3468 || prev_trimmed.ends_with("?\u{2019}"))
3469 && !text_ends_with_abbreviation(
3470 prev_trimmed.trim_end_matches(['*', '_', '"', '\'', '\u{201D}', '\u{2019}']),
3471 &abbreviations,
3472 );
3473
3474 if has_hard_break(prev_line) || (options.sentence_per_line && ends_with_sentence) {
3475 paragraph_parts.push(current_part.join(" "));
3477 current_part = vec![next_line];
3478 } else {
3479 current_part.push(next_line);
3480 }
3481 i += 1;
3482 }
3483
3484 if !current_part.is_empty() {
3486 if current_part.len() == 1 {
3487 paragraph_parts.push(current_part[0].to_string());
3489 } else {
3490 paragraph_parts.push(current_part.join(" "));
3491 }
3492 }
3493
3494 for (j, part) in paragraph_parts.iter().enumerate() {
3496 let reflowed = reflow_line(part, options);
3497 result.extend(reflowed);
3498
3499 if j < paragraph_parts.len() - 1 && !result.is_empty() && !options.sentence_per_line {
3503 let last_idx = result.len() - 1;
3504 if !has_hard_break(&result[last_idx]) {
3505 result[last_idx].push_str(" ");
3506 }
3507 }
3508 }
3509 }
3510 }
3511
3512 let result_text = result.join("\n");
3514 if content.ends_with('\n') && !result_text.ends_with('\n') {
3515 format!("{result_text}\n")
3516 } else {
3517 result_text
3518 }
3519}
3520
3521#[derive(Debug, Clone)]
3523pub struct ParagraphReflow {
3524 pub start_byte: usize,
3526 pub end_byte: usize,
3528 pub reflowed_text: String,
3530}
3531
3532#[derive(Debug, Clone)]
3538pub struct BlockquoteLineData {
3539 pub(crate) content: String,
3541 pub(crate) is_explicit: bool,
3543 pub(crate) prefix: Option<String>,
3545}
3546
3547impl BlockquoteLineData {
3548 pub fn explicit(content: String, prefix: String) -> Self {
3550 Self {
3551 content,
3552 is_explicit: true,
3553 prefix: Some(prefix),
3554 }
3555 }
3556
3557 pub fn lazy(content: String) -> Self {
3559 Self {
3560 content,
3561 is_explicit: false,
3562 prefix: None,
3563 }
3564 }
3565}
3566
3567#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3569pub enum BlockquoteContinuationStyle {
3570 Explicit,
3571 Lazy,
3572}
3573
3574pub fn blockquote_continuation_style(lines: &[BlockquoteLineData]) -> BlockquoteContinuationStyle {
3582 let mut explicit_count = 0usize;
3583 let mut lazy_count = 0usize;
3584
3585 for line in lines.iter().skip(1) {
3586 if line.is_explicit {
3587 explicit_count += 1;
3588 } else {
3589 lazy_count += 1;
3590 }
3591 }
3592
3593 if explicit_count > 0 && lazy_count == 0 {
3594 BlockquoteContinuationStyle::Explicit
3595 } else if lazy_count > 0 && explicit_count == 0 {
3596 BlockquoteContinuationStyle::Lazy
3597 } else if explicit_count >= lazy_count {
3598 BlockquoteContinuationStyle::Explicit
3599 } else {
3600 BlockquoteContinuationStyle::Lazy
3601 }
3602}
3603
3604pub fn dominant_blockquote_prefix(lines: &[BlockquoteLineData], fallback: &str) -> String {
3609 let mut counts: std::collections::HashMap<String, (usize, usize)> = std::collections::HashMap::new();
3610
3611 for (idx, line) in lines.iter().enumerate() {
3612 let Some(prefix) = line.prefix.as_ref() else {
3613 continue;
3614 };
3615 counts
3616 .entry(prefix.clone())
3617 .and_modify(|entry| entry.0 += 1)
3618 .or_insert((1, idx));
3619 }
3620
3621 counts
3622 .into_iter()
3623 .max_by(|(_, (count_a, first_idx_a)), (_, (count_b, first_idx_b))| {
3624 count_a.cmp(count_b).then_with(|| first_idx_b.cmp(first_idx_a))
3625 })
3626 .map_or_else(|| fallback.to_string(), |(prefix, _)| prefix)
3627}
3628
3629pub(crate) fn should_force_explicit_blockquote_line(content_line: &str) -> bool {
3634 let trimmed = content_line.trim_start();
3635 trimmed.starts_with('>')
3636 || trimmed.starts_with('#')
3637 || trimmed.starts_with("```")
3638 || trimmed.starts_with("~~~")
3639 || is_unordered_list_marker(trimmed)
3640 || is_numbered_list_item(trimmed)
3641 || is_horizontal_rule(trimmed)
3642 || is_definition_list_item(trimmed)
3643 || (trimmed.starts_with('[') && trimmed.contains("]:"))
3644 || trimmed.starts_with(":::")
3645 || (trimmed.starts_with('<')
3646 && !trimmed.starts_with("<http")
3647 && !trimmed.starts_with("<https")
3648 && !trimmed.starts_with("<mailto:"))
3649}
3650
3651pub fn reflow_blockquote_content(
3660 lines: &[BlockquoteLineData],
3661 explicit_prefix: &str,
3662 continuation_style: BlockquoteContinuationStyle,
3663 options: &ReflowOptions,
3664) -> Vec<String> {
3665 let content_strs: Vec<&str> = lines.iter().map(|l| l.content.as_str()).collect();
3666 let segments = split_into_segments_strs(&content_strs);
3667 let mut reflowed_content_lines: Vec<String> = Vec::new();
3668
3669 for segment in segments {
3670 let hard_break_type = segment.last().and_then(|&line| {
3671 let line = line.strip_suffix('\r').unwrap_or(line);
3672 if line.ends_with('\\') {
3673 Some("\\")
3674 } else if line.ends_with(" ") {
3675 Some(" ")
3676 } else {
3677 None
3678 }
3679 });
3680
3681 let pieces: Vec<&str> = segment
3682 .iter()
3683 .map(|&line| {
3684 if let Some(l) = line.strip_suffix('\\') {
3685 l.trim_end()
3686 } else if let Some(l) = line.strip_suffix(" ") {
3687 l.trim_end()
3688 } else {
3689 line.trim_end()
3690 }
3691 })
3692 .collect();
3693
3694 let segment_text = pieces.join(" ");
3695 let segment_text = segment_text.trim();
3696 if segment_text.is_empty() {
3697 continue;
3698 }
3699
3700 let mut reflowed = reflow_line(segment_text, options);
3701 if let Some(break_marker) = hard_break_type
3702 && !reflowed.is_empty()
3703 {
3704 let last_idx = reflowed.len() - 1;
3705 if !has_hard_break(&reflowed[last_idx]) {
3706 reflowed[last_idx].push_str(break_marker);
3707 }
3708 }
3709 reflowed_content_lines.extend(reflowed);
3710 }
3711
3712 let mut styled_lines: Vec<String> = Vec::new();
3713 for (idx, line) in reflowed_content_lines.iter().enumerate() {
3714 let force_explicit = idx == 0
3715 || continuation_style == BlockquoteContinuationStyle::Explicit
3716 || should_force_explicit_blockquote_line(line);
3717 if force_explicit {
3718 styled_lines.push(format!("{explicit_prefix}{line}"));
3719 } else {
3720 styled_lines.push(line.clone());
3721 }
3722 }
3723
3724 styled_lines
3725}
3726
3727fn is_blockquote_content_boundary(content: &str) -> bool {
3728 let trimmed = content.trim();
3729 trimmed.is_empty()
3730 || is_block_boundary(trimmed)
3731 || crate::utils::table_utils::TableUtils::is_potential_table_row(content)
3732 || trimmed.starts_with(":::")
3733 || crate::utils::is_template_directive_only(content)
3734 || is_standalone_attr_list(content)
3735 || is_snippet_block_delimiter(content)
3736}
3737
3738fn split_into_segments_strs<'a>(lines: &[&'a str]) -> Vec<Vec<&'a str>> {
3739 let mut segments = Vec::new();
3740 let mut current = Vec::new();
3741
3742 for &line in lines {
3743 current.push(line);
3744 if has_hard_break(line) {
3745 segments.push(current);
3746 current = Vec::new();
3747 }
3748 }
3749
3750 if !current.is_empty() {
3751 segments.push(current);
3752 }
3753
3754 segments
3755}
3756
3757fn reflow_blockquote_paragraph_at_line(
3758 content: &str,
3759 lines: &[&str],
3760 target_idx: usize,
3761 options: &ReflowOptions,
3762) -> Option<ParagraphReflow> {
3763 let mut anchor_idx = target_idx;
3764 let mut target_level = if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[target_idx]) {
3765 parsed.nesting_level
3766 } else {
3767 let mut found = None;
3768 let mut idx = target_idx;
3769 loop {
3770 if lines[idx].trim().is_empty() {
3771 break;
3772 }
3773 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[idx]) {
3774 found = Some((idx, parsed.nesting_level));
3775 break;
3776 }
3777 if idx == 0 {
3778 break;
3779 }
3780 idx -= 1;
3781 }
3782 let (idx, level) = found?;
3783 anchor_idx = idx;
3784 level
3785 };
3786
3787 let mut para_start = anchor_idx;
3789 while para_start > 0 {
3790 let prev_idx = para_start - 1;
3791 let prev_line = lines[prev_idx];
3792
3793 if prev_line.trim().is_empty() {
3794 break;
3795 }
3796
3797 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(prev_line) {
3798 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
3799 break;
3800 }
3801 para_start = prev_idx;
3802 continue;
3803 }
3804
3805 let prev_lazy = prev_line.trim_start();
3806 if is_blockquote_content_boundary(prev_lazy) {
3807 break;
3808 }
3809 para_start = prev_idx;
3810 }
3811
3812 while para_start < lines.len() {
3814 let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[para_start]) else {
3815 para_start += 1;
3816 continue;
3817 };
3818 target_level = parsed.nesting_level;
3819 break;
3820 }
3821
3822 if para_start >= lines.len() || para_start > target_idx {
3823 return None;
3824 }
3825
3826 let mut collected: Vec<(usize, BlockquoteLineData)> = Vec::new();
3829 let mut idx = para_start;
3830 while idx < lines.len() {
3831 if !collected.is_empty() && has_hard_break(&collected[collected.len() - 1].1.content) {
3832 break;
3833 }
3834
3835 let line = lines[idx];
3836 if line.trim().is_empty() {
3837 break;
3838 }
3839
3840 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(line) {
3841 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
3842 break;
3843 }
3844 collected.push((
3845 idx,
3846 BlockquoteLineData::explicit(trim_preserving_hard_break(parsed.content), parsed.prefix.to_string()),
3847 ));
3848 idx += 1;
3849 continue;
3850 }
3851
3852 let lazy_content = line.trim_start();
3853 if is_blockquote_content_boundary(lazy_content) {
3854 break;
3855 }
3856
3857 collected.push((idx, BlockquoteLineData::lazy(trim_preserving_hard_break(lazy_content))));
3858 idx += 1;
3859 }
3860
3861 if collected.is_empty() {
3862 return None;
3863 }
3864
3865 let para_end = collected[collected.len() - 1].0;
3866 if target_idx < para_start || target_idx > para_end {
3867 return None;
3868 }
3869
3870 let line_data: Vec<BlockquoteLineData> = collected.iter().map(|(_, d)| d.clone()).collect();
3871
3872 let fallback_prefix = line_data
3873 .iter()
3874 .find_map(|d| d.prefix.clone())
3875 .unwrap_or_else(|| "> ".to_string());
3876 let explicit_prefix = dominant_blockquote_prefix(&line_data, &fallback_prefix);
3877 let continuation_style = blockquote_continuation_style(&line_data);
3878
3879 let adjusted_line_length = options
3880 .line_length
3881 .saturating_sub(display_len(&explicit_prefix, options.length_mode))
3882 .max(1);
3883
3884 let adjusted_options = ReflowOptions {
3885 line_length: adjusted_line_length,
3886 ..options.clone()
3887 };
3888
3889 let styled_lines = reflow_blockquote_content(&line_data, &explicit_prefix, continuation_style, &adjusted_options);
3890
3891 if styled_lines.is_empty() {
3892 return None;
3893 }
3894
3895 let mut start_byte = 0;
3897 for line in lines.iter().take(para_start) {
3898 start_byte += line.len() + 1;
3899 }
3900
3901 let mut end_byte = start_byte;
3902 for line in lines.iter().take(para_end + 1).skip(para_start) {
3903 end_byte += line.len() + 1;
3904 }
3905
3906 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
3907 if !includes_trailing_newline {
3908 end_byte -= 1;
3909 }
3910
3911 let reflowed_joined = styled_lines.join("\n");
3912 let reflowed_text = if includes_trailing_newline {
3913 if reflowed_joined.ends_with('\n') {
3914 reflowed_joined
3915 } else {
3916 format!("{reflowed_joined}\n")
3917 }
3918 } else if reflowed_joined.ends_with('\n') {
3919 reflowed_joined.trim_end_matches('\n').to_string()
3920 } else {
3921 reflowed_joined
3922 };
3923
3924 Some(ParagraphReflow {
3925 start_byte,
3926 end_byte,
3927 reflowed_text,
3928 })
3929}
3930
3931pub fn reflow_paragraph_at_line(content: &str, line_number: usize, line_length: usize) -> Option<ParagraphReflow> {
3949 reflow_paragraph_at_line_with_mode(content, line_number, line_length, ReflowLengthMode::default())
3950}
3951
3952pub fn reflow_paragraph_at_line_with_mode(
3954 content: &str,
3955 line_number: usize,
3956 line_length: usize,
3957 length_mode: ReflowLengthMode,
3958) -> Option<ParagraphReflow> {
3959 let options = ReflowOptions {
3960 line_length,
3961 length_mode,
3962 ..Default::default()
3963 };
3964 reflow_paragraph_at_line_with_options(content, line_number, &options)
3965}
3966
3967pub fn reflow_paragraph_at_line_with_options(
3978 content: &str,
3979 line_number: usize,
3980 options: &ReflowOptions,
3981) -> Option<ParagraphReflow> {
3982 if line_number == 0 {
3983 return None;
3984 }
3985
3986 let lines: Vec<&str> = content.lines().collect();
3987
3988 if line_number > lines.len() {
3990 return None;
3991 }
3992
3993 let target_idx = line_number - 1; let target_line = lines[target_idx];
3995 let trimmed = target_line.trim();
3996
3997 if let Some(blockquote_reflow) = reflow_blockquote_paragraph_at_line(content, &lines, target_idx, options) {
4000 return Some(blockquote_reflow);
4001 }
4002
4003 if is_paragraph_boundary(trimmed, target_line) {
4005 return None;
4006 }
4007
4008 let mut para_start = target_idx;
4010 while para_start > 0 {
4011 let prev_idx = para_start - 1;
4012 let prev_line = lines[prev_idx];
4013 let prev_trimmed = prev_line.trim();
4014
4015 if is_paragraph_boundary(prev_trimmed, prev_line) {
4017 break;
4018 }
4019
4020 para_start = prev_idx;
4021 }
4022
4023 let mut para_end = target_idx;
4025 while para_end + 1 < lines.len() {
4026 let next_idx = para_end + 1;
4027 let next_line = lines[next_idx];
4028 let next_trimmed = next_line.trim();
4029
4030 if is_paragraph_boundary(next_trimmed, next_line) {
4032 break;
4033 }
4034
4035 para_end = next_idx;
4036 }
4037
4038 let paragraph_lines = &lines[para_start..=para_end];
4040
4041 let mut start_byte = 0;
4043 for line in lines.iter().take(para_start) {
4044 start_byte += line.len() + 1; }
4046
4047 let mut end_byte = start_byte;
4048 for line in paragraph_lines {
4049 end_byte += line.len() + 1; }
4051
4052 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
4055
4056 if !includes_trailing_newline {
4058 end_byte -= 1;
4059 }
4060
4061 let paragraph_text = paragraph_lines.join("\n");
4063
4064 let reflowed = reflow_markdown(¶graph_text, options);
4066
4067 let reflowed_text = if includes_trailing_newline {
4071 if reflowed.ends_with('\n') {
4073 reflowed
4074 } else {
4075 format!("{reflowed}\n")
4076 }
4077 } else {
4078 if reflowed.ends_with('\n') {
4080 reflowed.trim_end_matches('\n').to_string()
4081 } else {
4082 reflowed
4083 }
4084 };
4085
4086 Some(ParagraphReflow {
4087 start_byte,
4088 end_byte,
4089 reflowed_text,
4090 })
4091}
4092fn decompose_code_span(raw: &str) -> Option<(&str, &str)> {
4098 let marker_len = raw.bytes().take_while(|&b| b == b'`').count();
4099 if marker_len == 0 {
4100 return None;
4101 }
4102 let marker = &raw[..marker_len];
4103 if raw.len() < marker_len * 2 {
4104 return None;
4105 }
4106 let content = &raw[marker_len..raw.len() - marker_len];
4107 Some((content, marker))
4108}
4109
4110#[cfg(test)]
4111mod tests {
4112 use super::*;
4113
4114 #[test]
4115 fn cascade_split_line_handles_a_very_long_line_without_overflowing() {
4116 let words: Vec<String> = (0..4000).map(|i| format!("word{i}")).collect();
4122 let line = words.join(" ");
4123
4124 let options = ReflowOptions {
4125 line_length: 80,
4126 length_mode: ReflowLengthMode::Chars,
4127 ..Default::default()
4128 };
4129 let out = cascade_split_line(&line, &options);
4130
4131 assert!(out.len() > 1, "a very long line should split into many lines");
4132 for segment in &out {
4133 assert!(
4134 display_len(segment, ReflowLengthMode::Chars) <= 80 || !segment.contains(' '),
4135 "each wrapped line should fit the width (or be a single unbreakable token)"
4136 );
4137 }
4138 let rejoined = out.join(" ");
4140 let original_words: Vec<&str> = line.split(' ').collect();
4141 let result_words: Vec<&str> = rejoined.split_whitespace().collect();
4142 assert_eq!(original_words, result_words, "reflow must preserve all words in order");
4143 }
4144
4145 #[test]
4150 fn test_helper_function_text_ends_with_abbreviation() {
4151 let abbreviations = get_abbreviations(&None);
4153
4154 assert!(text_ends_with_abbreviation("Dr.", &abbreviations));
4156 assert!(text_ends_with_abbreviation("word Dr.", &abbreviations));
4157 assert!(text_ends_with_abbreviation("e.g.", &abbreviations));
4158 assert!(text_ends_with_abbreviation("i.e.", &abbreviations));
4159 assert!(text_ends_with_abbreviation("Mr.", &abbreviations));
4160 assert!(text_ends_with_abbreviation("Mrs.", &abbreviations));
4161 assert!(text_ends_with_abbreviation("Ms.", &abbreviations));
4162 assert!(text_ends_with_abbreviation("Prof.", &abbreviations));
4163
4164 assert!(!text_ends_with_abbreviation("etc.", &abbreviations));
4166 assert!(!text_ends_with_abbreviation("paradigms.", &abbreviations));
4167 assert!(!text_ends_with_abbreviation("programs.", &abbreviations));
4168 assert!(!text_ends_with_abbreviation("items.", &abbreviations));
4169 assert!(!text_ends_with_abbreviation("systems.", &abbreviations));
4170 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)); }
4176
4177 #[test]
4178 fn test_footnote_after_period_splits_sentence() {
4179 let text = "First sentence.[^1] Second sentence.";
4183 let sentences = split_into_sentences(text);
4184 assert_eq!(
4185 sentences,
4186 vec!["First sentence.[^1]".to_string(), "Second sentence.".to_string()],
4187 "footnote glued to the period should keep the boundary and stay attached to the first sentence"
4188 );
4189 }
4190
4191 #[test]
4192 fn test_multiple_consecutive_footnotes_after_period_splits_sentence() {
4193 let text = "Notes here.[^1][^2] Second sentence.";
4195 let sentences = split_into_sentences(text);
4196 assert_eq!(
4197 sentences,
4198 vec!["Notes here.[^1][^2]".to_string(), "Second sentence.".to_string()]
4199 );
4200 }
4201
4202 #[test]
4203 fn test_footnote_before_period_still_splits_sentence() {
4204 let text = "Annotation here[^1]. Second sentence.";
4208 let sentences = split_into_sentences(text);
4209 assert_eq!(
4210 sentences,
4211 vec!["Annotation here[^1].".to_string(), "Second sentence.".to_string()]
4212 );
4213 }
4214
4215 #[test]
4216 fn test_mid_sentence_footnote_does_not_split() {
4217 let text = "The system word[^1] more words. Next sentence.";
4220 let sentences = split_into_sentences(text);
4221 assert_eq!(
4222 sentences,
4223 vec![
4224 "The system word[^1] more words.".to_string(),
4225 "Next sentence.".to_string()
4226 ]
4227 );
4228 }
4229
4230 #[test]
4231 fn test_bare_numeric_bracket_after_period_does_not_split() {
4232 let text = "Citation here.[1] Second sentence.";
4235 let sentences = split_into_sentences(text);
4236 assert_eq!(
4237 sentences,
4238 vec![text.to_string()],
4239 "a bare numeric bracket must not be treated as a sentence boundary"
4240 );
4241 }
4242
4243 #[test]
4244 fn test_footnote_glued_to_following_word_does_not_split() {
4245 let text = "First sentence.[^1]Continued glued text.";
4248 let sentences = split_into_sentences(text);
4249 assert_eq!(sentences, vec![text.to_string()]);
4250 }
4251
4252 #[test]
4253 fn test_footnote_at_end_of_text_is_preserved() {
4254 let text = "Sentence.[^1]";
4257 let sentences = split_into_sentences(text);
4258 assert_eq!(sentences, vec![text.to_string()]);
4259 }
4260
4261 #[test]
4262 fn test_abbreviation_before_footnote_does_not_split() {
4263 let text = "See the notes, e.g.[^1] this one.";
4266 let sentences = split_into_sentences(text);
4267 assert_eq!(
4268 sentences,
4269 vec![text.to_string()],
4270 "e.g. is an abbreviation, not a sentence boundary"
4271 );
4272 }
4273
4274 #[test]
4275 fn test_is_unordered_list_marker() {
4276 assert!(is_unordered_list_marker("- item"));
4278 assert!(is_unordered_list_marker("* item"));
4279 assert!(is_unordered_list_marker("+ item"));
4280 assert!(is_unordered_list_marker("-")); assert!(is_unordered_list_marker("*"));
4282 assert!(is_unordered_list_marker("+"));
4283
4284 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")); }
4295
4296 #[test]
4297 fn test_is_block_boundary() {
4298 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"));
4320 assert!(!is_block_boundary("*emphasis*")); assert!(!is_block_boundary("[link](url)")); assert!(!is_block_boundary("some words here"));
4323 }
4324
4325 #[test]
4326 fn test_definition_list_boundary_in_single_line_paragraph() {
4327 let options = ReflowOptions {
4330 line_length: 80,
4331 ..Default::default()
4332 };
4333 let input = "Term\n: Definition of the term";
4334 let result = reflow_markdown(input, &options);
4335 assert!(
4337 result.contains(": Definition"),
4338 "Definition list item should not be merged into previous line. Got: {result:?}"
4339 );
4340 let lines: Vec<&str> = result.lines().collect();
4341 assert_eq!(lines.len(), 2, "Should remain two separate lines. Got: {lines:?}");
4342 assert_eq!(lines[0], "Term");
4343 assert_eq!(lines[1], ": Definition of the term");
4344 }
4345
4346 #[test]
4347 fn test_is_paragraph_boundary() {
4348 assert!(is_paragraph_boundary("# Heading", "# Heading"));
4350 assert!(is_paragraph_boundary("- item", "- item"));
4351 assert!(is_paragraph_boundary(":::", ":::"));
4352 assert!(is_paragraph_boundary(": definition", ": definition"));
4353
4354 assert!(is_paragraph_boundary("code", " code"));
4356 assert!(is_paragraph_boundary("code", "\tcode"));
4357
4358 assert!(is_paragraph_boundary("| a | b |", "| a | b |"));
4360 assert!(is_paragraph_boundary("a | b", "a | b")); assert!(!is_paragraph_boundary("regular text", "regular text"));
4364 assert!(!is_paragraph_boundary("text", " text")); }
4366
4367 #[test]
4368 fn test_div_marker_boundary_in_reflow_paragraph_at_line() {
4369 let content = "Some paragraph text here.\n\n::: {.callout-note}\nThis is a callout.\n:::\n";
4372 let result = reflow_paragraph_at_line(content, 3, 80);
4374 assert!(result.is_none(), "Div marker line should not be reflowed");
4375 }
4376
4377 #[test]
4378 fn starts_block_construct_detects_block_openers() {
4379 for case in ["- item", "-", "* item", "*", "+ item", "+", "-\titem"] {
4381 assert!(starts_block_construct(case), "bullet: {case:?}");
4382 }
4383 for case in ["1. item", "1) item", "9. x", "123456789. x", "1.", "42) x"] {
4385 assert!(starts_block_construct(case), "ordered: {case:?}");
4386 }
4387 for case in ["> quote", ">quote", ">"] {
4389 assert!(starts_block_construct(case), "blockquote: {case:?}");
4390 }
4391 for case in ["# heading", "###### h6", "#", "##"] {
4393 assert!(starts_block_construct(case), "heading: {case:?}");
4394 }
4395 for case in ["```", "```rust", "````", "~~~", "~~~text"] {
4397 assert!(starts_block_construct(case), "fence: {case:?}");
4398 }
4399 for case in ["---", "--", "===", "=", "***", "___", "_ _ _", "- - -"] {
4401 assert!(starts_block_construct(case), "setext/thematic: {case:?}");
4402 }
4403 for case in [
4406 "[^1]: text",
4407 "[^note]:",
4408 "[ref]: http://example.com",
4409 "[wat]: url follows",
4410 ] {
4411 assert!(starts_block_construct(case), "definition: {case:?}");
4412 }
4413 for case in ["<div>content", "</div>", "<p>text", "<table>", "<pre>code", "<h1>x"] {
4415 assert!(starts_block_construct(case), "html block: {case:?}");
4416 }
4417 }
4418
4419 #[test]
4420 fn starts_block_construct_allows_ordinary_prose() {
4421 for case in [
4422 "",
4423 "word",
4424 "-5 degrees",
4425 "--flag",
4426 "-item",
4427 "#hashtag",
4428 "####### seven hashes is not a heading",
4429 "1.5 million",
4430 "1234567890. ten digits is not a list marker",
4431 "1:30 pm",
4432 "*emphasis*",
4433 "**bold** text",
4434 "__bold__ text",
4435 "_emphasis_ text",
4436 "`code` span",
4437 "`` double backtick span ``",
4438 "~~strikethrough~~",
4439 "=x",
4440 "== ==",
4441 "(parenthetical)",
4442 "[link](url)",
4443 "[text][ref] more",
4444 "[bracketed] aside",
4445 "[a](b) [ref]: first bracket is a link, not a label",
4446 "[esc\\]: not a close] text",
4447 "<span>inline</span>",
4448 "<b>bold</b>",
4449 "<https://example.com> autolink",
4450 "<mailto:a@b.com>",
4451 "<notarealtag>",
4452 ] {
4453 assert!(!starts_block_construct(case), "prose: {case:?}");
4454 }
4455 }
4456
4457 #[test]
4458 fn merge_block_construct_continuations_merges_marker_led_lines() {
4459 let lines = vec![
4460 "First sentence?".to_string(),
4461 "- looks like a list item".to_string(),
4462 "Second sentence.".to_string(),
4463 ];
4464 assert_eq!(
4465 merge_block_construct_continuations(lines),
4466 vec![
4467 "First sentence? - looks like a list item".to_string(),
4468 "Second sentence.".to_string(),
4469 ]
4470 );
4471
4472 let lines = vec!["- real list content".to_string(), "continuation".to_string()];
4475 assert_eq!(
4476 merge_block_construct_continuations(lines.clone()),
4477 lines,
4478 "first line must never be merged"
4479 );
4480 }
4481
4482 #[test]
4483 fn wrap_never_starts_a_line_with_a_block_marker() {
4484 let options = ReflowOptions {
4485 line_length: 25,
4486 ..Default::default()
4487 };
4488 let lines = reflow_line(
4491 "Some words here and then - a dash clause that wraps around the limit.",
4492 &options,
4493 );
4494 assert_eq!(
4495 lines,
4496 vec![
4497 "Some words here and",
4498 "then - a dash clause that",
4499 "wraps around the limit."
4500 ]
4501 );
4502
4503 for input in [
4505 "Alpha beta gamma delta epsilon - dash clause here to wrap",
4506 "Alpha beta gamma delta epsilon > quote lookalike here to wrap",
4507 "Alpha beta gamma delta epsilon # heading lookalike here to wrap",
4508 "Alpha beta gamma delta epsilon 1. ordered lookalike here to wrap",
4509 "Alpha beta gamma delta epsilon * star clause here to wrap",
4510 "Alpha beta gamma delta epsilon + plus clause here to wrap",
4511 ] {
4512 for width in 10..40 {
4513 let options = ReflowOptions {
4514 line_length: width,
4515 ..Default::default()
4516 };
4517 for line in reflow_line(input, &options) {
4518 assert!(
4519 !starts_block_construct(&line),
4520 "width {width}: wrapped line opens a block construct: {line:?} (input {input:?})"
4521 );
4522 }
4523 }
4524 }
4525 }
4526
4527 #[test]
4528 fn sentence_per_line_keeps_block_markers_mid_line() {
4529 let options = ReflowOptions {
4530 line_length: 80,
4531 sentence_per_line: true,
4532 ..Default::default()
4533 };
4534 let lines = reflow_line(
4537 "Google Calendar (Can't we get rid of this dependency? - I don't really see the need)",
4538 &options,
4539 );
4540 assert_eq!(
4541 lines,
4542 vec!["Google Calendar (Can't we get rid of this dependency? - I don't really see the need)".to_string()]
4543 );
4544
4545 let lines = reflow_line("See section 4? # is the marker we use. Fine.", &options);
4547 assert_eq!(lines, vec!["See section 4? # is the marker we use.", "Fine."]);
4548
4549 let lines = reflow_line("Is this a problem? > I quote someone here.", &options);
4550 assert_eq!(lines, vec!["Is this a problem? > I quote someone here."]);
4551
4552 let lines = reflow_line("Another case! 1. Not a list. More text follows here.", &options);
4553 for line in &lines {
4554 assert!(
4555 !starts_block_construct(line),
4556 "sentence-per-line output opens a block construct: {line:?}"
4557 );
4558 }
4559 }
4560
4561 #[test]
4562 fn inline_math_directly_after_display_math_stays_atomic() {
4563 let options = ReflowOptions {
4571 line_length: 8,
4572 ..Default::default()
4573 };
4574 let lines = reflow_line("$$a$$$bb cc dd$ x", &options);
4575 assert_eq!(lines, vec!["$$a$$$bb cc dd$".to_string(), "x".to_string()]);
4576 }
4577
4578 #[test]
4579 fn test_code_span_parsing() {
4580 let elements = parse_markdown_elements_inner("`code`", false, false, None);
4582 assert_eq!(elements.len(), 1);
4583 assert!(matches!(&elements[0], Element::Code { content, marker } if content == "code" && marker == "`"));
4584
4585 let elements = parse_markdown_elements_inner("``code``", false, false, None);
4587 assert_eq!(elements.len(), 1);
4588 assert!(matches!(&elements[0], Element::Code { content, marker } if content == "code" && marker == "``"));
4589
4590 let elements = parse_markdown_elements_inner("``code`inside``", false, false, None);
4592 assert_eq!(elements.len(), 1);
4593 assert!(
4594 matches!(&elements[0], Element::Code { content, marker } if content == "code`inside" && marker == "``")
4595 );
4596
4597 let elements = parse_markdown_elements_inner("`` code ``", false, false, None);
4599 assert_eq!(elements.len(), 1);
4600 assert!(matches!(&elements[0], Element::Code { content, marker } if content == " code " && marker == "``"));
4601
4602 let elements = parse_markdown_elements_inner("`unclosed", false, false, None);
4604 assert_eq!(elements.len(), 1);
4605 assert!(matches!(&elements[0], Element::Text(s) if s == "`unclosed"));
4606
4607 let elements = parse_markdown_elements_inner("`unclosed [link](url)", false, false, None);
4609 assert_eq!(elements.len(), 2);
4611 assert!(matches!(&elements[0], Element::Text(s) if s == "`unclosed "));
4612 assert!(matches!(&elements[1], Element::Link(s) if s == "[link](url)"));
4613 }
4614
4615 #[test]
4616 fn test_reflow_performance_long_input() {
4617 let mut text = String::new();
4620 for i in 1..400 {
4621 let backticks = "`".repeat(i);
4622 text.push_str(&backticks);
4623 text.push(' ');
4624 }
4625
4626 let start = std::time::Instant::now();
4627 let elements = parse_markdown_elements_inner(&text, false, false, None);
4628 let duration = start.elapsed();
4629
4630 assert!(duration.as_millis() < 100, "Parsing took too long: {duration:?}");
4632 assert!(!elements.is_empty());
4633 }
4634
4635 #[test]
4636 fn test_reflow_performance_display_math_heavy() {
4637 let text = "$$a$$".repeat(4000);
4642
4643 let start = std::time::Instant::now();
4644 let elements = parse_markdown_elements_inner(&text, false, false, None);
4645 let duration = start.elapsed();
4646
4647 assert!(duration.as_millis() < 100, "Parsing took too long: {duration:?}");
4648 assert_eq!(elements.len(), 4000);
4649 }
4650
4651 #[test]
4652 fn inline_math_len_at_start_matches_regex_at_slice_start() {
4653 let alphabet = ['$', 'a', ' '];
4658 let mut inputs: Vec<String> = vec![String::new()];
4659 let mut frontier: Vec<String> = vec![String::new()];
4660 for _ in 0..6 {
4661 let mut longer = Vec::new();
4662 for prefix in &frontier {
4663 for ch in alphabet {
4664 let mut s = prefix.clone();
4665 s.push(ch);
4666 longer.push(s);
4667 }
4668 }
4669 inputs.extend(longer.iter().cloned());
4670 frontier = longer;
4671 }
4672 inputs.push("$αβ$x".to_string());
4674 inputs.push("$α$$".to_string());
4675
4676 for s in &inputs {
4677 let expected = INLINE_MATH_REGEX
4678 .find(s)
4679 .ok()
4680 .flatten()
4681 .filter(|m| m.start() == 0)
4682 .map(|m| m.end());
4683 assert_eq!(inline_math_len_at_start(s), expected, "input: {s:?}");
4684 }
4685 }
4686
4687 #[test]
4688 fn inline_math_probe_after_dollar_matches_uncached_parse() {
4689 let cases = [
4695 ("$$a$$$b c$ x", r#"[DisplayMath("a"), InlineMath("b c"), Text(" x")]"#),
4696 (
4697 "$$a$$$b$ $$a$$$b$",
4698 r#"[DisplayMath("a"), InlineMath("b"), Text(" "), DisplayMath("a"), InlineMath("b")]"#,
4699 ),
4700 (
4702 "$$a$$$ x $y z$",
4703 r#"[DisplayMath("a"), InlineMath(" x "), Text("y z$")]"#,
4704 ),
4705 ("$$a$$$$ x", r#"[DisplayMath("a"), Text("$$ x")]"#),
4707 ("$$a$$$$b$$ x", r#"[DisplayMath("a"), DisplayMath("b"), Text(" x")]"#),
4708 (
4710 "$a$$b$$c$$d$ tail",
4711 r#"[Text("$a"), DisplayMath("b"), Text("c$$d$ tail")]"#,
4712 ),
4713 ];
4714 for (input, expected) in cases {
4715 let elements = parse_markdown_elements_inner(input, false, false, None);
4716 assert_eq!(format!("{elements:?}"), expected, "input: {input:?}");
4717 }
4718 }
4719
4720 #[test]
4721 fn test_atomic_spans() {
4722 let text_emphasis = "hello **word1 word2**";
4724
4725 let options_disabled = ReflowOptions {
4726 line_length: 18,
4727 atomic_spans: true,
4728 ..Default::default()
4729 };
4730 let lines_disabled = reflow_line(text_emphasis, &options_disabled);
4731 assert_eq!(lines_disabled, vec!["hello", "**word1 word2**"]);
4732
4733 let options_enabled = ReflowOptions {
4734 line_length: 18,
4735 atomic_spans: false,
4736 ..Default::default()
4737 };
4738 let lines_enabled = reflow_line(text_emphasis, &options_enabled);
4739 assert_eq!(lines_enabled, vec!["hello **word1", "word2**"]);
4740
4741 let text_code = "hello `word1 word2`";
4743
4744 let lines_code_disabled = reflow_line(text_code, &options_disabled);
4745 assert_eq!(lines_code_disabled, vec!["hello", "`word1 word2`"]);
4746
4747 let lines_code_enabled = reflow_line(text_code, &options_enabled);
4748 assert_eq!(lines_code_enabled, vec!["hello `word1", "word2`"]);
4749
4750 let text_code_padding = "hello `` `word1` `word2` ``";
4752 let lines_padding_enabled = reflow_line(text_code_padding, &options_enabled);
4753 assert_eq!(lines_padding_enabled, vec![r#"hello `` `word1`"#, r#"`word2` ``"#]);
4754 }
4755
4756 #[test]
4757 fn test_emphasis_containing_markers_is_not_split() {
4758 let options = ReflowOptions {
4759 line_length: 5,
4760 atomic_spans: false,
4761 ..Default::default()
4762 };
4763 let lines = reflow_line(r#"*foo \*bar*"#, &options);
4765 assert_eq!(lines, vec![r#"*foo \*bar*"#.to_string()]);
4766 }
4767
4768 fn semantic_shape(markdown: &str) -> String {
4773 let mut options = Options::empty();
4774 options.insert(Options::ENABLE_STRIKETHROUGH);
4775 let mut out = String::new();
4776 let push_prose = |out: &mut String, text: &str| {
4777 for c in text.chars() {
4778 if c.is_whitespace() {
4779 if !out.ends_with(char::is_whitespace) {
4780 out.push(' ');
4781 }
4782 } else {
4783 out.push(c);
4784 }
4785 }
4786 };
4787 for event in Parser::new_ext(markdown, options) {
4788 match event {
4789 Event::Text(text) => push_prose(&mut out, &text),
4790 Event::SoftBreak | Event::HardBreak => push_prose(&mut out, " "),
4791 Event::Code(code) => out.push_str(&format!("<code>{code}</code>")),
4793 Event::Start(tag) => out.push_str(&format!("<{tag:?}>")),
4794 Event::End(tag) => out.push_str(&format!("</{tag:?}>")),
4795 other => out.push_str(&format!("{other:?}")),
4796 }
4797 }
4798 out.trim().to_string()
4799 }
4800
4801 #[test]
4802 fn test_wrapping_a_span_never_changes_what_it_parses_to() {
4803 let corpus = [
4807 "_This is a very, very, very, very, very long line with some `code` inside._",
4808 "_alpha beta gamma delta epsilon `a b` zeta eta theta iota kappa lambda_",
4809 "**strong text with `code` and more words than fit on one single line**",
4810 "~~struck text with `code` and more words than fit on one single line~~",
4811 "_emphasis with **nested strong that is quite long** and trailing words_",
4812 "***A doubly nested bold italic span with more words than fit on a line***",
4815 "___Another doubly nested span with more words than fit on a single line___",
4816 "**_mixed strong then emphasis with more words than fit on a single line_**",
4817 "*__mixed emphasis then strong with more words than fit on a single line__*",
4818 "**~~strong strikethrough with more words than fit on a single line here~~**",
4819 "**a * b with a stray marker and plenty more words to pass the budget**",
4822 "_foo `a` bar `b` baz qux quux corge grault garply waldo fred plugh xyzzy_",
4823 "text before _a long emphasis with `code` inside of it here_ and after",
4824 "(_a parenthesized long emphasis with `code` inside of it right here_)",
4825 r#"*foo \*bar baz qux quux corge grault garply waldo fred plugh xyzzy*"#,
4826 "_tab\tseparated `a\tb` words spread out over quite a long emphasis span_",
4827 "_This has [text](<a b c d e f g h i j k>) and trailing words to wrap._",
4830 "_This has [`code`](<a b c d e f g h i j k>) and trailing words to wrap._",
4831 r#"_See [x](https://example.com "a long link title here") and `code` too._"#,
4832 "_A [link with a long label](https://example.com/path) and `code` here._",
4833 "_An image  plus `code` and more text_",
4834 ];
4835 for text in corpus {
4836 let expected = semantic_shape(text);
4837 for line_length in [20, 30, 40, 80] {
4838 for atomic_spans in [true, false] {
4839 let options = ReflowOptions {
4840 line_length,
4841 atomic_spans,
4842 ..Default::default()
4843 };
4844 let wrapped = reflow_line(text, &options).join("\n");
4845 assert_eq!(
4846 semantic_shape(&wrapped),
4847 expected,
4848 "reflow changed the parse of {text:?} at line_length={line_length} \
4849 atomic_spans={atomic_spans}\n wrapped: {wrapped:?}"
4850 );
4851 }
4852 }
4853 }
4854 }
4855
4856 #[test]
4857 fn test_wrapping_a_span_keeps_whole_the_constructs_pulldown_cannot_see() {
4858 let cases = [
4862 (
4863 "_alpha beta gamma [[a wiki link]] delta epsilon zeta eta_",
4864 "[[a wiki link]]",
4865 ),
4866 (
4867 "_alpha beta gamma {{< foo bar >}} delta epsilon zeta eta_",
4868 "{{< foo bar >}}",
4869 ),
4870 ("_alpha beta gamma $a + b$ delta epsilon zeta eta theta_", "$a + b$"),
4871 ("_alpha beta gamma $$a + b$$ delta epsilon zeta eta theta_", "$$a + b$$"),
4872 ];
4873 for (text, construct) in cases {
4874 for line_length in [12, 20, 30] {
4875 for atomic_spans in [true, false] {
4876 let options = ReflowOptions {
4877 line_length,
4878 atomic_spans,
4879 ..Default::default()
4880 };
4881 let wrapped = reflow_line(text, &options).join("\n");
4882 assert!(
4883 wrapped.contains(construct),
4884 "{construct} was broken at line_length={line_length} \
4885 atomic_spans={atomic_spans}: {wrapped:?}"
4886 );
4887 }
4888 }
4889 }
4890 }
4891
4892 #[test]
4893 fn test_overlong_emphasis_with_nested_code_span_wraps() {
4894 let options = ReflowOptions {
4898 line_length: 80,
4899 atomic_spans: true,
4900 ..Default::default()
4901 };
4902 let text = "_This is a very, very, very, very, very, very, very long line that exceeds 80 characters with some `code` inside._";
4903 let lines = reflow_line(text, &options);
4904 assert_eq!(
4905 lines,
4906 vec![
4907 "_This is a very, very, very, very, very, very, very long line that exceeds 80",
4908 "characters with some `code` inside._",
4909 ]
4910 );
4911 }
4912
4913 #[test]
4914 fn test_overlong_emphasis_with_nested_strong_wraps() {
4915 let options = ReflowOptions {
4917 line_length: 80,
4918 atomic_spans: true,
4919 ..Default::default()
4920 };
4921 let text = "_This is a very, very, very, very, very, very, very long line that exceeds 80 characters with some **bold** inside._";
4922 let lines = reflow_line(text, &options);
4923 assert_eq!(
4924 lines,
4925 vec![
4926 "_This is a very, very, very, very, very, very, very long line that exceeds 80",
4927 "characters with some **bold** inside._",
4928 ]
4929 );
4930 }
4931
4932 #[test]
4933 fn test_overlong_doubly_nested_span_wraps() {
4934 let options = ReflowOptions {
4939 line_length: 80,
4940 atomic_spans: true,
4941 ..Default::default()
4942 };
4943 let body = "This is a very, very, very, very, very, very, very, very, very, very long line that is emphasised.";
4944 for (open, close) in [
4945 ("***", "***"),
4946 ("___", "___"),
4947 ("**_", "_**"),
4948 ("*__", "__*"),
4949 ("**~~", "~~**"),
4950 ] {
4951 let text = format!("{open}{body}{close}");
4952 assert!(text.len() > options.line_length, "case must start over budget");
4953 let lines = reflow_line(&text, &options);
4954 assert!(
4955 lines.len() > 1,
4956 "{open}...{close} should wrap but stayed on one line: {lines:?}"
4957 );
4958 assert!(
4959 lines.iter().all(|line| line.len() <= options.line_length),
4960 "{open}...{close} left a line over the budget: {lines:?}"
4961 );
4962 assert_eq!(
4963 lines.join(" "),
4964 text,
4965 "{open}...{close} wrapping must only replace a space with a newline"
4966 );
4967 }
4968 }
4969
4970 #[test]
4971 fn test_overlong_span_with_stray_marker_stays_whole() {
4972 let options = ReflowOptions {
4976 line_length: 40,
4977 atomic_spans: true,
4978 ..Default::default()
4979 };
4980 let text = "**alpha * beta gamma delta epsilon zeta eta theta iota kappa**";
4981 let lines = reflow_line(text, &options);
4982 assert_eq!(lines, vec![text], "stray marker must keep the span whole");
4983 }
4984
4985 #[test]
4986 fn test_overlong_span_never_breaks_inside_a_nested_reference_link() {
4987 let options = ReflowOptions {
4993 line_length: 30,
4994 atomic_spans: true,
4995 defined_references: Some(HashSet::from([
4996 "ref".to_string(),
4997 "one two three four five six seven".to_string(),
4999 ])),
5000 ..Default::default()
5001 };
5002 for (text, link) in [
5003 (
5004 "_**alpha [one two three four five six seven][ref] beta gamma delta**_",
5005 "[one two three four five six seven][ref]",
5006 ),
5007 (
5008 "**alpha [one two three four five six seven][ref] beta gamma delta**",
5009 "[one two three four five six seven][ref]",
5010 ),
5011 (
5012 "_**alpha ![one two three four five six seven][ref] beta gamma delta**_",
5013 "![one two three four five six seven][ref]",
5014 ),
5015 (
5016 "_**alpha [one two three four five six seven][] beta gamma delta**_",
5017 "[one two three four five six seven][]",
5018 ),
5019 (
5020 "_**alpha [one two three four five six seven] beta gamma delta**_",
5021 "[one two three four five six seven]",
5022 ),
5023 ] {
5024 let lines = reflow_line(text, &options);
5025 assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
5026 assert!(
5027 lines.iter().any(|line| line.contains(link)),
5028 "{link} must stay on one line: {lines:?}"
5029 );
5030 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
5031 }
5032 }
5033
5034 #[test]
5035 fn test_overlong_span_breaks_inside_an_undefined_shortcut_reference() {
5036 let options = ReflowOptions {
5040 line_length: 30,
5041 atomic_spans: true,
5042 defined_references: Some(HashSet::new()),
5043 ..Default::default()
5044 };
5045 let text = "_**alpha [one two three four five six seven] beta gamma delta**_";
5046 let lines = reflow_line(text, &options);
5047 assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
5048 assert!(
5049 !lines
5050 .iter()
5051 .any(|line| line.contains("[one two three four five six seven]")),
5052 "an undefined shortcut is prose and should break: {lines:?}"
5053 );
5054 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
5055 }
5056
5057 #[test]
5058 fn test_overlong_span_never_breaks_inside_a_nested_attr_list() {
5059 let attr = "{.highlight key=\"a b c\"}";
5063 let text = format!("_**alpha beta gamma delta epsilon zeta{attr} eta theta iota kappa**_");
5064 let options = ReflowOptions {
5065 line_length: 20,
5066 atomic_spans: true,
5067 attr_lists: true,
5068 ..Default::default()
5069 };
5070 let lines = reflow_line(&text, &options);
5071 assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
5072 assert!(
5073 lines.iter().any(|line| line.contains(attr)),
5074 "attr list must stay on one line: {lines:?}"
5075 );
5076 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
5077
5078 let plain = ReflowOptions {
5081 attr_lists: false,
5082 ..options
5083 };
5084 let lines = reflow_line(&text, &plain);
5085 assert!(
5086 !lines.iter().any(|line| line.contains(attr)),
5087 "without the flavor the braces are prose and should break: {lines:?}"
5088 );
5089 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
5090 }
5091
5092 #[test]
5093 fn test_overlong_emphasis_never_breaks_inside_nested_code_span() {
5094 let options = ReflowOptions {
5098 line_length: 30,
5099 atomic_spans: true,
5100 ..Default::default()
5101 };
5102 let text = "_alpha beta gamma delta epsilon `a b` zeta eta theta iota kappa_";
5103 let lines = reflow_line(text, &options);
5104 assert!(lines.len() > 1, "over-long emphasis should wrap: {lines:?}");
5105 assert!(
5106 lines.iter().any(|line| line.contains("`a b`")),
5107 "nested code span must stay whole with its interior spaces: {lines:?}"
5108 );
5109 for line in &lines {
5110 assert_eq!(
5111 line.matches('`').count() % 2,
5112 0,
5113 "no line may contain half a code span: {line:?}"
5114 );
5115 }
5116 }
5117
5118 #[test]
5119 fn test_definition_list_marker_does_not_start_line() {
5120 let options = ReflowOptions {
5121 line_length: 20,
5122 ..Default::default()
5123 };
5124 let lines = reflow_line("This is a term and : definition here.", &options);
5126 for line in &lines {
5127 assert!(
5128 !line.trim_start().starts_with(": "),
5129 "Wrapped line should not start with definition marker: {line}"
5130 );
5131 }
5132 }
5133
5134 #[test]
5135 fn test_div_marker_does_not_start_line() {
5136 let options = ReflowOptions {
5137 line_length: 20,
5138 ..Default::default()
5139 };
5140 let lines = reflow_line("This is some text with ::: class marker.", &options);
5142 for line in &lines {
5143 assert!(
5144 !line.trim_start().starts_with(":::"),
5145 "Wrapped line should not start with div marker: {line}"
5146 );
5147 }
5148 }
5149}