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 pub length_exemptions: LengthExemptions,
357}
358
359#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
367pub struct LengthExemptions {
368 pub link_urls: bool,
371 pub code_spans: bool,
373}
374
375impl LengthExemptions {
376 fn any(&self) -> bool {
379 self.link_urls || self.code_spans
380 }
381}
382
383impl Default for ReflowOptions {
384 fn default() -> Self {
385 Self {
386 line_length: 80,
387 break_on_sentences: true,
388 preserve_breaks: false,
389 sentence_per_line: false,
390 semantic_line_breaks: false,
391 abbreviations: None,
392 length_mode: ReflowLengthMode::default(),
393 attr_lists: false,
394 myst_roles: false,
395 require_sentence_capital: true,
396 max_list_continuation_indent: None,
397 defined_references: None,
398 atomic_spans: true,
399 length_exemptions: LengthExemptions::default(),
400 }
401 }
402}
403
404#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
411struct LineWidth {
412 link_exempt: usize,
414 code_exempt: usize,
416}
417
418impl LineWidth {
419 fn plain(width: usize) -> Self {
422 Self {
423 link_exempt: width,
424 code_exempt: width,
425 }
426 }
427
428 fn effective(self) -> usize {
431 self.link_exempt.min(self.code_exempt)
432 }
433
434 fn fits(self, line_length: usize) -> bool {
435 self.effective() <= line_length
436 }
437
438 fn is_empty(self) -> bool {
442 self.link_exempt == 0 && self.code_exempt == 0
443 }
444}
445
446impl std::ops::Add for LineWidth {
447 type Output = Self;
448
449 fn add(self, other: Self) -> Self {
450 Self {
451 link_exempt: self.link_exempt + other.link_exempt,
452 code_exempt: self.code_exempt + other.code_exempt,
453 }
454 }
455}
456
457impl std::ops::AddAssign for LineWidth {
458 fn add_assign(&mut self, other: Self) {
459 *self = *self + other;
460 }
461}
462
463pub fn normalize_reference_label(label: &str) -> String {
470 label.split_whitespace().collect::<Vec<_>>().join(" ").to_lowercase()
471}
472
473fn footnote_refs_end(chars: &[char], start: usize) -> Option<usize> {
479 let mut pos = start;
480 let mut found = false;
481
482 loop {
483 if chars.get(pos) != Some(&'[') || chars.get(pos + 1) != Some(&'^') {
484 break;
485 }
486 let label_start = pos + 2;
487 let mut label_end = label_start;
488 while matches!(chars.get(label_end), Some(c) if c.is_ascii_alphanumeric() || *c == '_' || *c == '-') {
489 label_end += 1;
490 }
491 if label_end == label_start || chars.get(label_end) != Some(&']') {
492 break;
493 }
494 pos = label_end + 1;
495 found = true;
496 }
497
498 found.then_some(pos)
499}
500
501fn is_sentence_boundary(
505 text: &str,
506 chars: &[char],
507 pos: usize,
508 byte_offset_after_punct: usize,
509 abbreviations: &HashSet<String>,
510 require_sentence_capital: bool,
511) -> bool {
512 if pos + 1 >= chars.len() {
513 return false;
514 }
515
516 let c = chars[pos];
517 let next_char = chars[pos + 1];
518
519 if is_cjk_sentence_ending(c) {
522 let mut after_punct_pos = pos + 1;
524 while after_punct_pos < chars.len()
525 && (chars[after_punct_pos] == '*' || chars[after_punct_pos] == '_' || chars[after_punct_pos] == '~')
526 {
527 after_punct_pos += 1;
528 }
529
530 while after_punct_pos < chars.len() && chars[after_punct_pos].is_whitespace() {
532 after_punct_pos += 1;
533 }
534
535 if after_punct_pos >= chars.len() {
537 return false;
538 }
539
540 while after_punct_pos < chars.len()
542 && (chars[after_punct_pos] == '*' || chars[after_punct_pos] == '_' || chars[after_punct_pos] == '~')
543 {
544 after_punct_pos += 1;
545 }
546
547 if after_punct_pos >= chars.len() {
548 return false;
549 }
550
551 return true;
554 }
555
556 if c != '.' && c != '!' && c != '?' {
558 return false;
559 }
560
561 let inside_quotation = is_closing_quote(next_char);
564
565 let (_space_pos, after_space_pos) = if next_char == ' ' {
567 (pos + 1, pos + 2)
569 } else if is_closing_quote(next_char) && pos + 2 < chars.len() {
570 if chars[pos + 2] == ' ' {
572 (pos + 2, pos + 3)
574 } else if (chars[pos + 2] == '*' || chars[pos + 2] == '_') && pos + 3 < chars.len() && chars[pos + 3] == ' ' {
575 (pos + 3, pos + 4)
577 } else if (chars[pos + 2] == '*' || chars[pos + 2] == '_')
578 && pos + 4 < chars.len()
579 && chars[pos + 3] == chars[pos + 2]
580 && chars[pos + 4] == ' '
581 {
582 (pos + 4, pos + 5)
584 } else {
585 return false;
586 }
587 } else if (next_char == '*' || next_char == '_') && pos + 2 < chars.len() && chars[pos + 2] == ' ' {
588 (pos + 2, pos + 3)
590 } else if (next_char == '*' || next_char == '_')
591 && pos + 3 < chars.len()
592 && chars[pos + 2] == next_char
593 && chars[pos + 3] == ' '
594 {
595 (pos + 3, pos + 4)
597 } else if next_char == '~' && pos + 3 < chars.len() && chars[pos + 2] == '~' && chars[pos + 3] == ' ' {
598 (pos + 3, pos + 4)
600 } else if next_char == '[' {
601 match footnote_refs_end(chars, pos + 1) {
607 Some(end_pos) if chars.get(end_pos) == Some(&' ') => (end_pos, end_pos + 1),
608 _ => return false,
609 }
610 } else {
611 return false;
612 };
613
614 let mut next_char_pos = after_space_pos;
616 while next_char_pos < chars.len() && chars[next_char_pos].is_whitespace() {
617 next_char_pos += 1;
618 }
619
620 if next_char_pos >= chars.len() {
622 return false;
623 }
624
625 let mut first_letter_pos = next_char_pos;
627 while first_letter_pos < chars.len()
628 && (chars[first_letter_pos] == '*'
629 || chars[first_letter_pos] == '_'
630 || chars[first_letter_pos] == '~'
631 || is_opening_quote(chars[first_letter_pos]))
632 {
633 first_letter_pos += 1;
634 }
635
636 if first_letter_pos >= chars.len() {
638 return false;
639 }
640
641 let first_char = chars[first_letter_pos];
642
643 if c == '!' || c == '?' {
649 return !inside_quotation || !require_sentence_capital || first_char.is_uppercase() || is_cjk_char(first_char);
650 }
651
652 if pos > 0 {
656 if text_ends_with_abbreviation(&text[..byte_offset_after_punct], abbreviations) {
658 return false;
659 }
660
661 if chars[pos - 1].is_numeric() && first_char.is_ascii_digit() {
663 return false;
664 }
665
666 if chars[pos - 1].is_ascii_uppercase() && (pos == 1 || (pos >= 2 && chars[pos - 2].is_whitespace())) {
670 return false;
671 }
672 }
673
674 if require_sentence_capital && !first_char.is_uppercase() && !is_cjk_char(first_char) {
677 return false;
678 }
679
680 true
681}
682
683pub fn split_into_sentences(text: &str) -> Vec<String> {
685 split_into_sentences_custom(text, &None)
686}
687
688pub fn split_into_sentences_custom(text: &str, custom_abbreviations: &Option<Vec<String>>) -> Vec<String> {
690 let abbreviations = get_abbreviations(custom_abbreviations);
691 split_into_sentences_with_set(text, &abbreviations, true, None)
692}
693
694fn split_into_sentences_with_set(
704 text: &str,
705 abbreviations: &HashSet<String>,
706 require_sentence_capital: bool,
707 appended_span_start: Option<usize>,
708) -> Vec<String> {
709 let char_vec: Vec<char> = text.chars().collect();
710
711 let mut char_offsets = Vec::with_capacity(char_vec.len() + 1);
715 let mut offset = 0;
716 for c in &char_vec {
717 char_offsets.push(offset);
718 offset += c.len_utf8();
719 }
720 char_offsets.push(offset);
721
722 let code_spans = extract_code_spans(text);
724 let mut span_it = code_spans.iter().peekable();
725
726 let mut sentences = Vec::new();
727 let mut current_sentence = String::new();
728 let mut pos = 0;
729
730 while pos < char_vec.len() {
731 let c = char_vec[pos];
732 current_sentence.push(c);
733
734 let byte_idx = char_offsets[pos];
735
736 while let Some(span) = span_it.peek() {
738 if span.end <= byte_idx {
739 span_it.next();
740 } else {
741 break;
742 }
743 }
744
745 let in_code = if let Some(span) = span_it.peek() {
747 byte_idx >= span.start && byte_idx < span.end
748 } else {
749 false
750 };
751
752 if !in_code
753 && is_sentence_boundary(
754 text,
755 &char_vec,
756 pos,
757 char_offsets[pos + 1],
758 abbreviations,
759 require_sentence_capital,
760 )
761 {
762 if let Some(end_pos) = footnote_refs_end(&char_vec, pos + 1) {
764 while pos + 1 < end_pos {
765 pos += 1;
766 current_sentence.push(char_vec[pos]);
767 }
768 }
769
770 while pos + 1 < char_vec.len() {
772 let next = char_vec[pos + 1];
773 if matches!(next, '*' | '_' | '~') && Some(char_offsets[pos + 1]) == appended_span_start {
774 break;
775 }
776 if next == '*' || next == '_' || next == '~' || is_closing_quote(next) {
777 pos += 1;
778 current_sentence.push(char_vec[pos]);
779 } else {
780 break;
781 }
782 }
783
784 if pos + 1 < char_vec.len() && char_vec[pos + 1] == ' ' {
786 pos += 1; }
788
789 sentences.push(current_sentence.trim().to_string());
790 current_sentence.clear();
791 }
792
793 pos += 1;
794 }
795
796 if !current_sentence.trim().is_empty() {
798 sentences.push(current_sentence.trim().to_string());
799 }
800 sentences
801}
802
803fn is_horizontal_rule(line: &str) -> bool {
805 if line.len() < 3 {
806 return false;
807 }
808
809 let mut chars = line.chars();
812 let Some(first_char) = chars.next() else {
813 return false;
814 };
815 if first_char != '-' && first_char != '_' && first_char != '*' {
816 return false;
817 }
818
819 let mut non_space_count = 1usize; for c in chars {
821 if c == ' ' {
822 continue;
823 }
824 if c != first_char {
825 return false;
826 }
827 non_space_count += 1;
828 }
829 non_space_count >= 3
830}
831
832fn is_numbered_list_item(line: &str) -> bool {
834 let mut chars = line.chars();
835
836 if !chars.next().is_some_and(char::is_numeric) {
838 return false;
839 }
840
841 while let Some(c) = chars.next() {
843 if c == '.' {
844 return chars.next() == Some(' ');
847 }
848 if !c.is_numeric() {
849 return false;
850 }
851 }
852
853 false
854}
855
856fn is_unordered_list_marker(s: &str) -> bool {
858 matches!(s.as_bytes().first(), Some(b'-' | b'*' | b'+'))
859 && !is_horizontal_rule(s)
860 && (s.len() == 1 || s.as_bytes().get(1) == Some(&b' '))
861}
862
863fn is_block_boundary_core(trimmed: &str) -> bool {
866 trimmed.is_empty()
867 || trimmed.starts_with('#')
868 || trimmed.starts_with("```")
869 || trimmed.starts_with("~~~")
870 || trimmed.starts_with('>')
871 || (trimmed.starts_with('[') && trimmed.contains("]:"))
872 || is_horizontal_rule(trimmed)
873 || is_unordered_list_marker(trimmed)
874 || is_numbered_list_item(trimmed)
875 || is_definition_list_item(trimmed)
876 || trimmed.starts_with(":::")
877}
878
879fn is_block_boundary(trimmed: &str) -> bool {
882 is_block_boundary_core(trimmed) || trimmed.starts_with('|')
883}
884
885fn is_paragraph_boundary(trimmed: &str, line: &str) -> bool {
889 is_block_boundary_core(trimmed)
890 || calculate_indentation_width_default(line) >= 4
891 || crate::utils::table_utils::TableUtils::is_potential_table_row(line)
892}
893
894fn has_hard_break(line: &str) -> bool {
900 let line = line.strip_suffix('\r').unwrap_or(line);
901 line.ends_with(" ") || line.ends_with('\\')
902}
903
904fn ends_with_sentence_punct(text: &str) -> bool {
906 text.ends_with('.') || text.ends_with('!') || text.ends_with('?')
907}
908
909fn trim_preserving_hard_break(s: &str) -> String {
915 let s = s.strip_suffix('\r').unwrap_or(s);
917
918 if s.ends_with('\\') {
920 return s.to_string();
922 }
923
924 if s.ends_with(" ") {
926 let content_end = s.trim_end().len();
928 if content_end == 0 {
929 return String::new();
931 }
932 format!("{} ", &s[..content_end])
934 } else {
935 s.trim_end().to_string()
937 }
938}
939
940fn parse_elements(text: &str, options: &ReflowOptions) -> Vec<Element> {
942 parse_markdown_elements_inner(
943 text,
944 options.attr_lists,
945 options.myst_roles,
946 options.defined_references.as_ref(),
947 )
948}
949
950pub fn reflow_line(line: &str, options: &ReflowOptions) -> Vec<String> {
960 let reflowed = reflow_line_unchecked(line, options);
961 if preserves_content(line, &reflowed) {
962 reflowed
963 } else {
964 vec![line.to_string()]
965 }
966}
967
968fn preserves_content(original: &str, reflowed: &[String]) -> bool {
975 let (original_text, original_breaks) = visible_text_and_breaks(original.chars());
976 let (reflowed_text, reflowed_breaks) =
977 visible_text_and_breaks(reflowed.iter().flat_map(|line| line.chars().chain(['\n'])));
978
979 original_text == reflowed_text && contains_all(&reflowed_breaks, &original_breaks)
980}
981
982fn visible_text_and_breaks(text: impl Iterator<Item = char>) -> (String, Vec<usize>) {
985 let mut visible = String::new();
986 let mut breaks = Vec::new();
987 let mut count = 0usize;
988 let mut pending_break = false;
989
990 for c in text {
991 if c.is_whitespace() {
992 pending_break = count > 0;
993 } else {
994 if pending_break {
995 breaks.push(count);
996 pending_break = false;
997 }
998 visible.push(c);
999 count += 1;
1000 }
1001 }
1002
1003 (visible, breaks)
1004}
1005
1006fn contains_all(superset: &[usize], subset: &[usize]) -> bool {
1008 let mut candidates = superset.iter();
1009 subset
1010 .iter()
1011 .all(|wanted| candidates.by_ref().any(|found| found == wanted))
1012}
1013
1014fn reflow_line_unchecked(line: &str, options: &ReflowOptions) -> Vec<String> {
1015 if options.sentence_per_line {
1017 let elements = parse_elements(line, options);
1018 return merge_block_construct_continuations(reflow_elements_sentence_per_line(
1019 &elements,
1020 &options.abbreviations,
1021 options.require_sentence_capital,
1022 ));
1023 }
1024
1025 if options.semantic_line_breaks {
1027 let elements = parse_elements(line, options);
1028 return merge_block_construct_continuations(reflow_elements_semantic(&elements, options));
1029 }
1030
1031 if options.line_length == 0 || line_fits(line, options) {
1034 return vec![line.to_string()];
1035 }
1036
1037 let elements = parse_elements(line, options);
1039
1040 merge_block_construct_continuations(reflow_elements(&elements, options))
1042}
1043
1044#[derive(Debug, Clone)]
1046enum Element {
1047 Text(String),
1049 Link(String),
1051 ReferenceLink(String),
1053 EmptyReferenceLink(String),
1055 ShortcutReference(String),
1057 InlineImage(String),
1059 ReferenceImage(String),
1061 EmptyReferenceImage(String),
1063 LinkedImage(String),
1065 FootnoteReference(String),
1067 Strikethrough {
1069 content: String,
1070 double: bool,
1072 },
1073 WikiLink(String),
1075 InlineMath(String),
1077 DisplayMath(String),
1079 EmojiShortcode(String),
1081 Autolink(String),
1083 HtmlTag(String),
1085 HtmlEntity(String),
1087 HugoShortcode(String),
1089 AttrList(String),
1091 MystRole(String),
1095 Code { content: String, marker: String },
1097 Bold {
1099 content: String,
1100 underscore: bool,
1102 },
1103 Italic {
1105 content: String,
1106 underscore: bool,
1108 },
1109}
1110
1111impl std::fmt::Display for Element {
1112 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1113 match self {
1114 Element::Text(s) => write!(f, "{s}"),
1115 Element::Link(s) => write!(f, "{s}"),
1116 Element::ReferenceLink(s) => write!(f, "{s}"),
1117 Element::EmptyReferenceLink(s) => write!(f, "{s}"),
1118 Element::ShortcutReference(s) => write!(f, "{s}"),
1119 Element::InlineImage(s) => write!(f, "{s}"),
1120 Element::ReferenceImage(s) => write!(f, "{s}"),
1121 Element::EmptyReferenceImage(s) => write!(f, "{s}"),
1122 Element::LinkedImage(s) => write!(f, "{s}"),
1123 Element::FootnoteReference(s) => write!(f, "{s}"),
1124 Element::Strikethrough { content, double } => {
1125 let marker = if *double { "~~" } else { "~" };
1126 write!(f, "{marker}{content}{marker}")
1127 }
1128 Element::WikiLink(s) => write!(f, "[[{s}]]"),
1129 Element::InlineMath(s) => write!(f, "${s}$"),
1130 Element::DisplayMath(s) => write!(f, "$${s}$$"),
1131 Element::EmojiShortcode(s) => write!(f, ":{s}:"),
1132 Element::Autolink(s) => write!(f, "{s}"),
1133 Element::HtmlTag(s) => write!(f, "{s}"),
1134 Element::HtmlEntity(s) => write!(f, "{s}"),
1135 Element::HugoShortcode(s) => write!(f, "{s}"),
1136 Element::AttrList(s) => write!(f, "{s}"),
1137 Element::MystRole(s) => write!(f, "{s}"),
1138 Element::Code { content, marker } => write!(f, "{marker}{content}{marker}"),
1139 Element::Bold { content, underscore } => {
1140 if *underscore {
1141 write!(f, "__{content}__")
1142 } else {
1143 write!(f, "**{content}**")
1144 }
1145 }
1146 Element::Italic { content, underscore } => {
1147 if *underscore {
1148 write!(f, "_{content}_")
1149 } else {
1150 write!(f, "*{content}*")
1151 }
1152 }
1153 }
1154 }
1155}
1156
1157impl Element {
1158 fn display_len(&self, mode: ReflowLengthMode) -> usize {
1159 match self {
1160 Element::Text(s)
1161 | Element::Link(s)
1162 | Element::ReferenceLink(s)
1163 | Element::EmptyReferenceLink(s)
1164 | Element::ShortcutReference(s)
1165 | Element::InlineImage(s)
1166 | Element::ReferenceImage(s)
1167 | Element::EmptyReferenceImage(s)
1168 | Element::LinkedImage(s)
1169 | Element::FootnoteReference(s)
1170 | Element::Autolink(s)
1171 | Element::HtmlTag(s)
1172 | Element::HtmlEntity(s)
1173 | Element::HugoShortcode(s)
1174 | Element::AttrList(s)
1175 | Element::MystRole(s) => display_len(s, mode),
1176 Element::WikiLink(s) => display_len(s, mode) + 4,
1177 Element::InlineMath(s) => display_len(s, mode) + 2,
1178 Element::DisplayMath(s) => display_len(s, mode) + 4,
1179 Element::EmojiShortcode(s) => display_len(s, mode) + 2,
1180 Element::Strikethrough { content, double } => display_len(content, mode) + if *double { 4 } else { 2 },
1181 Element::Code { content, marker } => display_len(content, mode) + display_len(marker, mode) * 2,
1182 Element::Bold { content, .. } => display_len(content, mode) + 4,
1183 Element::Italic { content, .. } => display_len(content, mode) + 2,
1184 }
1185 }
1186
1187 fn exempt_width(&self, mode: ReflowLengthMode, exemptions: LengthExemptions) -> LineWidth {
1198 let full = self.display_len(mode);
1199 let mut width = LineWidth::plain(full);
1200 match self {
1201 Element::Link(s) | Element::LinkedImage(s) if exemptions.link_urls => {
1202 if let Some(text) = bracketed_text(s, 0) {
1203 width.link_exempt = (2 + display_len(text, mode)).min(full);
1204 }
1205 }
1206 Element::InlineImage(s) if exemptions.link_urls => {
1207 if let Some(alt) = bracketed_text(s, 1) {
1208 width.link_exempt = (3 + display_len(alt, mode)).min(full);
1209 }
1210 }
1211 Element::Code { .. } if exemptions.code_spans => width.code_exempt = 0,
1212 _ => {}
1213 }
1214 width
1215 }
1216}
1217
1218fn bracketed_text(s: &str, open: usize) -> Option<&str> {
1225 let bytes = s.as_bytes();
1226 if bytes.get(open) != Some(&b'[') {
1227 return None;
1228 }
1229 let mut depth = 0usize;
1230 let mut in_code_span = false;
1231 let mut escaped = false;
1232 for (i, &byte) in bytes.iter().enumerate().skip(open + 1) {
1233 if escaped {
1234 escaped = false;
1235 continue;
1236 }
1237 match byte {
1238 b'\\' => escaped = true,
1239 b'`' => in_code_span = !in_code_span,
1240 b'[' if !in_code_span => depth += 1,
1241 b']' if !in_code_span => match depth.checked_sub(1) {
1242 Some(next) => depth = next,
1243 None => return s.get(open + 1..i),
1244 },
1245 _ => {}
1246 }
1247 }
1248 None
1249}
1250
1251#[derive(Debug, Clone)]
1253struct EmphasisSpan {
1254 start: usize,
1256 end: usize,
1258 content: String,
1260 is_strong: bool,
1262 is_strikethrough: bool,
1264 uses_underscore: bool,
1266 strikethrough_double: bool,
1269}
1270
1271fn extract_emphasis_and_code_spans(text: &str) -> (Vec<EmphasisSpan>, Vec<CodeSpan>) {
1281 let has_emphasis = text.contains(['*', '_', '~']);
1283 let has_code = text.contains('`');
1284 if !has_emphasis && !has_code {
1285 return (Vec::new(), Vec::new());
1286 }
1287
1288 let mut emphasis_spans = Vec::new();
1289 let mut code_spans = Vec::new();
1290
1291 let mut options = Options::empty();
1292 if has_emphasis {
1293 options.insert(Options::ENABLE_STRIKETHROUGH);
1294 }
1295
1296 let mut emphasis_stack: Vec<(usize, bool)> = Vec::new(); let mut strong_stack: Vec<(usize, bool)> = Vec::new();
1299 let mut strikethrough_stack: Vec<usize> = Vec::new();
1300
1301 let parser = Parser::new_ext(text, options).into_offset_iter();
1302
1303 for (event, range) in parser {
1304 match event {
1305 Event::Code(_) => {
1306 code_spans.push(CodeSpan {
1307 start: range.start,
1308 end: range.end,
1309 });
1310 }
1311 Event::Start(Tag::Emphasis) => {
1312 let uses_underscore = text.get(range.start..range.start + 1) == Some("_");
1314 emphasis_stack.push((range.start, uses_underscore));
1315 }
1316 Event::End(TagEnd::Emphasis) => {
1317 if let Some((start_byte, uses_underscore)) = emphasis_stack.pop() {
1318 let content_start = start_byte + 1;
1319 let content_end = range.end - 1;
1320 if content_end > content_start
1321 && let Some(content) = text.get(content_start..content_end)
1322 {
1323 emphasis_spans.push(EmphasisSpan {
1324 start: start_byte,
1325 end: range.end,
1326 content: content.to_string(),
1327 is_strong: false,
1328 is_strikethrough: false,
1329 uses_underscore,
1330 strikethrough_double: false,
1331 });
1332 }
1333 }
1334 }
1335 Event::Start(Tag::Strong) => {
1336 let uses_underscore = text.get(range.start..range.start + 2) == Some("__");
1337 strong_stack.push((range.start, uses_underscore));
1338 }
1339 Event::End(TagEnd::Strong) => {
1340 if let Some((start_byte, uses_underscore)) = strong_stack.pop() {
1341 let content_start = start_byte + 2;
1342 let content_end = range.end - 2;
1343 if content_end > content_start
1344 && let Some(content) = text.get(content_start..content_end)
1345 {
1346 emphasis_spans.push(EmphasisSpan {
1347 start: start_byte,
1348 end: range.end,
1349 content: content.to_string(),
1350 is_strong: true,
1351 is_strikethrough: false,
1352 uses_underscore,
1353 strikethrough_double: false,
1354 });
1355 }
1356 }
1357 }
1358 Event::Start(Tag::Strikethrough) => {
1359 strikethrough_stack.push(range.start);
1360 }
1361 Event::End(TagEnd::Strikethrough) => {
1362 if let Some(start_byte) = strikethrough_stack.pop() {
1363 let double = text.get(start_byte..start_byte + 2) == Some("~~");
1364 let marker_len = if double { 2 } else { 1 };
1365 let content_start = start_byte + marker_len;
1366 let content_end = range.end - marker_len;
1367 if content_end > content_start
1368 && let Some(content) = text.get(content_start..content_end)
1369 {
1370 emphasis_spans.push(EmphasisSpan {
1371 start: start_byte,
1372 end: range.end,
1373 content: content.to_string(),
1374 is_strong: false,
1375 is_strikethrough: true,
1376 uses_underscore: false,
1377 strikethrough_double: double,
1378 });
1379 }
1380 }
1381 }
1382 _ => {}
1383 }
1384 }
1385
1386 emphasis_spans.sort_by_key(|s| s.start);
1387 (emphasis_spans, code_spans)
1388}
1389
1390#[derive(Debug, Clone)]
1391struct CodeSpan {
1392 start: usize,
1393 end: usize,
1394}
1395
1396fn extract_code_spans(text: &str) -> Vec<CodeSpan> {
1397 if !text.contains('`') {
1399 return Vec::new();
1400 }
1401
1402 let mut spans = Vec::new();
1403 let parser = Parser::new(text).into_offset_iter();
1404 for (event, range) in parser {
1405 if let Event::Code(_) = event {
1406 spans.push(CodeSpan {
1407 start: range.start,
1408 end: range.end,
1409 });
1410 }
1411 }
1412 spans
1413}
1414
1415#[derive(Debug, Clone)]
1416struct LinkSpan {
1417 start: usize,
1418 end: usize,
1419 link_type: Option<LinkType>,
1420 is_image: bool,
1421 is_footnote: bool,
1422}
1423
1424fn extract_link_spans(text: &str, defined_references: Option<&HashSet<String>>) -> Vec<LinkSpan> {
1425 if !text.contains('[') {
1428 return Vec::new();
1429 }
1430
1431 let mut spans = Vec::new();
1432 let mut options = Options::empty();
1433 options.insert(Options::ENABLE_FOOTNOTES);
1434
1435 let resolve = move |link: BrokenLink<'_>| -> Option<(CowStr<'_>, CowStr<'_>)> {
1452 let atomic = match link.link_type {
1457 LinkType::Shortcut | LinkType::ShortcutUnknown => match defined_references {
1458 Some(defs) => defs.contains(&normalize_reference_label(link.reference.as_ref())),
1459 None => true,
1460 },
1461 _ => true,
1462 };
1463 atomic.then_some((CowStr::Borrowed(""), CowStr::Borrowed("")))
1464 };
1465 let parser = Parser::new_with_broken_link_callback(text, options, Some(resolve)).into_offset_iter();
1466 let mut stack = Vec::new();
1467
1468 for (event, range) in parser {
1469 match event {
1470 Event::Start(Tag::Link { link_type, .. }) => {
1471 stack.push((range.start, Some(link_type), false));
1472 }
1473 Event::Start(Tag::Image { link_type, .. }) => {
1474 stack.push((range.start, Some(link_type), true));
1475 }
1476 Event::End(TagEnd::Link) => {
1477 if let Some((start_byte, link_type, is_image)) = stack.pop()
1478 && stack.is_empty()
1479 {
1480 spans.push(LinkSpan {
1481 start: start_byte,
1482 end: range.end,
1483 link_type,
1484 is_image,
1485 is_footnote: false,
1486 });
1487 }
1488 }
1489 Event::End(TagEnd::Image) => {
1490 if let Some((start_byte, link_type, is_image)) = stack.pop()
1491 && stack.is_empty()
1492 {
1493 spans.push(LinkSpan {
1494 start: start_byte,
1495 end: range.end,
1496 link_type,
1497 is_image,
1498 is_footnote: false,
1499 });
1500 }
1501 }
1502 Event::FootnoteReference(_) if stack.is_empty() => {
1503 spans.push(LinkSpan {
1504 start: range.start,
1505 end: range.end,
1506 link_type: None,
1507 is_image: false,
1508 is_footnote: true,
1509 });
1510 }
1511 _ => {}
1512 }
1513 }
1514
1515 spans.sort_by_key(|s| s.start);
1516 spans
1517}
1518
1519fn myst_role_len_at(text: &str, absolute_pos: usize, code_spans: &[CodeSpan]) -> Option<usize> {
1527 let bytes = text.as_bytes();
1528 if bytes.first() != Some(&b'{') {
1529 return None;
1530 }
1531
1532 let mut j = 1;
1534 match bytes.get(j) {
1535 Some(&b) if b.is_ascii_alphabetic() || b == b'_' => {}
1536 _ => return None,
1537 }
1538 while let Some(&b) = bytes.get(j) {
1539 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b':' | b'.') {
1540 j += 1;
1541 } else {
1542 break;
1543 }
1544 }
1545 if bytes.get(j) != Some(&b'}') {
1546 return None;
1547 }
1548 j += 1; let code_span_start = absolute_pos + j;
1552 if let Ok(idx) = code_spans.binary_search_by_key(&code_span_start, |span| span.start) {
1553 let span = &code_spans[idx];
1554 let code_span_len = span.end - span.start;
1555 return Some(j + code_span_len);
1556 }
1557
1558 None
1559}
1560
1561fn inline_math_len_at_start(s: &str) -> Option<usize> {
1568 let bytes = s.as_bytes();
1569 if bytes.first() != Some(&b'$') || bytes.get(1) == Some(&b'$') {
1571 return None;
1572 }
1573 let close = 1 + s[1..].find('$')?;
1576 if bytes.get(close + 1) == Some(&b'$') {
1578 return None;
1579 }
1580 Some(close + 1)
1581}
1582
1583#[derive(Clone, Copy, Debug)]
1585struct PatternMatch {
1586 start: usize,
1587 end: usize,
1588}
1589
1590#[derive(Clone, Copy)]
1604enum PatternCache {
1605 Unsearched,
1606 NotFound,
1607 Found(PatternMatch),
1608}
1609
1610impl PatternCache {
1611 fn earliest_in(
1615 &mut self,
1616 remaining: &str,
1617 cursor: usize,
1618 find: impl FnOnce(&str) -> Option<(usize, usize)>,
1619 ) -> Option<(usize, usize)> {
1620 let stale = match self {
1621 PatternCache::Found(pm) => pm.start < cursor,
1622 PatternCache::NotFound => false,
1623 PatternCache::Unsearched => true,
1624 };
1625 if stale {
1626 *self = match find(remaining) {
1627 Some((start, end)) => PatternCache::Found(PatternMatch {
1628 start: cursor + start,
1629 end: cursor + end,
1630 }),
1631 None => PatternCache::NotFound,
1632 };
1633 }
1634 match self {
1635 PatternCache::Found(pm) => Some((pm.start - cursor, pm.end - cursor)),
1636 _ => None,
1637 }
1638 }
1639}
1640
1641fn parse_markdown_elements_inner(
1652 text: &str,
1653 attr_lists: bool,
1654 myst_roles: bool,
1655 defined_references: Option<&HashSet<String>>,
1656) -> Vec<Element> {
1657 let mut elements = Vec::new();
1658 let mut remaining = text;
1659
1660 let (emphasis_spans, code_spans) = extract_emphasis_and_code_spans(text);
1665 let link_spans = extract_link_spans(text, defined_references);
1666
1667 let mut cached_wiki_link = PatternCache::Unsearched;
1670 let mut cached_display_math = PatternCache::Unsearched;
1671 let mut cached_inline_math = PatternCache::Unsearched;
1672 let mut cached_emoji = PatternCache::Unsearched;
1673 let mut cached_html_entity = PatternCache::Unsearched;
1674 let mut cached_hugo_shortcode = PatternCache::Unsearched;
1675 let mut cached_html_tag = PatternCache::Unsearched;
1676 let mut cached_next_curly = PatternCache::Unsearched;
1677
1678 let mut link_span_idx = 0usize;
1682 let mut emphasis_span_idx = 0usize;
1683 let mut code_span_idx = 0usize;
1684
1685 while !remaining.is_empty() {
1686 let current_offset = text.len() - remaining.len();
1688 let mut earliest_match: Option<(usize, usize, &str)> = None;
1691
1692 while link_span_idx < link_spans.len() && link_spans[link_span_idx].start < current_offset {
1694 link_span_idx += 1;
1695 }
1696 let next_link: Option<&LinkSpan> = link_spans.get(link_span_idx);
1697
1698 if let Some(span) = next_link {
1699 let pos_in_remaining = span.start - current_offset;
1700 if earliest_match
1701 .as_ref()
1702 .is_none_or(|(start, _, _)| pos_in_remaining < *start)
1703 {
1704 let match_end = span.end - current_offset;
1705 earliest_match = Some((pos_in_remaining, match_end, "link_span"));
1706 }
1707 }
1708
1709 if let Some((start, end)) = cached_wiki_link.earliest_in(remaining, current_offset, |suffix| {
1711 WIKI_LINK_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1712 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1713 {
1714 earliest_match = Some((start, end, "wiki_link"));
1715 }
1716
1717 if let Some((start, end)) = cached_display_math.earliest_in(remaining, current_offset, |suffix| {
1719 DISPLAY_MATH_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1720 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1721 {
1722 earliest_match = Some((start, end, "display_math"));
1723 }
1724
1725 let inline_math_probe = if current_offset > 0 && text.as_bytes()[current_offset - 1] == b'$' {
1739 inline_math_len_at_start(remaining).map(|len| (0, len))
1740 } else {
1741 None
1742 };
1743 if let Some((start, end)) = inline_math_probe.or_else(|| {
1744 cached_inline_math.earliest_in(remaining, current_offset, |suffix| {
1745 INLINE_MATH_REGEX
1746 .find(suffix)
1747 .ok()
1748 .flatten()
1749 .map(|m| (m.start(), m.end()))
1750 })
1751 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1752 {
1753 earliest_match = Some((start, end, "inline_math"));
1754 }
1755
1756 if let Some((start, end)) = cached_emoji.earliest_in(remaining, current_offset, |suffix| {
1758 EMOJI_SHORTCODE_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1759 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1760 {
1761 earliest_match = Some((start, end, "emoji"));
1762 }
1763
1764 if let Some((start, end)) = cached_html_entity.earliest_in(remaining, current_offset, |suffix| {
1766 HTML_ENTITY_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1767 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1768 {
1769 earliest_match = Some((start, end, "html_entity"));
1770 }
1771
1772 if let Some((start, end)) = cached_hugo_shortcode.earliest_in(remaining, current_offset, |suffix| {
1775 HUGO_SHORTCODE_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1776 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1777 {
1778 earliest_match = Some((start, end, "hugo_shortcode"));
1779 }
1780
1781 if let Some((start, end)) = cached_html_tag.earliest_in(remaining, current_offset, |suffix| {
1788 let mut from = 0;
1789 while let Some(m) = HTML_TAG_PATTERN.find(&suffix[from..]) {
1790 let (tag_start, tag_end) = (from + m.start(), from + m.end());
1791 let tag = &suffix[tag_start..tag_end];
1792 let is_url_autolink = tag.starts_with("<http://")
1794 || tag.starts_with("<https://")
1795 || tag.starts_with("<mailto:")
1796 || tag.starts_with("<ftp://")
1797 || tag.starts_with("<ftps://");
1798 let is_email_autolink = {
1801 let content = tag.trim_start_matches('<').trim_end_matches('>');
1802 EMAIL_PATTERN.is_match(content)
1803 };
1804 if is_url_autolink || is_email_autolink {
1805 from = tag_end;
1806 } else {
1807 return Some((tag_start, tag_end));
1808 }
1809 }
1810 None
1811 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1812 {
1813 earliest_match = Some((start, end, "html_tag"));
1814 }
1815
1816 let mut next_special = remaining.len();
1818 let mut special_type = "";
1819 let mut pulldown_emphasis: Option<&EmphasisSpan> = None;
1820 let mut attr_list_len: usize = 0;
1821 let mut myst_role_len: usize = 0;
1822
1823 while code_span_idx < code_spans.len() && code_spans[code_span_idx].start < current_offset {
1825 code_span_idx += 1;
1826 }
1827 let next_code_span: Option<&CodeSpan> = code_spans.get(code_span_idx);
1828 if let Some(span) = next_code_span {
1829 let pos_in_remaining = span.start - current_offset;
1830 if pos_in_remaining < next_special {
1831 next_special = pos_in_remaining;
1832 special_type = "pulldown_code";
1833 }
1834 }
1835
1836 let next_curly_pos = cached_next_curly
1839 .earliest_in(remaining, current_offset, |suffix| {
1840 suffix.find('{').map(|pos| (pos, pos + 1))
1841 })
1842 .map(|(start, _)| start);
1843
1844 if myst_roles
1849 && let Some(pos) = next_curly_pos
1850 && pos < next_special
1851 && let Some(role_len) = myst_role_len_at(&remaining[pos..], current_offset + pos, &code_spans)
1852 {
1853 next_special = pos;
1854 special_type = "myst_role";
1855 myst_role_len = role_len;
1856 }
1857
1858 if attr_lists
1860 && let Some(pos) = next_curly_pos
1861 && pos < next_special
1862 && let Some(m) = ATTR_LIST_PATTERN.find(&remaining[pos..])
1863 && m.start() == 0
1864 {
1865 next_special = pos;
1866 special_type = "attr_list";
1867 attr_list_len = m.end();
1868 }
1869
1870 while emphasis_span_idx < emphasis_spans.len() && emphasis_spans[emphasis_span_idx].start < current_offset {
1872 emphasis_span_idx += 1;
1873 }
1874 if let Some(span) = emphasis_spans.get(emphasis_span_idx) {
1875 let pos_in_remaining = span.start - current_offset;
1876 if pos_in_remaining < next_special {
1877 next_special = pos_in_remaining;
1878 special_type = "pulldown_emphasis";
1879 pulldown_emphasis = Some(span);
1880 }
1881 }
1882
1883 let should_process_markdown_link = if let Some((pos, _, _)) = earliest_match {
1885 pos < next_special
1886 } else {
1887 false
1888 };
1889
1890 if should_process_markdown_link {
1891 let (pos, match_end, pattern_type) = earliest_match.unwrap();
1892
1893 if pos > 0 {
1895 elements.push(Element::Text(remaining[..pos].to_string()));
1896 }
1897
1898 match pattern_type {
1900 "link_span" => {
1901 let span = next_link.unwrap();
1902 let raw_text = remaining[pos..match_end].to_string();
1903 if span.is_footnote {
1904 elements.push(Element::FootnoteReference(raw_text));
1905 } else if span.is_image {
1906 match span.link_type {
1907 Some(LinkType::Inline) => elements.push(Element::InlineImage(raw_text)),
1908 Some(LinkType::Reference)
1911 | Some(LinkType::ReferenceUnknown)
1912 | Some(LinkType::Shortcut)
1913 | Some(LinkType::ShortcutUnknown) => elements.push(Element::ReferenceImage(raw_text)),
1914 Some(LinkType::Collapsed) | Some(LinkType::CollapsedUnknown) => {
1915 elements.push(Element::EmptyReferenceImage(raw_text))
1916 }
1917 _ => elements.push(Element::InlineImage(raw_text)),
1918 }
1919 } else {
1920 match span.link_type {
1921 Some(LinkType::Inline) => {
1922 if raw_text.starts_with('[') && raw_text.contains("![") {
1923 elements.push(Element::LinkedImage(raw_text));
1924 } else {
1925 elements.push(Element::Link(raw_text));
1926 }
1927 }
1928 Some(LinkType::Reference) | Some(LinkType::ReferenceUnknown) => {
1931 elements.push(Element::ReferenceLink(raw_text))
1932 }
1933 Some(LinkType::Collapsed) | Some(LinkType::CollapsedUnknown) => {
1934 elements.push(Element::EmptyReferenceLink(raw_text))
1935 }
1936 Some(LinkType::Shortcut) | Some(LinkType::ShortcutUnknown) => {
1937 elements.push(Element::ShortcutReference(raw_text))
1938 }
1939 Some(LinkType::Autolink) | Some(LinkType::Email) => {
1940 elements.push(Element::Autolink(raw_text))
1941 }
1942 _ => elements.push(Element::Link(raw_text)),
1943 }
1944 }
1945 remaining = &remaining[match_end..];
1946 }
1947 "wiki_link" => {
1948 if let Some(caps) = WIKI_LINK_REGEX.captures(remaining) {
1949 let content = caps.get(1).map_or("", |m| m.as_str());
1950 elements.push(Element::WikiLink(content.to_string()));
1951 remaining = &remaining[match_end..];
1952 } else {
1953 elements.push(Element::Text("[[".to_string()));
1954 remaining = &remaining[2..];
1955 }
1956 }
1957 "display_math" => {
1958 if let Some(caps) = DISPLAY_MATH_REGEX.captures(remaining) {
1959 let math = caps.get(1).map_or("", |m| m.as_str());
1960 elements.push(Element::DisplayMath(math.to_string()));
1961 remaining = &remaining[match_end..];
1962 } else {
1963 elements.push(Element::Text("$$".to_string()));
1964 remaining = &remaining[2..];
1965 }
1966 }
1967 "inline_math" => {
1968 if let Ok(Some(caps)) = INLINE_MATH_REGEX.captures(remaining) {
1969 let math = caps.get(1).map_or("", |m| m.as_str());
1970 elements.push(Element::InlineMath(math.to_string()));
1971 remaining = &remaining[match_end..];
1972 } else {
1973 elements.push(Element::Text("$".to_string()));
1974 remaining = &remaining[1..];
1975 }
1976 }
1977 "emoji" => {
1978 if let Some(caps) = EMOJI_SHORTCODE_REGEX.captures(remaining) {
1979 let emoji = caps.get(1).map_or("", |m| m.as_str());
1980 elements.push(Element::EmojiShortcode(emoji.to_string()));
1981 remaining = &remaining[match_end..];
1982 } else {
1983 elements.push(Element::Text(":".to_string()));
1984 remaining = &remaining[1..];
1985 }
1986 }
1987 "html_entity" => {
1988 elements.push(Element::HtmlEntity(remaining[pos..match_end].to_string()));
1990 remaining = &remaining[match_end..];
1991 }
1992 "hugo_shortcode" => {
1993 elements.push(Element::HugoShortcode(remaining[pos..match_end].to_string()));
1995 remaining = &remaining[match_end..];
1996 }
1997 "html_tag" => {
1998 elements.push(Element::HtmlTag(remaining[pos..match_end].to_string()));
2000 remaining = &remaining[match_end..];
2001 }
2002 _ => unreachable!("unknown pattern type: {}", pattern_type),
2003 }
2004 } else {
2005 if next_special > 0 && next_special < remaining.len() {
2009 elements.push(Element::Text(remaining[..next_special].to_string()));
2010 remaining = &remaining[next_special..];
2011 }
2012
2013 match special_type {
2015 "pulldown_code" => {
2016 let span = next_code_span.unwrap();
2017 let span_len = span.end - span.start;
2018 let code_raw = &remaining[..span_len];
2019 if let Some((content, marker)) = decompose_code_span(code_raw) {
2020 elements.push(Element::Code {
2021 content: content.to_string(),
2022 marker: marker.to_string(),
2023 });
2024 } else {
2025 elements.push(Element::Text(code_raw.to_string()));
2026 }
2027 remaining = &remaining[span_len..];
2028 }
2029 "attr_list" => {
2030 elements.push(Element::AttrList(remaining[..attr_list_len].to_string()));
2031 remaining = &remaining[attr_list_len..];
2032 }
2033 "myst_role" => {
2034 elements.push(Element::MystRole(remaining[..myst_role_len].to_string()));
2035 remaining = &remaining[myst_role_len..];
2036 }
2037 "pulldown_emphasis" => {
2038 let span = pulldown_emphasis.expect("pulldown_emphasis must be set");
2040 let span_len = span.end - span.start;
2041 if span.is_strikethrough {
2042 elements.push(Element::Strikethrough {
2043 content: span.content.clone(),
2044 double: span.strikethrough_double,
2045 });
2046 } else if span.is_strong {
2047 elements.push(Element::Bold {
2048 content: span.content.clone(),
2049 underscore: span.uses_underscore,
2050 });
2051 } else {
2052 elements.push(Element::Italic {
2053 content: span.content.clone(),
2054 underscore: span.uses_underscore,
2055 });
2056 }
2057 remaining = &remaining[span_len..];
2058 }
2059 _ => {
2060 elements.push(Element::Text(remaining.to_string()));
2062 break;
2063 }
2064 }
2065 }
2066 }
2067
2068 let mut merged_elements = Vec::new();
2070 for el in elements {
2071 match el {
2072 Element::Text(s) => {
2073 if let Some(Element::Text(last_s)) = merged_elements.last_mut() {
2074 last_s.push_str(&s);
2075 } else {
2076 merged_elements.push(Element::Text(s));
2077 }
2078 }
2079 other => merged_elements.push(other),
2080 }
2081 }
2082 merged_elements
2083}
2084
2085fn source_gap_before(elements: &[Element], idx: usize) -> &str {
2099 let Some(Element::Text(previous)) = idx.checked_sub(1).map(|prev| &elements[prev]) else {
2100 return "";
2101 };
2102
2103 let gap = &previous[previous.trim_end_matches(char::is_whitespace).len()..];
2104 if gap.is_empty() {
2105 ""
2106 } else if gap.contains(is_non_breaking_space) {
2107 gap
2108 } else {
2109 " "
2110 }
2111}
2112
2113fn push_source_gap(current_line: &mut String, gap: &str) {
2116 if !gap.is_empty() && !current_line.is_empty() && !current_line.ends_with(char::is_whitespace) {
2117 current_line.push_str(gap);
2118 }
2119}
2120
2121fn is_setext_or_thematic(text: &str) -> bool {
2127 let mut marker = 0u8;
2128 let mut count = 0usize;
2129 let mut has_space = false;
2130 for &b in text.as_bytes() {
2131 match b {
2132 b' ' | b'\t' => has_space = true,
2133 b'-' | b'=' | b'*' | b'_' => {
2134 if marker == 0 {
2135 marker = b;
2136 } else if b != marker {
2137 return false;
2138 }
2139 count += 1;
2140 }
2141 _ => return false,
2142 }
2143 }
2144 match marker {
2145 b'=' => !has_space,
2146 b'-' => !has_space || count >= 3,
2147 b'*' | b'_' => count >= 3,
2148 _ => false,
2149 }
2150}
2151
2152fn starts_block_construct(text: &str) -> bool {
2164 let text = text.trim_start();
2165 let bytes = text.as_bytes();
2166 let Some(&first) = bytes.first() else {
2167 return false;
2168 };
2169 let marker_then_boundary = |len: usize| bytes.len() == len || bytes[len] == b' ' || bytes[len] == b'\t';
2170 match first {
2171 b'>' => true,
2173 b'-' | b'*' | b'+' => marker_then_boundary(1) || is_setext_or_thematic(text),
2174 b'_' | b'=' => is_setext_or_thematic(text),
2175 b':' => is_definition_list_item(text) || text.starts_with(":::"),
2176 b'|' => true,
2177 b'#' => {
2178 let hashes = bytes.iter().take_while(|&&b| b == b'#').count();
2179 hashes <= 6 && marker_then_boundary(hashes)
2180 }
2181 b'`' => bytes.iter().take_while(|&&b| b == b'`').count() >= 3,
2182 b'~' => bytes.iter().take_while(|&&b| b == b'~').count() >= 3,
2183 b'0'..=b'9' => {
2190 let digits = bytes.iter().take_while(|b| b.is_ascii_digit()).count();
2191 digits <= 9
2192 && text[..digits].trim_start_matches('0') == "1"
2193 && bytes.len() > digits + 1
2194 && (bytes[digits] == b'.' || bytes[digits] == b')')
2195 && (bytes[digits + 1] == b' ' || bytes[digits + 1] == b'\t')
2196 }
2197 b'[' => {
2205 let mut escaped = false;
2206 let mut label_close = None;
2207 for (i, &b) in bytes.iter().enumerate().skip(1) {
2208 if escaped {
2209 escaped = false;
2210 } else if b == b'\\' {
2211 escaped = true;
2212 } else if b == b']' {
2213 label_close = Some(i);
2214 break;
2215 }
2216 }
2217 label_close.is_some_and(|i| bytes.get(i + 1) == Some(&b':'))
2218 }
2219 b'<' => crate::utils::html_block::parse_html_block_start(text).is_some(),
2222 _ => false,
2223 }
2224}
2225
2226fn merge_block_construct_continuations(lines: Vec<String>) -> Vec<String> {
2235 let mut merged: Vec<String> = Vec::with_capacity(lines.len());
2236 for line in lines {
2237 merged.push(line);
2238 while merged.len() > 1 && starts_block_construct(merged.last().expect("non-empty")) {
2242 let last = merged.pop().expect("non-empty");
2243 let prev = merged.last_mut().expect("len > 1");
2244 prev.push(' ');
2245 prev.push_str(last.trim_start());
2246 }
2247 }
2248 merged
2249}
2250
2251fn reflow_elements_sentence_per_line(
2253 elements: &[Element],
2254 custom_abbreviations: &Option<Vec<String>>,
2255 require_sentence_capital: bool,
2256) -> Vec<String> {
2257 let abbreviations = get_abbreviations(custom_abbreviations);
2258 let mut lines = Vec::new();
2259 let mut current_line = String::new();
2260
2261 for (idx, element) in elements.iter().enumerate() {
2262 let is_span = matches!(
2268 element,
2269 Element::Italic { .. } | Element::Bold { .. } | Element::Strikethrough { .. }
2270 );
2271 let piece = match element {
2272 Element::Text(text) => Some(text.clone()),
2274 Element::Italic { content, underscore } => Some(wrap_emphasis(
2275 content,
2276 if *underscore { "_" } else { "*" },
2277 &mut current_line,
2278 source_gap_before(elements, idx),
2279 )),
2280 Element::Bold { content, underscore } => Some(wrap_emphasis(
2281 content,
2282 if *underscore { "__" } else { "**" },
2283 &mut current_line,
2284 source_gap_before(elements, idx),
2285 )),
2286 Element::Strikethrough { content, double } => Some(wrap_emphasis(
2287 content,
2288 if *double { "~~" } else { "~" },
2289 &mut current_line,
2290 source_gap_before(elements, idx),
2291 )),
2292 _ => None,
2293 };
2294
2295 if let Some(piece) = piece {
2296 let appended_span_start = is_span.then_some(current_line.len());
2300 let combined = format!("{current_line}{piece}");
2301 let sentences =
2303 split_into_sentences_with_set(&combined, &abbreviations, require_sentence_capital, appended_span_start);
2304
2305 if sentences.len() > 1 {
2306 let mut pending = String::new();
2310 let last = sentences.len() - 1;
2311 for (i, sentence) in sentences.iter().enumerate() {
2312 if !pending.is_empty() {
2313 pending.push(' ');
2314 }
2315 pending.push_str(sentence);
2316
2317 let closed = i < last || ends_with_sentence_punct(&pending);
2322 if closed && !text_ends_with_abbreviation(&pending, &abbreviations) {
2323 lines.push(std::mem::take(&mut pending));
2324 }
2325 }
2326 current_line = pending;
2327 } else {
2328 let trimmed = combined.trim();
2330
2331 if trimmed.is_empty() {
2335 continue;
2336 }
2337
2338 let ends_with_sentence_punct = ends_with_sentence_punct(trimmed);
2339
2340 if ends_with_sentence_punct && !text_ends_with_abbreviation(trimmed, &abbreviations) {
2341 lines.push(combined.trim_matches(is_breakable_whitespace).to_string());
2344 current_line.clear();
2345 } else {
2346 current_line = combined;
2348 }
2349 }
2350 } else {
2351 let element_str = format!("{element}");
2353 push_source_gap(&mut current_line, source_gap_before(elements, idx));
2354 current_line.push_str(&element_str);
2355 }
2356 }
2357
2358 if !current_line.is_empty() {
2360 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
2361 }
2362 lines
2363}
2364
2365fn wrap_emphasis(content: &str, marker: &str, current_line: &mut String, gap: &str) -> String {
2369 push_source_gap(current_line, gap);
2370 format!("{marker}{content}{marker}")
2371}
2372
2373const BREAK_WORDS: &[&str] = &[
2377 "and",
2378 "or",
2379 "but",
2380 "nor",
2381 "yet",
2382 "so",
2383 "for",
2384 "which",
2385 "that",
2386 "because",
2387 "when",
2388 "if",
2389 "while",
2390 "where",
2391 "although",
2392 "though",
2393 "unless",
2394 "since",
2395 "after",
2396 "before",
2397 "until",
2398 "as",
2399 "once",
2400 "whether",
2401 "however",
2402 "therefore",
2403 "moreover",
2404 "furthermore",
2405 "nevertheless",
2406 "whereas",
2407];
2408
2409fn is_clause_punctuation(c: char) -> bool {
2411 matches!(c, ',' | ';' | ':' | '\u{2014}') }
2413
2414fn clause_break_allowed_after(chars: &[char], i: usize) -> bool {
2424 match chars.get(i + 1) {
2425 None => true,
2426 Some(next) => is_breakable_whitespace(*next),
2427 }
2428}
2429
2430fn paren_group_end<'a>(slice: &'a str, element_spans: &[ElementSpan], offset: usize) -> Option<(usize, &'a str)> {
2444 debug_assert!(slice.starts_with('('));
2445 let mut depth: i32 = 0;
2446 for (local_byte, c) in slice.char_indices() {
2447 let global_byte = offset + local_byte;
2448 if depth > 0 && is_inside_element(global_byte, element_spans) {
2453 continue;
2454 }
2455 match c {
2456 '(' => depth += 1,
2457 ')' => {
2458 depth -= 1;
2459 if depth == 0 {
2460 let end = local_byte + 1;
2461 let inner = &slice[1..local_byte];
2462 return Some((end, inner));
2463 }
2464 }
2465 _ => {}
2466 }
2467 }
2468 None
2469}
2470
2471fn split_at_parenthetical(
2488 text: &str,
2489 line_length: usize,
2490 element_spans: &[ElementSpan],
2491 length_mode: ReflowLengthMode,
2492) -> Option<(String, String)> {
2493 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
2494
2495 if text.starts_with('(')
2497 && let Some((end_local, inner)) = paren_group_end(text, element_spans, 0)
2498 && inner.contains(' ')
2499 {
2500 let mut first_end = end_local;
2507 loop {
2508 first_end += text[first_end..]
2509 .char_indices()
2510 .take_while(|(_, c)| !is_breakable_whitespace(*c))
2511 .last()
2512 .map_or(0, |(idx, c)| idx + c.len_utf8());
2513 match element_containing(first_end, element_spans) {
2514 Some(span) => first_end = span.end,
2515 None => break,
2516 }
2517 }
2518 let rest_start = first_end;
2519 let first = &text[..first_end];
2520 if measure(first, 0, element_spans, length_mode).fits(line_length) {
2523 let rest = text[rest_start..].trim_start();
2524 if !rest.is_empty() {
2525 return Some((first.to_string(), rest.to_string()));
2526 }
2527 }
2528 }
2529
2530 let mut best_open_byte: Option<usize> = None;
2532 let mut pos = 0usize;
2533 while pos < text.len() {
2534 if text.as_bytes()[pos] != b'(' {
2536 let c = text[pos..].chars().next().unwrap();
2537 pos += c.len_utf8();
2538 continue;
2539 }
2540 if is_inside_element(pos, element_spans) {
2542 pos += 1;
2543 continue;
2544 }
2545 if let Some((end_local, inner)) = paren_group_end(&text[pos..], element_spans, pos) {
2546 let first = text[..pos].trim_end_matches(is_breakable_whitespace);
2547 let first_len = measure(first, 0, element_spans, length_mode).effective();
2548 if first.len() < pos
2551 && !first.is_empty()
2552 && first_len >= min_first_len
2553 && first_len <= line_length
2554 && inner.contains(' ')
2555 && best_open_byte.is_none_or(|prev| pos > prev)
2556 {
2557 best_open_byte = Some(pos);
2558 }
2559 pos += end_local;
2560 } else {
2561 pos += 1;
2562 }
2563 }
2564
2565 let open_byte = best_open_byte?;
2566 let first = text[..open_byte].trim_end_matches(is_breakable_whitespace).to_string();
2567 let rest = text[open_byte..].to_string();
2568 if first.is_empty() || rest.trim().is_empty() {
2569 return None;
2570 }
2571 Some((first, rest))
2572}
2573
2574#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2582struct ElementSpan {
2583 start: usize,
2584 end: usize,
2585 link_saving: usize,
2588 code_saving: usize,
2590}
2591
2592impl ElementSpan {
2593 fn new(start: usize, len: usize, full: usize, width: LineWidth) -> Self {
2596 Self {
2597 start,
2598 end: start + len,
2599 link_saving: full - width.link_exempt,
2600 code_saving: full - width.code_exempt,
2601 }
2602 }
2603
2604 fn contains(&self, pos: usize) -> bool {
2605 pos > self.start && pos < self.end
2606 }
2607
2608 fn within(&self, start: usize, end: usize) -> bool {
2609 self.start >= start && self.end <= end
2610 }
2611}
2612
2613fn compute_element_spans(
2619 elements: &[Element],
2620 mode: ReflowLengthMode,
2621 exemptions: LengthExemptions,
2622) -> Vec<ElementSpan> {
2623 let mut spans = Vec::new();
2624 let mut offset = 0;
2625 for element in elements {
2626 let len = element.display_len(ReflowLengthMode::Bytes);
2627 if !matches!(element, Element::Text(_)) {
2628 let full = element.display_len(mode);
2629 let width = element.exempt_width(mode, exemptions);
2630 spans.push(ElementSpan::new(offset, len, full, width));
2631 }
2632 offset += len;
2633 }
2634 spans
2635}
2636
2637fn measure(text: &str, offset: usize, spans: &[ElementSpan], mode: ReflowLengthMode) -> LineWidth {
2645 let full = display_len(text, mode);
2646 let end = offset + text.len();
2647 let mut width = LineWidth::plain(full);
2648 for span in spans.iter().filter(|span| span.within(offset, end)) {
2649 width.link_exempt -= span.link_saving;
2650 width.code_exempt -= span.code_saving;
2651 }
2652 width
2653}
2654
2655fn line_width_components(line: &str, options: &ReflowOptions) -> LineWidth {
2660 let raw = display_len(line, options.length_mode);
2661 if !options.length_exemptions.any() {
2662 return LineWidth::plain(raw);
2663 }
2664 let elements = parse_markdown_elements_inner(
2665 line,
2666 options.attr_lists,
2667 options.myst_roles,
2668 options.defined_references.as_ref(),
2669 );
2670 let spans = compute_element_spans(&elements, options.length_mode, options.length_exemptions);
2671 measure(line, 0, &spans, options.length_mode)
2672}
2673
2674fn line_width(line: &str, options: &ReflowOptions) -> usize {
2676 line_width_components(line, options).effective()
2677}
2678
2679fn line_fits(line: &str, options: &ReflowOptions) -> bool {
2685 display_len(line, options.length_mode) <= options.line_length || line_width(line, options) <= options.line_length
2686}
2687
2688fn element_containing(pos: usize, spans: &[ElementSpan]) -> Option<ElementSpan> {
2690 spans.iter().copied().find(|span| span.contains(pos))
2691}
2692
2693fn is_inside_element(pos: usize, spans: &[ElementSpan]) -> bool {
2695 element_containing(pos, spans).is_some()
2696}
2697
2698const MIN_SPLIT_RATIO: f64 = 0.3;
2701
2702fn split_at_clause_punctuation(
2706 text: &str,
2707 line_length: usize,
2708 element_spans: &[ElementSpan],
2709 length_mode: ReflowLengthMode,
2710) -> Option<(String, String)> {
2711 let chars: Vec<char> = text.chars().collect();
2712 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
2713
2714 let mut width_acc = LineWidth::default();
2720 let mut search_end_char = 0;
2721 let mut byte = 0usize;
2722 let mut idx = 0usize;
2723 while idx < chars.len() {
2724 let (advance_chars, advance_bytes, width) = match element_spans.iter().find(|s| s.start == byte) {
2725 Some(span) => {
2726 let source = &text[span.start..span.end];
2727 (
2728 source.chars().count(),
2729 source.len(),
2730 measure(source, span.start, element_spans, length_mode),
2731 )
2732 }
2733 None => {
2734 let c = chars[idx];
2735 (
2736 1,
2737 c.len_utf8(),
2738 LineWidth::plain(display_len(&c.to_string(), length_mode)),
2739 )
2740 }
2741 };
2742 if !(width_acc + width).fits(line_length) {
2743 break;
2744 }
2745 width_acc += width;
2746 byte += advance_bytes;
2747 idx += advance_chars;
2748 search_end_char = idx;
2749 }
2750
2751 let mut paren_depth: i32 = 0;
2758 let mut best_pos = None;
2759 for i in (0..search_end_char).rev() {
2760 let byte_start: usize = chars[..i].iter().map(|c| c.len_utf8()).sum();
2762 let byte_after: usize = byte_start + chars[i].len_utf8();
2764
2765 if !is_inside_element(byte_start, element_spans) {
2766 match chars[i] {
2767 ')' => paren_depth += 1,
2768 '(' => paren_depth = paren_depth.saturating_sub(1),
2769 _ => {}
2770 }
2771 }
2772
2773 if paren_depth == 0
2774 && is_clause_punctuation(chars[i])
2775 && clause_break_allowed_after(&chars, i)
2776 && !is_inside_element(byte_after, element_spans)
2777 {
2778 best_pos = Some(i);
2779 break;
2780 }
2781 }
2782
2783 let pos = best_pos?;
2784
2785 let first: String = chars[..=pos].iter().collect();
2787 if measure(&first, 0, element_spans, length_mode).effective() < min_first_len {
2788 return None;
2789 }
2790
2791 let rest: String = chars[pos + 1..].iter().collect();
2793 let rest = rest.trim_start().to_string();
2794
2795 if rest.is_empty() {
2796 return None;
2797 }
2798
2799 Some((first, rest))
2800}
2801
2802fn paren_depth_map(text: &str, element_spans: &[ElementSpan]) -> Vec<i32> {
2809 let mut map = vec![0i32; text.len()];
2810 let mut depth = 0i32;
2811 for (byte, c) in text.char_indices() {
2812 if !is_inside_element(byte, element_spans) {
2813 match c {
2814 '(' => depth += 1,
2815 ')' => depth = depth.saturating_sub(1),
2816 _ => {}
2817 }
2818 }
2819 let end = (byte + c.len_utf8()).min(map.len());
2821 for slot in &mut map[byte..end] {
2822 *slot = depth;
2823 }
2824 }
2825 map
2826}
2827
2828fn is_standalone_parenthetical(line: &str) -> bool {
2837 let trimmed = line.trim();
2838 if !trimmed.starts_with('(') {
2839 return false;
2840 }
2841 let Some(close) = trimmed.rfind(')') else {
2844 return false;
2845 };
2846 if trimmed[close + 1..].contains(char::is_whitespace) {
2847 return false;
2848 }
2849 let core = &trimmed[..=close];
2850 let inner = &core[1..core.len() - 1];
2852 if !inner.contains(' ') {
2853 return false;
2854 }
2855 let mut depth = 0i32;
2857 for c in core.chars() {
2858 match c {
2859 '(' => depth += 1,
2860 ')' => depth -= 1,
2861 _ => {}
2862 }
2863 if depth < 0 {
2864 return false;
2865 }
2866 }
2867 depth == 0
2868}
2869
2870fn split_at_break_word(
2874 text: &str,
2875 line_length: usize,
2876 element_spans: &[ElementSpan],
2877 length_mode: ReflowLengthMode,
2878) -> Option<(String, String)> {
2879 let lower = text.to_lowercase();
2880 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
2881 let mut best_split: Option<(usize, usize)> = None; let depth_map = paren_depth_map(text, element_spans);
2886
2887 for &word in BREAK_WORDS {
2888 let mut search_start = 0;
2889 while let Some(pos) = lower[search_start..].find(word) {
2890 let abs_pos = search_start + pos;
2891
2892 let preceded_by_space = abs_pos == 0 || text.as_bytes().get(abs_pos - 1) == Some(&b' ');
2894 let followed_by_space = text.as_bytes().get(abs_pos + word.len()) == Some(&b' ');
2895
2896 if preceded_by_space && followed_by_space {
2897 let first_part = text[..abs_pos].trim_end();
2899 let first_part_len = measure(first_part, 0, element_spans, length_mode).effective();
2900
2901 let inside_paren = depth_map.get(abs_pos).is_some_and(|&d| d > 0);
2903
2904 if first_part_len >= min_first_len
2905 && first_part_len <= line_length
2906 && !is_inside_element(abs_pos, element_spans)
2907 && !inside_paren
2908 {
2909 if best_split.is_none_or(|(prev_pos, _)| abs_pos > prev_pos) {
2911 best_split = Some((abs_pos, word.len()));
2912 }
2913 }
2914 }
2915
2916 search_start = abs_pos + word.len();
2917 }
2918 }
2919
2920 let (byte_start, _word_len) = best_split?;
2921
2922 let first = text[..byte_start].trim_end().to_string();
2923 let rest = text[byte_start..].to_string();
2924
2925 if first.is_empty() || rest.trim().is_empty() {
2926 return None;
2927 }
2928
2929 Some((first, rest))
2930}
2931
2932fn replaces_whitespace(text: &str, first: &str, rest: &str, element_spans: &[ElementSpan]) -> bool {
2943 if !text.starts_with(first) || !text.ends_with(rest) {
2944 return false;
2945 }
2946 let gap_end = text.len() - rest.len();
2947 gap_end > first.len()
2948 && text[first.len()..gap_end].chars().all(is_breakable_whitespace)
2949 && !element_spans
2950 .iter()
2951 .any(|span| first.len() < span.end && span.start < gap_end)
2952}
2953
2954fn cascade_split_line(text: &str, options: &ReflowOptions) -> Vec<String> {
2965 let line_length = options.line_length;
2966 let length_mode = options.length_mode;
2967 let attr_lists = options.attr_lists;
2968 let myst_roles = options.myst_roles;
2969 let defined_references = options.defined_references.as_ref();
2970 if line_length == 0 || display_len(text, length_mode) <= line_length {
2971 return vec![text.to_string()];
2972 }
2973
2974 let elements = parse_markdown_elements_inner(text, attr_lists, myst_roles, defined_references);
2975 let element_spans = compute_element_spans(&elements, length_mode, options.length_exemptions);
2976
2977 if measure(text, 0, &element_spans, length_mode).fits(line_length) {
2980 return vec![text.to_string()];
2981 }
2982
2983 let rebased_spans = |start: usize| -> Vec<ElementSpan> {
2987 if start == 0 {
2988 return element_spans.clone();
2989 }
2990 element_spans
2991 .iter()
2992 .filter(|span| span.end > start)
2993 .map(|span| ElementSpan {
2994 start: span.start.saturating_sub(start),
2995 end: span.end.saturating_sub(start),
2996 ..*span
2997 })
2998 .collect()
2999 };
3000
3001 let mut result = Vec::new();
3002 let mut start = 0usize;
3003
3004 loop {
3005 let remaining = &text[start..];
3006 let spans = rebased_spans(start);
3007 if measure(remaining, 0, &spans, length_mode).fits(line_length) {
3008 result.push(remaining.to_string());
3009 return result;
3010 }
3011
3012 let at_whitespace = |candidate: Option<(String, String)>| {
3021 candidate.filter(|(first, rest)| replaces_whitespace(remaining, first, rest, &spans))
3022 };
3023 let split = at_whitespace(split_at_parenthetical(remaining, line_length, &spans, length_mode))
3024 .or_else(|| at_whitespace(split_at_clause_punctuation(remaining, line_length, &spans, length_mode)))
3025 .or_else(|| at_whitespace(split_at_break_word(remaining, line_length, &spans, length_mode)));
3026
3027 if let Some((first, rest)) = split {
3028 let consumed = remaining.len().saturating_sub(rest.len());
3029 if consumed == 0 {
3032 break;
3033 }
3034 result.push(first);
3035 start += consumed;
3036 continue;
3037 }
3038
3039 break;
3041 }
3042
3043 let mut fallback_options = options.clone();
3045 fallback_options.break_on_sentences = false;
3046 fallback_options.preserve_breaks = false;
3047 fallback_options.sentence_per_line = false;
3048 fallback_options.semantic_line_breaks = false;
3049 fallback_options.require_sentence_capital = true;
3050 fallback_options.max_list_continuation_indent = None;
3051 fallback_options.defined_references = None;
3052 let remaining = &text[start..];
3053 let tail_elements = if start == 0 {
3054 elements
3055 } else {
3056 parse_markdown_elements_inner(remaining, attr_lists, myst_roles, defined_references)
3057 };
3058 result.extend(reflow_elements(&tail_elements, &fallback_options));
3059 result
3060}
3061
3062fn reflow_elements_semantic(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
3066 let sentence_lines =
3068 reflow_elements_sentence_per_line(elements, &options.abbreviations, options.require_sentence_capital);
3069
3070 if options.line_length == 0 {
3073 return sentence_lines;
3074 }
3075
3076 let mut result = Vec::new();
3077 for line in sentence_lines {
3078 if line_fits(&line, options) {
3079 result.push(line);
3080 } else {
3081 result.extend(cascade_split_line(&line, options));
3082 }
3083 }
3084
3085 let min_line_len = ((options.line_length as f64) * MIN_SPLIT_RATIO) as usize;
3088 let mut merged: Vec<String> = Vec::with_capacity(result.len());
3089 for line in result {
3090 if !merged.is_empty() && line_width(&line, options) < min_line_len && !line.trim().is_empty() {
3091 if is_standalone_parenthetical(&line) {
3094 merged.push(line);
3095 continue;
3096 }
3097
3098 let prev_ends_at_sentence = {
3100 let trimmed = merged.last().unwrap().trim_end();
3101 trimmed
3102 .chars()
3103 .rev()
3104 .find(|c| !matches!(c, '"' | '\'' | '\u{201D}' | '\u{2019}' | ')' | ']'))
3105 .is_some_and(|c| matches!(c, '.' | '!' | '?'))
3106 };
3107
3108 if !prev_ends_at_sentence {
3109 let prev = merged.last_mut().unwrap();
3110 let combined = format!("{prev} {line}");
3111 if line_fits(&combined, options) {
3113 *prev = combined;
3114 continue;
3115 }
3116 }
3117 }
3118 merged.push(line);
3119 }
3120 merged
3121}
3122
3123fn rfind_safe_space(line: &str, element_spans: &[ElementSpan]) -> Option<usize> {
3133 line.char_indices().rev().map(|(pos, _)| pos).find(|&pos| {
3134 line.as_bytes()[pos] == b' '
3135 && !is_inside_element(pos, element_spans)
3136 && !starts_block_construct(&line[pos + 1..])
3137 })
3138}
3139
3140#[derive(Clone, Copy)]
3145struct Attached<'a> {
3146 text: &'a str,
3147 width: LineWidth,
3148 separator: &'a str,
3149}
3150
3151fn break_before_attached(
3168 lines: &mut Vec<String>,
3169 current_line: &mut String,
3170 current_width: &mut LineWidth,
3171 element_spans: &mut Vec<ElementSpan>,
3172 attach: Attached<'_>,
3173 length_mode: ReflowLengthMode,
3174) -> Option<usize> {
3175 let last_space = rfind_safe_space(current_line, element_spans)?;
3176 let before = current_line[..last_space]
3177 .trim_end_matches(is_breakable_whitespace)
3178 .to_string();
3179 let after = current_line[last_space + 1..].to_string();
3180 let after_width = measure(&after, last_space + 1, element_spans, length_mode);
3181 lines.push(before);
3182 let carried = after.len();
3183 let Attached { text, width, separator } = attach;
3184 *current_line = format!("{after}{separator}{text}");
3185 *current_width = after_width + LineWidth::plain(display_len(separator, length_mode)) + width;
3186 rebase_spans_after_break(element_spans, last_space + 1);
3187 Some(carried)
3188}
3189
3190fn rebase_spans_after_break(element_spans: &mut Vec<ElementSpan>, carried_start: usize) {
3199 element_spans.retain(|span| span.end > carried_start);
3200 for span in element_spans.iter_mut() {
3201 span.start = span.start.saturating_sub(carried_start);
3202 span.end -= carried_start;
3203 }
3204}
3205
3206fn reflow_elements(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
3208 let mut lines = Vec::new();
3209 let mut current_line = String::new();
3210 let mut current_width = LineWidth::default();
3213 let mut current_line_element_spans: Vec<ElementSpan> = Vec::new();
3215 let length_mode = options.length_mode;
3216 let exemptions = options.length_exemptions;
3217
3218 for (idx, element) in elements.iter().enumerate() {
3219 let element_len = element.display_len(length_mode);
3220 let element_width = element.exempt_width(length_mode, exemptions);
3221
3222 let is_adjacent_to_prev = if idx > 0 {
3231 match (&elements[idx - 1], element) {
3232 (Element::Text(t), _) => !t.is_empty() && !t.ends_with(is_breakable_whitespace),
3233 (_, Element::Text(t)) => !t.is_empty() && !t.starts_with(is_breakable_whitespace),
3234 _ => true,
3235 }
3236 } else {
3237 false
3238 };
3239
3240 if let Element::Text(text) = element {
3242 let has_leading_space = text.starts_with(is_breakable_whitespace);
3244 let words: Vec<&str> = split_breakable_words(text).collect();
3246
3247 for (i, word) in words.iter().enumerate() {
3248 let word_width = LineWidth::plain(display_len(word, length_mode));
3250 let is_trailing_punct = word.chars().all(|c| {
3256 matches!(c, ',' | '.' | ':' | ';' | '!' | '?' | ')' | ']' | '}') || is_non_breaking_space(c)
3257 });
3258
3259 let is_first_adjacent = i == 0 && is_adjacent_to_prev;
3262
3263 if is_first_adjacent {
3264 if !(current_width + word_width).fits(options.line_length)
3266 && !current_width.is_empty()
3267 && break_before_attached(
3268 &mut lines,
3269 &mut current_line,
3270 &mut current_width,
3271 &mut current_line_element_spans,
3272 Attached {
3273 text: word,
3274 width: word_width,
3275 separator: "",
3276 },
3277 length_mode,
3278 )
3279 .is_some()
3280 {
3281 } else {
3286 current_line.push_str(word);
3287 current_width += word_width;
3288 }
3289 } else if !current_width.is_empty()
3290 && !(current_width + LineWidth::plain(1) + word_width).fits(options.line_length)
3291 {
3292 if is_trailing_punct {
3293 if break_before_attached(
3300 &mut lines,
3301 &mut current_line,
3302 &mut current_width,
3303 &mut current_line_element_spans,
3304 Attached {
3305 text: word,
3306 width: word_width,
3307 separator: " ",
3308 },
3309 length_mode,
3310 )
3311 .is_none()
3312 {
3313 current_line.push(' ');
3314 current_line.push_str(word);
3315 current_width += LineWidth::plain(1) + word_width;
3316 }
3317 } else if !starts_block_construct(word) {
3318 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
3320 current_line = word.to_string();
3321 current_width = word_width;
3322 current_line_element_spans.clear();
3323 } else if break_before_attached(
3324 &mut lines,
3325 &mut current_line,
3326 &mut current_width,
3327 &mut current_line_element_spans,
3328 Attached {
3329 text: word,
3330 width: word_width,
3331 separator: " ",
3332 },
3333 length_mode,
3334 )
3335 .is_some()
3336 {
3337 } else {
3342 if i > 0 || has_leading_space {
3345 current_line.push(' ');
3346 current_width += LineWidth::plain(1);
3347 }
3348 current_line.push_str(word);
3349 current_width += word_width;
3350 }
3351 } else {
3352 let add_space = !current_width.is_empty() && (i > 0 || has_leading_space);
3364 if add_space {
3365 current_line.push(' ');
3366 current_width += LineWidth::plain(1);
3367 }
3368 current_line.push_str(word);
3369 current_width += word_width;
3370 }
3371 }
3372 } else {
3373 let span_info = match element {
3374 Element::Italic { content, underscore } => {
3375 Some((content.as_str(), if *underscore { "_" } else { "*" }, false))
3376 }
3377 Element::Bold { content, underscore } => {
3378 Some((content.as_str(), if *underscore { "__" } else { "**" }, false))
3379 }
3380 Element::Strikethrough { content, double } => {
3381 Some((content.as_str(), if *double { "~~" } else { "~" }, false))
3382 }
3383 Element::Code { content, marker } => Some((content.as_str(), marker.as_str(), true)),
3384 _ => None,
3385 };
3386
3387 let breakable: Option<Vec<&str>> = match span_info {
3391 Some((content, _, is_code)) => {
3392 if is_code {
3393 (!options.atomic_spans && code_span_wraps_losslessly(content))
3394 .then(|| split_breakable_words(content).collect())
3395 } else {
3396 (!options.atomic_spans || element_len > options.line_length)
3397 .then(|| breakable_units(content, options.defined_references.as_ref(), options.attr_lists))
3398 .flatten()
3399 }
3400 }
3401 None => None,
3402 };
3403
3404 if let Some(words) = breakable {
3405 let (_, marker, is_code) = span_info.expect("breakable implies a span");
3406 let n = words.len();
3407 if n == 0 {
3408 let full = format!("{marker}{marker}");
3410 let full_width = LineWidth::plain(display_len(&full, length_mode));
3411 if !is_adjacent_to_prev && !current_width.is_empty() {
3412 current_line.push(' ');
3413 current_width += LineWidth::plain(1);
3414 }
3415 current_line.push_str(&full);
3416 current_width += full_width;
3417 } else {
3418 for (i, word) in words.iter().enumerate() {
3419 let is_first = i == 0;
3420 let is_last = i == n - 1;
3421
3422 let space_start = if is_first && is_code && word.starts_with('`') {
3423 " "
3424 } else {
3425 ""
3426 };
3427 let space_end = if is_last && is_code && word.ends_with('`') {
3428 " "
3429 } else {
3430 ""
3431 };
3432
3433 let word_str: String = match (is_first, is_last) {
3434 (true, true) => format!("{marker}{space_start}{word}{space_end}{marker}"),
3435 (true, false) => format!("{marker}{space_start}{word}"),
3436 (false, true) => format!("{word}{space_end}{marker}"),
3437 (false, false) => word.to_string(),
3438 };
3439 let word_width = LineWidth::plain(display_len(&word_str, length_mode));
3442
3443 let needs_space = if is_first {
3444 !is_adjacent_to_prev && !current_width.is_empty()
3445 } else {
3446 !current_width.is_empty()
3447 };
3448
3449 if needs_space
3450 && !(current_width + LineWidth::plain(1) + word_width).fits(options.line_length)
3451 && !starts_block_construct(&word_str)
3452 {
3453 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
3454 current_line = word_str;
3455 current_width = word_width;
3456 current_line_element_spans.clear();
3457 } else {
3458 if needs_space {
3459 current_line.push(' ');
3460 current_width += LineWidth::plain(1);
3461 }
3462 current_line.push_str(&word_str);
3463 current_width += word_width;
3464 }
3465 }
3466 }
3467 } else {
3468 let element_str = format!("{element}");
3471
3472 if is_adjacent_to_prev {
3473 if !(current_width + element_width).fits(options.line_length)
3475 && let Some(carried) = break_before_attached(
3476 &mut lines,
3477 &mut current_line,
3478 &mut current_width,
3479 &mut current_line_element_spans,
3480 Attached {
3481 text: &element_str,
3482 width: element_width,
3483 separator: "",
3484 },
3485 length_mode,
3486 )
3487 {
3488 current_line_element_spans.push(ElementSpan::new(
3492 carried,
3493 element_str.len(),
3494 element_len,
3495 element_width,
3496 ));
3497 } else {
3498 let start = current_line.len();
3499 current_line.push_str(&element_str);
3500 current_width += element_width;
3501 current_line_element_spans.push(ElementSpan::new(
3502 start,
3503 element_str.len(),
3504 element_len,
3505 element_width,
3506 ));
3507 }
3508 } else if !current_width.is_empty()
3509 && !(current_width + LineWidth::plain(1) + element_width).fits(options.line_length)
3510 {
3511 if !starts_block_construct(&element_str) {
3512 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
3514 current_line.clone_from(&element_str);
3515 current_width = element_width;
3516 current_line_element_spans.clear();
3517 current_line_element_spans.push(ElementSpan::new(
3518 0,
3519 element_str.len(),
3520 element_len,
3521 element_width,
3522 ));
3523 } else if let Some(carried) = break_before_attached(
3524 &mut lines,
3525 &mut current_line,
3526 &mut current_width,
3527 &mut current_line_element_spans,
3528 Attached {
3529 text: &element_str,
3530 width: element_width,
3531 separator: " ",
3532 },
3533 length_mode,
3534 ) {
3535 let start = carried + 1;
3539 current_line_element_spans.push(ElementSpan::new(
3540 start,
3541 element_str.len(),
3542 element_len,
3543 element_width,
3544 ));
3545 } else {
3546 let ends_with_opener =
3549 current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
3550 if !ends_with_opener {
3551 current_line.push(' ');
3552 current_width += LineWidth::plain(1);
3553 }
3554 let start = current_line.len();
3555 current_line.push_str(&element_str);
3556 current_width += element_width;
3557 current_line_element_spans.push(ElementSpan::new(
3558 start,
3559 element_str.len(),
3560 element_len,
3561 element_width,
3562 ));
3563 }
3564 } else {
3565 let ends_with_opener =
3567 current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
3568 if !current_width.is_empty() && !ends_with_opener {
3569 current_line.push(' ');
3570 current_width += LineWidth::plain(1);
3571 }
3572 let start = current_line.len();
3573 current_line.push_str(&element_str);
3574 current_width += element_width;
3575 current_line_element_spans.push(ElementSpan::new(
3576 start,
3577 element_str.len(),
3578 element_len,
3579 element_width,
3580 ));
3581 }
3582 }
3583 }
3584 }
3585
3586 if !current_line.is_empty() {
3588 lines.push(current_line.trim_end_matches(is_breakable_whitespace).to_string());
3589 }
3590
3591 lines
3592}
3593
3594pub fn reflow_markdown(content: &str, options: &ReflowOptions) -> String {
3596 let lines: Vec<&str> = content.lines().collect();
3597 let mut result = Vec::new();
3598 let mut i = 0;
3599
3600 while i < lines.len() {
3601 let line = lines[i];
3602 let trimmed = line.trim();
3603
3604 if trimmed.is_empty() {
3606 result.push(String::new());
3607 i += 1;
3608 continue;
3609 }
3610
3611 if trimmed.starts_with('#') {
3613 result.push(line.to_string());
3614 i += 1;
3615 continue;
3616 }
3617
3618 if trimmed.starts_with(":::") {
3620 result.push(line.to_string());
3621 i += 1;
3622 continue;
3623 }
3624
3625 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
3627 result.push(line.to_string());
3628 i += 1;
3629 while i < lines.len() {
3631 result.push(lines[i].to_string());
3632 if lines[i].trim().starts_with("```") || lines[i].trim().starts_with("~~~") {
3633 i += 1;
3634 break;
3635 }
3636 i += 1;
3637 }
3638 continue;
3639 }
3640
3641 if calculate_indentation_width_default(line) >= 4 {
3643 result.push(line.to_string());
3645 i += 1;
3646 while i < lines.len() {
3647 let next_line = lines[i];
3648 if calculate_indentation_width_default(next_line) >= 4 || next_line.trim().is_empty() {
3650 result.push(next_line.to_string());
3651 i += 1;
3652 } else {
3653 break;
3654 }
3655 }
3656 continue;
3657 }
3658
3659 if trimmed.starts_with('>') {
3661 let gt_pos = line.find('>').expect("'>' must exist since trimmed.starts_with('>')");
3664 let quote_prefix = line[0..=gt_pos].to_string();
3665 let quote_content = &line[quote_prefix.len()..].trim_start();
3666
3667 let reflowed = reflow_line(quote_content, options);
3668 for reflowed_line in &reflowed {
3669 result.push(format!("{quote_prefix} {reflowed_line}"));
3670 }
3671 i += 1;
3672 continue;
3673 }
3674
3675 if is_horizontal_rule(trimmed) {
3677 result.push(line.to_string());
3678 i += 1;
3679 continue;
3680 }
3681
3682 if is_unordered_list_marker(trimmed) || is_numbered_list_item(trimmed) {
3684 let indent = line.len() - line.trim_start().len();
3686 let indent_str = " ".repeat(indent);
3687
3688 let mut marker_end = indent;
3691 let mut content_start = indent;
3692
3693 if trimmed.chars().next().is_some_and(char::is_numeric) {
3694 if let Some(period_pos) = line[indent..].find('.') {
3696 marker_end = indent + period_pos + 1; content_start = marker_end;
3698 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
3702 content_start += 1;
3703 }
3704 }
3705 } else {
3706 marker_end = indent + 1; content_start = marker_end;
3709 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
3713 content_start += 1;
3714 }
3715 }
3716
3717 let min_continuation_indent = content_start;
3719
3720 let rest = &line[content_start..];
3723 if rest.starts_with("[ ] ") || rest.starts_with("[x] ") || rest.starts_with("[X] ") {
3724 marker_end = content_start + 3; content_start += 4; }
3727
3728 let marker = &line[indent..marker_end];
3729
3730 let mut list_content = vec![trim_preserving_hard_break(&line[content_start..])];
3733 i += 1;
3734
3735 while i < lines.len() {
3739 let next_line = lines[i];
3740 let next_trimmed = next_line.trim();
3741
3742 if is_block_boundary(next_trimmed) {
3744 break;
3745 }
3746
3747 let next_indent = next_line.len() - next_line.trim_start().len();
3749 if next_indent >= min_continuation_indent {
3750 let trimmed_start = next_line.trim_start();
3753 list_content.push(trim_preserving_hard_break(trimmed_start));
3754 i += 1;
3755 } else {
3756 break;
3758 }
3759 }
3760
3761 let combined_content = if options.preserve_breaks {
3764 list_content[0].clone()
3765 } else {
3766 let has_hard_breaks = list_content.iter().any(|line| has_hard_break(line));
3768 if has_hard_breaks {
3769 list_content.join("\n")
3771 } else {
3772 list_content.join(" ")
3774 }
3775 };
3776
3777 let trimmed_marker = marker;
3779 let continuation_spaces = if let Some(max_indent) = options.max_list_continuation_indent {
3780 indent + (content_start - indent).min(max_indent)
3783 } else {
3784 content_start
3785 };
3786
3787 let prefix_length = indent + trimmed_marker.len() + 1;
3789
3790 let adjusted_options = ReflowOptions {
3792 line_length: options.line_length.saturating_sub(prefix_length),
3793 ..options.clone()
3794 };
3795
3796 let reflowed = reflow_line(&combined_content, &adjusted_options);
3797 for (j, reflowed_line) in reflowed.iter().enumerate() {
3798 if j == 0 {
3799 result.push(format!("{indent_str}{trimmed_marker} {reflowed_line}"));
3800 } else {
3801 let continuation_indent = " ".repeat(continuation_spaces);
3803 result.push(format!("{continuation_indent}{reflowed_line}"));
3804 }
3805 }
3806 continue;
3807 }
3808
3809 if crate::utils::table_utils::TableUtils::is_potential_table_row(line) {
3811 result.push(line.to_string());
3812 i += 1;
3813 continue;
3814 }
3815
3816 if trimmed.starts_with('[') && line.contains("]:") {
3818 result.push(line.to_string());
3819 i += 1;
3820 continue;
3821 }
3822
3823 if is_definition_list_item(trimmed) {
3825 result.push(line.to_string());
3826 i += 1;
3827 continue;
3828 }
3829
3830 let mut is_single_line_paragraph = true;
3832 if i + 1 < lines.len() {
3833 let next_trimmed = lines[i + 1].trim();
3834 if !is_block_boundary(next_trimmed) {
3836 is_single_line_paragraph = false;
3837 }
3838 }
3839
3840 if is_single_line_paragraph && line_fits(line, options) {
3842 result.push(line.to_string());
3843 i += 1;
3844 continue;
3845 }
3846
3847 let mut paragraph_parts = Vec::new();
3849 let mut current_part = vec![line];
3850 i += 1;
3851
3852 if options.preserve_breaks {
3854 let hard_break_type = if line.strip_suffix('\r').unwrap_or(line).ends_with('\\') {
3856 Some("\\")
3857 } else if line.ends_with(" ") {
3858 Some(" ")
3859 } else {
3860 None
3861 };
3862 let reflowed = reflow_line(line, options);
3863
3864 if let Some(break_marker) = hard_break_type {
3866 if !reflowed.is_empty() {
3867 let mut reflowed_with_break = reflowed;
3868 let last_idx = reflowed_with_break.len() - 1;
3869 if !has_hard_break(&reflowed_with_break[last_idx]) {
3870 reflowed_with_break[last_idx].push_str(break_marker);
3871 }
3872 result.extend(reflowed_with_break);
3873 }
3874 } else {
3875 result.extend(reflowed);
3876 }
3877 } else {
3878 while i < lines.len() {
3880 let prev_line = if !current_part.is_empty() {
3881 current_part.last().unwrap()
3882 } else {
3883 ""
3884 };
3885 let next_line = lines[i];
3886 let next_trimmed = next_line.trim();
3887
3888 if is_block_boundary(next_trimmed) {
3890 break;
3891 }
3892
3893 let prev_trimmed = prev_line.trim();
3896 let abbreviations = get_abbreviations(&options.abbreviations);
3897 let ends_with_sentence = (prev_trimmed.ends_with('.')
3898 || prev_trimmed.ends_with('!')
3899 || prev_trimmed.ends_with('?')
3900 || prev_trimmed.ends_with(".*")
3901 || prev_trimmed.ends_with("!*")
3902 || prev_trimmed.ends_with("?*")
3903 || prev_trimmed.ends_with("._")
3904 || prev_trimmed.ends_with("!_")
3905 || prev_trimmed.ends_with("?_")
3906 || prev_trimmed.ends_with(".\"")
3908 || prev_trimmed.ends_with("!\"")
3909 || prev_trimmed.ends_with("?\"")
3910 || prev_trimmed.ends_with(".'")
3911 || prev_trimmed.ends_with("!'")
3912 || prev_trimmed.ends_with("?'")
3913 || prev_trimmed.ends_with(".\u{201D}")
3914 || prev_trimmed.ends_with("!\u{201D}")
3915 || prev_trimmed.ends_with("?\u{201D}")
3916 || prev_trimmed.ends_with(".\u{2019}")
3917 || prev_trimmed.ends_with("!\u{2019}")
3918 || prev_trimmed.ends_with("?\u{2019}"))
3919 && !text_ends_with_abbreviation(
3920 prev_trimmed.trim_end_matches(['*', '_', '"', '\'', '\u{201D}', '\u{2019}']),
3921 &abbreviations,
3922 );
3923
3924 if has_hard_break(prev_line) || (options.sentence_per_line && ends_with_sentence) {
3925 paragraph_parts.push(current_part.join(" "));
3927 current_part = vec![next_line];
3928 } else {
3929 current_part.push(next_line);
3930 }
3931 i += 1;
3932 }
3933
3934 if !current_part.is_empty() {
3936 if current_part.len() == 1 {
3937 paragraph_parts.push(current_part[0].to_string());
3939 } else {
3940 paragraph_parts.push(current_part.join(" "));
3941 }
3942 }
3943
3944 for (j, part) in paragraph_parts.iter().enumerate() {
3946 let reflowed = reflow_line(part, options);
3947 result.extend(reflowed);
3948
3949 if j < paragraph_parts.len() - 1 && !result.is_empty() && !options.sentence_per_line {
3953 let last_idx = result.len() - 1;
3954 if !has_hard_break(&result[last_idx]) {
3955 result[last_idx].push_str(" ");
3956 }
3957 }
3958 }
3959 }
3960 }
3961
3962 let result_text = result.join("\n");
3964 if content.ends_with('\n') && !result_text.ends_with('\n') {
3965 format!("{result_text}\n")
3966 } else {
3967 result_text
3968 }
3969}
3970
3971#[derive(Debug, Clone)]
3973pub struct ParagraphReflow {
3974 pub start_byte: usize,
3976 pub end_byte: usize,
3978 pub reflowed_text: String,
3980}
3981
3982#[derive(Debug, Clone)]
3988pub struct BlockquoteLineData {
3989 pub(crate) content: String,
3991 pub(crate) is_explicit: bool,
3993 pub(crate) prefix: Option<String>,
3995}
3996
3997impl BlockquoteLineData {
3998 pub fn explicit(content: String, prefix: String) -> Self {
4000 Self {
4001 content,
4002 is_explicit: true,
4003 prefix: Some(prefix),
4004 }
4005 }
4006
4007 pub fn lazy(content: String) -> Self {
4009 Self {
4010 content,
4011 is_explicit: false,
4012 prefix: None,
4013 }
4014 }
4015}
4016
4017#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4019pub enum BlockquoteContinuationStyle {
4020 Explicit,
4021 Lazy,
4022}
4023
4024pub fn blockquote_continuation_style(lines: &[BlockquoteLineData]) -> BlockquoteContinuationStyle {
4032 let mut explicit_count = 0usize;
4033 let mut lazy_count = 0usize;
4034
4035 for line in lines.iter().skip(1) {
4036 if line.is_explicit {
4037 explicit_count += 1;
4038 } else {
4039 lazy_count += 1;
4040 }
4041 }
4042
4043 if explicit_count > 0 && lazy_count == 0 {
4044 BlockquoteContinuationStyle::Explicit
4045 } else if lazy_count > 0 && explicit_count == 0 {
4046 BlockquoteContinuationStyle::Lazy
4047 } else if explicit_count >= lazy_count {
4048 BlockquoteContinuationStyle::Explicit
4049 } else {
4050 BlockquoteContinuationStyle::Lazy
4051 }
4052}
4053
4054pub fn dominant_blockquote_prefix(lines: &[BlockquoteLineData], fallback: &str) -> String {
4059 let mut counts: std::collections::HashMap<String, (usize, usize)> = std::collections::HashMap::new();
4060
4061 for (idx, line) in lines.iter().enumerate() {
4062 let Some(prefix) = line.prefix.as_ref() else {
4063 continue;
4064 };
4065 counts
4066 .entry(prefix.clone())
4067 .and_modify(|entry| entry.0 += 1)
4068 .or_insert((1, idx));
4069 }
4070
4071 counts
4072 .into_iter()
4073 .max_by(|(_, (count_a, first_idx_a)), (_, (count_b, first_idx_b))| {
4074 count_a.cmp(count_b).then_with(|| first_idx_b.cmp(first_idx_a))
4075 })
4076 .map_or_else(|| fallback.to_string(), |(prefix, _)| prefix)
4077}
4078
4079pub(crate) fn should_force_explicit_blockquote_line(content_line: &str) -> bool {
4084 let trimmed = content_line.trim_start();
4085 trimmed.starts_with('>')
4086 || trimmed.starts_with('#')
4087 || trimmed.starts_with("```")
4088 || trimmed.starts_with("~~~")
4089 || is_unordered_list_marker(trimmed)
4090 || is_numbered_list_item(trimmed)
4091 || is_horizontal_rule(trimmed)
4092 || is_definition_list_item(trimmed)
4093 || (trimmed.starts_with('[') && trimmed.contains("]:"))
4094 || trimmed.starts_with(":::")
4095 || (trimmed.starts_with('<')
4096 && !trimmed.starts_with("<http")
4097 && !trimmed.starts_with("<https")
4098 && !trimmed.starts_with("<mailto:"))
4099}
4100
4101pub fn reflow_blockquote_content(
4110 lines: &[BlockquoteLineData],
4111 explicit_prefix: &str,
4112 continuation_style: BlockquoteContinuationStyle,
4113 options: &ReflowOptions,
4114) -> Vec<String> {
4115 let content_strs: Vec<&str> = lines.iter().map(|l| l.content.as_str()).collect();
4116 let segments = split_into_segments_strs(&content_strs);
4117 let mut reflowed_content_lines: Vec<String> = Vec::new();
4118
4119 for segment in segments {
4120 let hard_break_type = segment.last().and_then(|&line| {
4121 let line = line.strip_suffix('\r').unwrap_or(line);
4122 if line.ends_with('\\') {
4123 Some("\\")
4124 } else if line.ends_with(" ") {
4125 Some(" ")
4126 } else {
4127 None
4128 }
4129 });
4130
4131 let pieces: Vec<&str> = segment
4132 .iter()
4133 .map(|&line| {
4134 if let Some(l) = line.strip_suffix('\\') {
4135 l.trim_end()
4136 } else if let Some(l) = line.strip_suffix(" ") {
4137 l.trim_end()
4138 } else {
4139 line.trim_end()
4140 }
4141 })
4142 .collect();
4143
4144 let segment_text = pieces.join(" ");
4145 let segment_text = segment_text.trim();
4146 if segment_text.is_empty() {
4147 continue;
4148 }
4149
4150 let mut reflowed = reflow_line(segment_text, options);
4151 if let Some(break_marker) = hard_break_type
4152 && !reflowed.is_empty()
4153 {
4154 let last_idx = reflowed.len() - 1;
4155 if !has_hard_break(&reflowed[last_idx]) {
4156 reflowed[last_idx].push_str(break_marker);
4157 }
4158 }
4159 reflowed_content_lines.extend(reflowed);
4160 }
4161
4162 let mut styled_lines: Vec<String> = Vec::new();
4163 for (idx, line) in reflowed_content_lines.iter().enumerate() {
4164 let force_explicit = idx == 0
4165 || continuation_style == BlockquoteContinuationStyle::Explicit
4166 || should_force_explicit_blockquote_line(line);
4167 if force_explicit {
4168 styled_lines.push(format!("{explicit_prefix}{line}"));
4169 } else {
4170 styled_lines.push(line.clone());
4171 }
4172 }
4173
4174 styled_lines
4175}
4176
4177fn is_blockquote_content_boundary(content: &str) -> bool {
4178 let trimmed = content.trim();
4179 trimmed.is_empty()
4180 || is_block_boundary(trimmed)
4181 || crate::utils::table_utils::TableUtils::is_potential_table_row(content)
4182 || trimmed.starts_with(":::")
4183 || crate::utils::is_template_directive_only(content)
4184 || is_standalone_attr_list(content)
4185 || is_snippet_block_delimiter(content)
4186}
4187
4188fn split_into_segments_strs<'a>(lines: &[&'a str]) -> Vec<Vec<&'a str>> {
4189 let mut segments = Vec::new();
4190 let mut current = Vec::new();
4191
4192 for &line in lines {
4193 current.push(line);
4194 if has_hard_break(line) {
4195 segments.push(current);
4196 current = Vec::new();
4197 }
4198 }
4199
4200 if !current.is_empty() {
4201 segments.push(current);
4202 }
4203
4204 segments
4205}
4206
4207fn reflow_blockquote_paragraph_at_line(
4208 content: &str,
4209 lines: &[&str],
4210 target_idx: usize,
4211 options: &ReflowOptions,
4212) -> Option<ParagraphReflow> {
4213 let mut anchor_idx = target_idx;
4214 let mut target_level = if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[target_idx]) {
4215 parsed.nesting_level
4216 } else {
4217 let mut found = None;
4218 let mut idx = target_idx;
4219 loop {
4220 if lines[idx].trim().is_empty() {
4221 break;
4222 }
4223 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[idx]) {
4224 found = Some((idx, parsed.nesting_level));
4225 break;
4226 }
4227 if idx == 0 {
4228 break;
4229 }
4230 idx -= 1;
4231 }
4232 let (idx, level) = found?;
4233 anchor_idx = idx;
4234 level
4235 };
4236
4237 let mut para_start = anchor_idx;
4239 while para_start > 0 {
4240 let prev_idx = para_start - 1;
4241 let prev_line = lines[prev_idx];
4242
4243 if prev_line.trim().is_empty() {
4244 break;
4245 }
4246
4247 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(prev_line) {
4248 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
4249 break;
4250 }
4251 para_start = prev_idx;
4252 continue;
4253 }
4254
4255 let prev_lazy = prev_line.trim_start();
4256 if is_blockquote_content_boundary(prev_lazy) {
4257 break;
4258 }
4259 para_start = prev_idx;
4260 }
4261
4262 while para_start < lines.len() {
4264 let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[para_start]) else {
4265 para_start += 1;
4266 continue;
4267 };
4268 target_level = parsed.nesting_level;
4269 break;
4270 }
4271
4272 if para_start >= lines.len() || para_start > target_idx {
4273 return None;
4274 }
4275
4276 let mut collected: Vec<(usize, BlockquoteLineData)> = Vec::new();
4279 let mut idx = para_start;
4280 while idx < lines.len() {
4281 if !collected.is_empty() && has_hard_break(&collected[collected.len() - 1].1.content) {
4282 break;
4283 }
4284
4285 let line = lines[idx];
4286 if line.trim().is_empty() {
4287 break;
4288 }
4289
4290 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(line) {
4291 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
4292 break;
4293 }
4294 collected.push((
4295 idx,
4296 BlockquoteLineData::explicit(trim_preserving_hard_break(parsed.content), parsed.prefix.to_string()),
4297 ));
4298 idx += 1;
4299 continue;
4300 }
4301
4302 let lazy_content = line.trim_start();
4303 if is_blockquote_content_boundary(lazy_content) {
4304 break;
4305 }
4306
4307 collected.push((idx, BlockquoteLineData::lazy(trim_preserving_hard_break(lazy_content))));
4308 idx += 1;
4309 }
4310
4311 if collected.is_empty() {
4312 return None;
4313 }
4314
4315 let para_end = collected[collected.len() - 1].0;
4316 if target_idx < para_start || target_idx > para_end {
4317 return None;
4318 }
4319
4320 let line_data: Vec<BlockquoteLineData> = collected.iter().map(|(_, d)| d.clone()).collect();
4321
4322 let fallback_prefix = line_data
4323 .iter()
4324 .find_map(|d| d.prefix.clone())
4325 .unwrap_or_else(|| "> ".to_string());
4326 let explicit_prefix = dominant_blockquote_prefix(&line_data, &fallback_prefix);
4327 let continuation_style = blockquote_continuation_style(&line_data);
4328
4329 let adjusted_line_length = options
4330 .line_length
4331 .saturating_sub(display_len(&explicit_prefix, options.length_mode))
4332 .max(1);
4333
4334 let adjusted_options = ReflowOptions {
4335 line_length: adjusted_line_length,
4336 ..options.clone()
4337 };
4338
4339 let styled_lines = reflow_blockquote_content(&line_data, &explicit_prefix, continuation_style, &adjusted_options);
4340
4341 if styled_lines.is_empty() {
4342 return None;
4343 }
4344
4345 let mut start_byte = 0;
4347 for line in lines.iter().take(para_start) {
4348 start_byte += line.len() + 1;
4349 }
4350
4351 let mut end_byte = start_byte;
4352 for line in lines.iter().take(para_end + 1).skip(para_start) {
4353 end_byte += line.len() + 1;
4354 }
4355
4356 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
4357 if !includes_trailing_newline {
4358 end_byte -= 1;
4359 }
4360
4361 let reflowed_joined = styled_lines.join("\n");
4362 let reflowed_text = if includes_trailing_newline {
4363 if reflowed_joined.ends_with('\n') {
4364 reflowed_joined
4365 } else {
4366 format!("{reflowed_joined}\n")
4367 }
4368 } else if reflowed_joined.ends_with('\n') {
4369 reflowed_joined.trim_end_matches('\n').to_string()
4370 } else {
4371 reflowed_joined
4372 };
4373
4374 Some(ParagraphReflow {
4375 start_byte,
4376 end_byte,
4377 reflowed_text,
4378 })
4379}
4380
4381pub fn reflow_paragraph_at_line(content: &str, line_number: usize, line_length: usize) -> Option<ParagraphReflow> {
4399 reflow_paragraph_at_line_with_mode(content, line_number, line_length, ReflowLengthMode::default())
4400}
4401
4402pub fn reflow_paragraph_at_line_with_mode(
4404 content: &str,
4405 line_number: usize,
4406 line_length: usize,
4407 length_mode: ReflowLengthMode,
4408) -> Option<ParagraphReflow> {
4409 let options = ReflowOptions {
4410 line_length,
4411 length_mode,
4412 ..Default::default()
4413 };
4414 reflow_paragraph_at_line_with_options(content, line_number, &options)
4415}
4416
4417pub fn reflow_paragraph_at_line_with_options(
4428 content: &str,
4429 line_number: usize,
4430 options: &ReflowOptions,
4431) -> Option<ParagraphReflow> {
4432 if line_number == 0 {
4433 return None;
4434 }
4435
4436 let lines: Vec<&str> = content.lines().collect();
4437
4438 if line_number > lines.len() {
4440 return None;
4441 }
4442
4443 let target_idx = line_number - 1; let target_line = lines[target_idx];
4445 let trimmed = target_line.trim();
4446
4447 if let Some(blockquote_reflow) = reflow_blockquote_paragraph_at_line(content, &lines, target_idx, options) {
4450 return Some(blockquote_reflow);
4451 }
4452
4453 if is_paragraph_boundary(trimmed, target_line) {
4455 return None;
4456 }
4457
4458 let mut para_start = target_idx;
4460 while para_start > 0 {
4461 let prev_idx = para_start - 1;
4462 let prev_line = lines[prev_idx];
4463 let prev_trimmed = prev_line.trim();
4464
4465 if is_paragraph_boundary(prev_trimmed, prev_line) {
4467 break;
4468 }
4469
4470 para_start = prev_idx;
4471 }
4472
4473 let mut para_end = target_idx;
4475 while para_end + 1 < lines.len() {
4476 let next_idx = para_end + 1;
4477 let next_line = lines[next_idx];
4478 let next_trimmed = next_line.trim();
4479
4480 if is_paragraph_boundary(next_trimmed, next_line) {
4482 break;
4483 }
4484
4485 para_end = next_idx;
4486 }
4487
4488 let paragraph_lines = &lines[para_start..=para_end];
4490
4491 let mut start_byte = 0;
4493 for line in lines.iter().take(para_start) {
4494 start_byte += line.len() + 1; }
4496
4497 let mut end_byte = start_byte;
4498 for line in paragraph_lines {
4499 end_byte += line.len() + 1; }
4501
4502 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
4505
4506 if !includes_trailing_newline {
4508 end_byte -= 1;
4509 }
4510
4511 let paragraph_text = paragraph_lines.join("\n");
4513
4514 let reflowed = reflow_markdown(¶graph_text, options);
4516
4517 let reflowed_text = if includes_trailing_newline {
4521 if reflowed.ends_with('\n') {
4523 reflowed
4524 } else {
4525 format!("{reflowed}\n")
4526 }
4527 } else {
4528 if reflowed.ends_with('\n') {
4530 reflowed.trim_end_matches('\n').to_string()
4531 } else {
4532 reflowed
4533 }
4534 };
4535
4536 Some(ParagraphReflow {
4537 start_byte,
4538 end_byte,
4539 reflowed_text,
4540 })
4541}
4542fn decompose_code_span(raw: &str) -> Option<(&str, &str)> {
4548 let marker_len = raw.bytes().take_while(|&b| b == b'`').count();
4549 if marker_len == 0 {
4550 return None;
4551 }
4552 let marker = &raw[..marker_len];
4553 if raw.len() < marker_len * 2 {
4554 return None;
4555 }
4556 let content = &raw[marker_len..raw.len() - marker_len];
4557 Some((content, marker))
4558}
4559
4560#[cfg(test)]
4561mod tests {
4562 use super::*;
4563
4564 #[test]
4568 fn preserves_content_accepts_whitespace_changes_and_rejects_the_rest() {
4569 let accepted: &[(&str, &[&str])] = &[
4570 ("one two three", &["one two three"]),
4571 ("one two three", &["one two", "three"]),
4572 ("one two three", &["one", "two", "three"]),
4573 ("one two ", &["one two"]),
4575 ("日本語のテキスト", &["日本語の", "テキスト"]),
4577 ("_First. Second._", &["_First.", "Second._"]),
4579 ];
4580 for (original, reflowed) in accepted {
4581 let reflowed: Vec<String> = reflowed.iter().map(ToString::to_string).collect();
4582 assert!(
4583 preserves_content(original, &reflowed),
4584 "{original:?} -> {reflowed:?} only moves whitespace"
4585 );
4586 }
4587
4588 let rejected: &[(&str, &[&str])] = &[
4589 ("one two three", &["one two"]),
4591 ("one two", &["one two three"]),
4593 ("one two", &["two one"]),
4595 ("_First. Second._", &["_First._", "_Second._"]),
4597 ("alpha and beta", &["alpha", "andbeta"]),
4599 ("mot suivant : autre", &["mot suivant: autre"]),
4601 ];
4602 for (original, reflowed) in rejected {
4603 let reflowed: Vec<String> = reflowed.iter().map(ToString::to_string).collect();
4604 assert!(
4605 !preserves_content(original, &reflowed),
4606 "{original:?} -> {reflowed:?} changes the text, not just its line breaks"
4607 );
4608 }
4609 }
4610
4611 #[test]
4613 fn reflow_line_falls_back_to_the_input_when_content_would_change() {
4614 let options = ReflowOptions {
4615 line_length: 40,
4616 ..Default::default()
4617 };
4618 let line = "one two three four five six seven eight nine ten";
4619
4620 assert!(preserves_content(line, &reflow_line(line, &options)));
4621 assert_eq!(reflow_line(line, &options), reflow_line_unchecked(line, &options));
4622 }
4623
4624 #[test]
4625 fn cascade_split_line_handles_a_very_long_line_without_overflowing() {
4626 let words: Vec<String> = (0..4000).map(|i| format!("word{i}")).collect();
4632 let line = words.join(" ");
4633
4634 let options = ReflowOptions {
4635 line_length: 80,
4636 length_mode: ReflowLengthMode::Chars,
4637 ..Default::default()
4638 };
4639 let out = cascade_split_line(&line, &options);
4640
4641 assert!(out.len() > 1, "a very long line should split into many lines");
4642 for segment in &out {
4643 assert!(
4644 display_len(segment, ReflowLengthMode::Chars) <= 80 || !segment.contains(' '),
4645 "each wrapped line should fit the width (or be a single unbreakable token)"
4646 );
4647 }
4648 let rejoined = out.join(" ");
4650 let original_words: Vec<&str> = line.split(' ').collect();
4651 let result_words: Vec<&str> = rejoined.split_whitespace().collect();
4652 assert_eq!(original_words, result_words, "reflow must preserve all words in order");
4653 }
4654
4655 #[test]
4660 fn test_helper_function_text_ends_with_abbreviation() {
4661 let abbreviations = get_abbreviations(&None);
4663
4664 assert!(text_ends_with_abbreviation("Dr.", &abbreviations));
4666 assert!(text_ends_with_abbreviation("word Dr.", &abbreviations));
4667 assert!(text_ends_with_abbreviation("e.g.", &abbreviations));
4668 assert!(text_ends_with_abbreviation("i.e.", &abbreviations));
4669 assert!(text_ends_with_abbreviation("Mr.", &abbreviations));
4670 assert!(text_ends_with_abbreviation("Mrs.", &abbreviations));
4671 assert!(text_ends_with_abbreviation("Ms.", &abbreviations));
4672 assert!(text_ends_with_abbreviation("Prof.", &abbreviations));
4673
4674 assert!(!text_ends_with_abbreviation("etc.", &abbreviations));
4676 assert!(!text_ends_with_abbreviation("paradigms.", &abbreviations));
4677 assert!(!text_ends_with_abbreviation("programs.", &abbreviations));
4678 assert!(!text_ends_with_abbreviation("items.", &abbreviations));
4679 assert!(!text_ends_with_abbreviation("systems.", &abbreviations));
4680 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)); }
4686
4687 #[test]
4688 fn test_footnote_after_period_splits_sentence() {
4689 let text = "First sentence.[^1] Second sentence.";
4693 let sentences = split_into_sentences(text);
4694 assert_eq!(
4695 sentences,
4696 vec!["First sentence.[^1]".to_string(), "Second sentence.".to_string()],
4697 "footnote glued to the period should keep the boundary and stay attached to the first sentence"
4698 );
4699 }
4700
4701 #[test]
4702 fn test_multiple_consecutive_footnotes_after_period_splits_sentence() {
4703 let text = "Notes here.[^1][^2] Second sentence.";
4705 let sentences = split_into_sentences(text);
4706 assert_eq!(
4707 sentences,
4708 vec!["Notes here.[^1][^2]".to_string(), "Second sentence.".to_string()]
4709 );
4710 }
4711
4712 #[test]
4713 fn test_footnote_before_period_still_splits_sentence() {
4714 let text = "Annotation here[^1]. Second sentence.";
4718 let sentences = split_into_sentences(text);
4719 assert_eq!(
4720 sentences,
4721 vec!["Annotation here[^1].".to_string(), "Second sentence.".to_string()]
4722 );
4723 }
4724
4725 #[test]
4726 fn test_mid_sentence_footnote_does_not_split() {
4727 let text = "The system word[^1] more words. Next sentence.";
4730 let sentences = split_into_sentences(text);
4731 assert_eq!(
4732 sentences,
4733 vec![
4734 "The system word[^1] more words.".to_string(),
4735 "Next sentence.".to_string()
4736 ]
4737 );
4738 }
4739
4740 #[test]
4741 fn test_bare_numeric_bracket_after_period_does_not_split() {
4742 let text = "Citation here.[1] Second sentence.";
4745 let sentences = split_into_sentences(text);
4746 assert_eq!(
4747 sentences,
4748 vec![text.to_string()],
4749 "a bare numeric bracket must not be treated as a sentence boundary"
4750 );
4751 }
4752
4753 #[test]
4754 fn test_footnote_glued_to_following_word_does_not_split() {
4755 let text = "First sentence.[^1]Continued glued text.";
4758 let sentences = split_into_sentences(text);
4759 assert_eq!(sentences, vec![text.to_string()]);
4760 }
4761
4762 #[test]
4763 fn test_footnote_at_end_of_text_is_preserved() {
4764 let text = "Sentence.[^1]";
4767 let sentences = split_into_sentences(text);
4768 assert_eq!(sentences, vec![text.to_string()]);
4769 }
4770
4771 #[test]
4772 fn test_abbreviation_before_footnote_does_not_split() {
4773 let text = "See the notes, e.g.[^1] this one.";
4776 let sentences = split_into_sentences(text);
4777 assert_eq!(
4778 sentences,
4779 vec![text.to_string()],
4780 "e.g. is an abbreviation, not a sentence boundary"
4781 );
4782 }
4783
4784 #[test]
4785 fn test_is_unordered_list_marker() {
4786 assert!(is_unordered_list_marker("- item"));
4788 assert!(is_unordered_list_marker("* item"));
4789 assert!(is_unordered_list_marker("+ item"));
4790 assert!(is_unordered_list_marker("-")); assert!(is_unordered_list_marker("*"));
4792 assert!(is_unordered_list_marker("+"));
4793
4794 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")); }
4805
4806 #[test]
4807 fn test_is_block_boundary() {
4808 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"));
4830 assert!(!is_block_boundary("*emphasis*")); assert!(!is_block_boundary("[link](url)")); assert!(!is_block_boundary("some words here"));
4833 }
4834
4835 #[test]
4836 fn test_definition_list_boundary_in_single_line_paragraph() {
4837 let options = ReflowOptions {
4840 line_length: 80,
4841 ..Default::default()
4842 };
4843 let input = "Term\n: Definition of the term";
4844 let result = reflow_markdown(input, &options);
4845 assert!(
4847 result.contains(": Definition"),
4848 "Definition list item should not be merged into previous line. Got: {result:?}"
4849 );
4850 let lines: Vec<&str> = result.lines().collect();
4851 assert_eq!(lines.len(), 2, "Should remain two separate lines. Got: {lines:?}");
4852 assert_eq!(lines[0], "Term");
4853 assert_eq!(lines[1], ": Definition of the term");
4854 }
4855
4856 #[test]
4857 fn test_is_paragraph_boundary() {
4858 assert!(is_paragraph_boundary("# Heading", "# Heading"));
4860 assert!(is_paragraph_boundary("- item", "- item"));
4861 assert!(is_paragraph_boundary(":::", ":::"));
4862 assert!(is_paragraph_boundary(": definition", ": definition"));
4863
4864 assert!(is_paragraph_boundary("code", " code"));
4866 assert!(is_paragraph_boundary("code", "\tcode"));
4867
4868 assert!(is_paragraph_boundary("| a | b |", "| a | b |"));
4870 assert!(is_paragraph_boundary("a | b", "a | b")); assert!(!is_paragraph_boundary("regular text", "regular text"));
4874 assert!(!is_paragraph_boundary("text", " text")); }
4876
4877 #[test]
4878 fn test_div_marker_boundary_in_reflow_paragraph_at_line() {
4879 let content = "Some paragraph text here.\n\n::: {.callout-note}\nThis is a callout.\n:::\n";
4882 let result = reflow_paragraph_at_line(content, 3, 80);
4884 assert!(result.is_none(), "Div marker line should not be reflowed");
4885 }
4886
4887 #[test]
4888 fn starts_block_construct_detects_block_openers() {
4889 for case in ["- item", "-", "* item", "*", "+ item", "+", "-\titem"] {
4891 assert!(starts_block_construct(case), "bullet: {case:?}");
4892 }
4893 for case in ["1. item", "1) item", "01. x", "000000001. x", "1.\titem"] {
4896 assert!(starts_block_construct(case), "ordered: {case:?}");
4897 }
4898 for case in ["> quote", ">quote", ">"] {
4900 assert!(starts_block_construct(case), "blockquote: {case:?}");
4901 }
4902 for case in ["# heading", "###### h6", "#", "##"] {
4904 assert!(starts_block_construct(case), "heading: {case:?}");
4905 }
4906 for case in ["```", "```rust", "````", "~~~", "~~~text"] {
4908 assert!(starts_block_construct(case), "fence: {case:?}");
4909 }
4910 for case in ["---", "--", "===", "=", "***", "___", "_ _ _", "- - -"] {
4912 assert!(starts_block_construct(case), "setext/thematic: {case:?}");
4913 }
4914 for case in [
4917 "[^1]: text",
4918 "[^note]:",
4919 "[ref]: http://example.com",
4920 "[wat]: url follows",
4921 ] {
4922 assert!(starts_block_construct(case), "definition: {case:?}");
4923 }
4924 for case in ["<div>content", "</div>", "<p>text", "<table>", "<pre>code", "<h1>x"] {
4926 assert!(starts_block_construct(case), "html block: {case:?}");
4927 }
4928 }
4929
4930 #[test]
4931 fn starts_block_construct_allows_ordinary_prose() {
4932 for case in [
4933 "",
4934 "word",
4935 "-5 degrees",
4936 "--flag",
4937 "-item",
4938 "#hashtag",
4939 "####### seven hashes is not a heading",
4940 "1.5 million",
4941 "1234567890. ten digits is not a list marker",
4942 "0000000001. ten digits is not a list marker either",
4943 "2. item",
4946 "7. item",
4947 "0. item",
4948 "42) x",
4949 "123456. item",
4950 "1.",
4951 "1)",
4952 "123456.",
4953 "123456)",
4954 "1.item",
4955 "1:30 pm",
4956 "*emphasis*",
4957 "**bold** text",
4958 "__bold__ text",
4959 "_emphasis_ text",
4960 "`code` span",
4961 "`` double backtick span ``",
4962 "~~strikethrough~~",
4963 "=x",
4964 "== ==",
4965 "(parenthetical)",
4966 "[link](url)",
4967 "[text][ref] more",
4968 "[bracketed] aside",
4969 "[a](b) [ref]: first bracket is a link, not a label",
4970 "[esc\\]: not a close] text",
4971 "<span>inline</span>",
4972 "<b>bold</b>",
4973 "<https://example.com> autolink",
4974 "<mailto:a@b.com>",
4975 "<notarealtag>",
4976 ] {
4977 assert!(!starts_block_construct(case), "prose: {case:?}");
4978 }
4979 }
4980
4981 #[test]
4982 fn merge_block_construct_continuations_merges_marker_led_lines() {
4983 let lines = vec![
4984 "First sentence?".to_string(),
4985 "- looks like a list item".to_string(),
4986 "Second sentence.".to_string(),
4987 ];
4988 assert_eq!(
4989 merge_block_construct_continuations(lines),
4990 vec![
4991 "First sentence? - looks like a list item".to_string(),
4992 "Second sentence.".to_string(),
4993 ]
4994 );
4995
4996 let lines = vec!["- real list content".to_string(), "continuation".to_string()];
4999 assert_eq!(
5000 merge_block_construct_continuations(lines.clone()),
5001 lines,
5002 "first line must never be merged"
5003 );
5004
5005 let lines = vec!["prose".to_string(), "1.".to_string(), "[ref]:".to_string()];
5008 assert_eq!(
5009 merge_block_construct_continuations(lines),
5010 vec!["prose 1. [ref]:".to_string()],
5011 "a merge that creates an opener must fold again"
5012 );
5013 }
5014
5015 #[test]
5016 fn wrap_never_starts_a_line_with_a_block_marker() {
5017 let options = ReflowOptions {
5018 line_length: 25,
5019 ..Default::default()
5020 };
5021 let lines = reflow_line(
5024 "Some words here and then - a dash clause that wraps around the limit.",
5025 &options,
5026 );
5027 assert_eq!(
5028 lines,
5029 vec![
5030 "Some words here and",
5031 "then - a dash clause that",
5032 "wraps around the limit."
5033 ]
5034 );
5035
5036 for input in [
5038 "Alpha beta gamma delta epsilon - dash clause here to wrap",
5039 "Alpha beta gamma delta epsilon > quote lookalike here to wrap",
5040 "Alpha beta gamma delta epsilon # heading lookalike here to wrap",
5041 "Alpha beta gamma delta epsilon 1. ordered lookalike here to wrap",
5042 "Alpha beta gamma delta epsilon * star clause here to wrap",
5043 "Alpha beta gamma delta epsilon + plus clause here to wrap",
5044 ] {
5045 for width in 10..40 {
5046 let options = ReflowOptions {
5047 line_length: width,
5048 ..Default::default()
5049 };
5050 for line in reflow_line(input, &options) {
5051 assert!(
5052 !starts_block_construct(&line),
5053 "width {width}: wrapped line opens a block construct: {line:?} (input {input:?})"
5054 );
5055 }
5056 }
5057 }
5058 }
5059
5060 #[test]
5061 fn sentence_per_line_keeps_block_markers_mid_line() {
5062 let options = ReflowOptions {
5063 line_length: 80,
5064 sentence_per_line: true,
5065 ..Default::default()
5066 };
5067 let lines = reflow_line(
5070 "Google Calendar (Can't we get rid of this dependency? - I don't really see the need)",
5071 &options,
5072 );
5073 assert_eq!(
5074 lines,
5075 vec!["Google Calendar (Can't we get rid of this dependency? - I don't really see the need)".to_string()]
5076 );
5077
5078 let lines = reflow_line("See section 4? # is the marker we use. Fine.", &options);
5080 assert_eq!(lines, vec!["See section 4? # is the marker we use.", "Fine."]);
5081
5082 let lines = reflow_line("Is this a problem? > I quote someone here.", &options);
5083 assert_eq!(lines, vec!["Is this a problem? > I quote someone here."]);
5084
5085 let lines = reflow_line("Another case! 1. Not a list. More text follows here.", &options);
5086 for line in &lines {
5087 assert!(
5088 !starts_block_construct(line),
5089 "sentence-per-line output opens a block construct: {line:?}"
5090 );
5091 }
5092 }
5093
5094 #[test]
5095 fn inline_math_directly_after_display_math_stays_atomic() {
5096 let options = ReflowOptions {
5104 line_length: 8,
5105 ..Default::default()
5106 };
5107 let lines = reflow_line("$$a$$$bb cc dd$ x", &options);
5108 assert_eq!(lines, vec!["$$a$$$bb cc dd$".to_string(), "x".to_string()]);
5109 }
5110
5111 #[test]
5112 fn test_code_span_parsing() {
5113 let elements = parse_markdown_elements_inner("`code`", false, false, None);
5115 assert_eq!(elements.len(), 1);
5116 assert!(matches!(&elements[0], Element::Code { content, marker } if content == "code" && marker == "`"));
5117
5118 let elements = parse_markdown_elements_inner("``code``", false, false, None);
5120 assert_eq!(elements.len(), 1);
5121 assert!(matches!(&elements[0], Element::Code { content, marker } if content == "code" && marker == "``"));
5122
5123 let elements = parse_markdown_elements_inner("``code`inside``", false, false, None);
5125 assert_eq!(elements.len(), 1);
5126 assert!(
5127 matches!(&elements[0], Element::Code { content, marker } if content == "code`inside" && marker == "``")
5128 );
5129
5130 let elements = parse_markdown_elements_inner("`` code ``", false, false, None);
5132 assert_eq!(elements.len(), 1);
5133 assert!(matches!(&elements[0], Element::Code { content, marker } if content == " code " && marker == "``"));
5134
5135 let elements = parse_markdown_elements_inner("`unclosed", false, false, None);
5137 assert_eq!(elements.len(), 1);
5138 assert!(matches!(&elements[0], Element::Text(s) if s == "`unclosed"));
5139
5140 let elements = parse_markdown_elements_inner("`unclosed [link](url)", false, false, None);
5142 assert_eq!(elements.len(), 2);
5144 assert!(matches!(&elements[0], Element::Text(s) if s == "`unclosed "));
5145 assert!(matches!(&elements[1], Element::Link(s) if s == "[link](url)"));
5146 }
5147
5148 #[test]
5149 fn test_reflow_performance_long_input() {
5150 let mut text = String::new();
5153 for i in 1..400 {
5154 let backticks = "`".repeat(i);
5155 text.push_str(&backticks);
5156 text.push(' ');
5157 }
5158
5159 let start = std::time::Instant::now();
5160 let elements = parse_markdown_elements_inner(&text, false, false, None);
5161 let duration = start.elapsed();
5162
5163 assert!(duration.as_millis() < 100, "Parsing took too long: {duration:?}");
5165 assert!(!elements.is_empty());
5166 }
5167
5168 #[test]
5169 fn test_reflow_performance_display_math_heavy() {
5170 let text = "$$a$$".repeat(4000);
5175
5176 let start = std::time::Instant::now();
5177 let elements = parse_markdown_elements_inner(&text, false, false, None);
5178 let duration = start.elapsed();
5179
5180 assert!(duration.as_millis() < 100, "Parsing took too long: {duration:?}");
5181 assert_eq!(elements.len(), 4000);
5182 }
5183
5184 #[test]
5185 fn inline_math_len_at_start_matches_regex_at_slice_start() {
5186 let alphabet = ['$', 'a', ' '];
5191 let mut inputs: Vec<String> = vec![String::new()];
5192 let mut frontier: Vec<String> = vec![String::new()];
5193 for _ in 0..6 {
5194 let mut longer = Vec::new();
5195 for prefix in &frontier {
5196 for ch in alphabet {
5197 let mut s = prefix.clone();
5198 s.push(ch);
5199 longer.push(s);
5200 }
5201 }
5202 inputs.extend(longer.iter().cloned());
5203 frontier = longer;
5204 }
5205 inputs.push("$αβ$x".to_string());
5207 inputs.push("$α$$".to_string());
5208
5209 for s in &inputs {
5210 let expected = INLINE_MATH_REGEX
5211 .find(s)
5212 .ok()
5213 .flatten()
5214 .filter(|m| m.start() == 0)
5215 .map(|m| m.end());
5216 assert_eq!(inline_math_len_at_start(s), expected, "input: {s:?}");
5217 }
5218 }
5219
5220 #[test]
5221 fn inline_math_probe_after_dollar_matches_uncached_parse() {
5222 let cases = [
5228 ("$$a$$$b c$ x", r#"[DisplayMath("a"), InlineMath("b c"), Text(" x")]"#),
5229 (
5230 "$$a$$$b$ $$a$$$b$",
5231 r#"[DisplayMath("a"), InlineMath("b"), Text(" "), DisplayMath("a"), InlineMath("b")]"#,
5232 ),
5233 (
5235 "$$a$$$ x $y z$",
5236 r#"[DisplayMath("a"), InlineMath(" x "), Text("y z$")]"#,
5237 ),
5238 ("$$a$$$$ x", r#"[DisplayMath("a"), Text("$$ x")]"#),
5240 ("$$a$$$$b$$ x", r#"[DisplayMath("a"), DisplayMath("b"), Text(" x")]"#),
5241 (
5243 "$a$$b$$c$$d$ tail",
5244 r#"[Text("$a"), DisplayMath("b"), Text("c$$d$ tail")]"#,
5245 ),
5246 ];
5247 for (input, expected) in cases {
5248 let elements = parse_markdown_elements_inner(input, false, false, None);
5249 assert_eq!(format!("{elements:?}"), expected, "input: {input:?}");
5250 }
5251 }
5252
5253 #[test]
5254 fn test_atomic_spans() {
5255 let text_emphasis = "hello **word1 word2**";
5257
5258 let options_disabled = ReflowOptions {
5259 line_length: 18,
5260 atomic_spans: true,
5261 ..Default::default()
5262 };
5263 let lines_disabled = reflow_line(text_emphasis, &options_disabled);
5264 assert_eq!(lines_disabled, vec!["hello", "**word1 word2**"]);
5265
5266 let options_enabled = ReflowOptions {
5267 line_length: 18,
5268 atomic_spans: false,
5269 ..Default::default()
5270 };
5271 let lines_enabled = reflow_line(text_emphasis, &options_enabled);
5272 assert_eq!(lines_enabled, vec!["hello **word1", "word2**"]);
5273
5274 let text_code = "hello `word1 word2`";
5276
5277 let lines_code_disabled = reflow_line(text_code, &options_disabled);
5278 assert_eq!(lines_code_disabled, vec!["hello", "`word1 word2`"]);
5279
5280 let lines_code_enabled = reflow_line(text_code, &options_enabled);
5281 assert_eq!(lines_code_enabled, vec!["hello `word1", "word2`"]);
5282
5283 let text_code_padding = "hello `` `word1` `word2` ``";
5285 let lines_padding_enabled = reflow_line(text_code_padding, &options_enabled);
5286 assert_eq!(lines_padding_enabled, vec![r#"hello `` `word1`"#, r#"`word2` ``"#]);
5287 }
5288
5289 #[test]
5290 fn test_emphasis_containing_markers_is_not_split() {
5291 let options = ReflowOptions {
5292 line_length: 5,
5293 atomic_spans: false,
5294 ..Default::default()
5295 };
5296 let lines = reflow_line(r#"*foo \*bar*"#, &options);
5298 assert_eq!(lines, vec![r#"*foo \*bar*"#.to_string()]);
5299 }
5300
5301 fn semantic_shape(markdown: &str) -> String {
5306 let mut options = Options::empty();
5307 options.insert(Options::ENABLE_STRIKETHROUGH);
5308 let mut out = String::new();
5309 let push_prose = |out: &mut String, text: &str| {
5310 for c in text.chars() {
5311 if c.is_whitespace() {
5312 if !out.ends_with(char::is_whitespace) {
5313 out.push(' ');
5314 }
5315 } else {
5316 out.push(c);
5317 }
5318 }
5319 };
5320 for event in Parser::new_ext(markdown, options) {
5321 match event {
5322 Event::Text(text) => push_prose(&mut out, &text),
5323 Event::SoftBreak | Event::HardBreak => push_prose(&mut out, " "),
5324 Event::Code(code) => out.push_str(&format!("<code>{code}</code>")),
5326 Event::Start(tag) => out.push_str(&format!("<{tag:?}>")),
5327 Event::End(tag) => out.push_str(&format!("</{tag:?}>")),
5328 other => out.push_str(&format!("{other:?}")),
5329 }
5330 }
5331 out.trim().to_string()
5332 }
5333
5334 #[test]
5335 fn test_wrapping_a_span_never_changes_what_it_parses_to() {
5336 let corpus = [
5340 "_This is a very, very, very, very, very long line with some `code` inside._",
5341 "_alpha beta gamma delta epsilon `a b` zeta eta theta iota kappa lambda_",
5342 "**strong text with `code` and more words than fit on one single line**",
5343 "~~struck text with `code` and more words than fit on one single line~~",
5344 "_emphasis with **nested strong that is quite long** and trailing words_",
5345 "***A doubly nested bold italic span with more words than fit on a line***",
5348 "___Another doubly nested span with more words than fit on a single line___",
5349 "**_mixed strong then emphasis with more words than fit on a single line_**",
5350 "*__mixed emphasis then strong with more words than fit on a single line__*",
5351 "**~~strong strikethrough with more words than fit on a single line here~~**",
5352 "**a * b with a stray marker and plenty more words to pass the budget**",
5355 "_foo `a` bar `b` baz qux quux corge grault garply waldo fred plugh xyzzy_",
5356 "text before _a long emphasis with `code` inside of it here_ and after",
5357 "(_a parenthesized long emphasis with `code` inside of it right here_)",
5358 r#"*foo \*bar baz qux quux corge grault garply waldo fred plugh xyzzy*"#,
5359 "_tab\tseparated `a\tb` words spread out over quite a long emphasis span_",
5360 "_This has [text](<a b c d e f g h i j k>) and trailing words to wrap._",
5363 "_This has [`code`](<a b c d e f g h i j k>) and trailing words to wrap._",
5364 r#"_See [x](https://example.com "a long link title here") and `code` too._"#,
5365 "_A [link with a long label](https://example.com/path) and `code` here._",
5366 "_An image  plus `code` and more text_",
5367 ];
5368 for text in corpus {
5369 let expected = semantic_shape(text);
5370 for line_length in [20, 30, 40, 80] {
5371 for atomic_spans in [true, false] {
5372 let options = ReflowOptions {
5373 line_length,
5374 atomic_spans,
5375 ..Default::default()
5376 };
5377 let wrapped = reflow_line(text, &options).join("\n");
5378 assert_eq!(
5379 semantic_shape(&wrapped),
5380 expected,
5381 "reflow changed the parse of {text:?} at line_length={line_length} \
5382 atomic_spans={atomic_spans}\n wrapped: {wrapped:?}"
5383 );
5384 }
5385 }
5386 }
5387 }
5388
5389 #[test]
5390 fn test_wrapping_a_span_keeps_whole_the_constructs_pulldown_cannot_see() {
5391 let cases = [
5395 (
5396 "_alpha beta gamma [[a wiki link]] delta epsilon zeta eta_",
5397 "[[a wiki link]]",
5398 ),
5399 (
5400 "_alpha beta gamma {{< foo bar >}} delta epsilon zeta eta_",
5401 "{{< foo bar >}}",
5402 ),
5403 ("_alpha beta gamma $a + b$ delta epsilon zeta eta theta_", "$a + b$"),
5404 ("_alpha beta gamma $$a + b$$ delta epsilon zeta eta theta_", "$$a + b$$"),
5405 ];
5406 for (text, construct) in cases {
5407 for line_length in [12, 20, 30] {
5408 for atomic_spans in [true, false] {
5409 let options = ReflowOptions {
5410 line_length,
5411 atomic_spans,
5412 ..Default::default()
5413 };
5414 let wrapped = reflow_line(text, &options).join("\n");
5415 assert!(
5416 wrapped.contains(construct),
5417 "{construct} was broken at line_length={line_length} \
5418 atomic_spans={atomic_spans}: {wrapped:?}"
5419 );
5420 }
5421 }
5422 }
5423 }
5424
5425 #[test]
5426 fn test_overlong_emphasis_with_nested_code_span_wraps() {
5427 let options = ReflowOptions {
5431 line_length: 80,
5432 atomic_spans: true,
5433 ..Default::default()
5434 };
5435 let text = "_This is a very, very, very, very, very, very, very long line that exceeds 80 characters with some `code` inside._";
5436 let lines = reflow_line(text, &options);
5437 assert_eq!(
5438 lines,
5439 vec![
5440 "_This is a very, very, very, very, very, very, very long line that exceeds 80",
5441 "characters with some `code` inside._",
5442 ]
5443 );
5444 }
5445
5446 #[test]
5447 fn test_overlong_emphasis_with_nested_strong_wraps() {
5448 let options = ReflowOptions {
5450 line_length: 80,
5451 atomic_spans: true,
5452 ..Default::default()
5453 };
5454 let text = "_This is a very, very, very, very, very, very, very long line that exceeds 80 characters with some **bold** inside._";
5455 let lines = reflow_line(text, &options);
5456 assert_eq!(
5457 lines,
5458 vec![
5459 "_This is a very, very, very, very, very, very, very long line that exceeds 80",
5460 "characters with some **bold** inside._",
5461 ]
5462 );
5463 }
5464
5465 #[test]
5466 fn test_overlong_doubly_nested_span_wraps() {
5467 let options = ReflowOptions {
5472 line_length: 80,
5473 atomic_spans: true,
5474 ..Default::default()
5475 };
5476 let body = "This is a very, very, very, very, very, very, very, very, very, very long line that is emphasised.";
5477 for (open, close) in [
5478 ("***", "***"),
5479 ("___", "___"),
5480 ("**_", "_**"),
5481 ("*__", "__*"),
5482 ("**~~", "~~**"),
5483 ] {
5484 let text = format!("{open}{body}{close}");
5485 assert!(text.len() > options.line_length, "case must start over budget");
5486 let lines = reflow_line(&text, &options);
5487 assert!(
5488 lines.len() > 1,
5489 "{open}...{close} should wrap but stayed on one line: {lines:?}"
5490 );
5491 assert!(
5492 lines.iter().all(|line| line.len() <= options.line_length),
5493 "{open}...{close} left a line over the budget: {lines:?}"
5494 );
5495 assert_eq!(
5496 lines.join(" "),
5497 text,
5498 "{open}...{close} wrapping must only replace a space with a newline"
5499 );
5500 }
5501 }
5502
5503 #[test]
5504 fn test_overlong_span_with_stray_marker_stays_whole() {
5505 let options = ReflowOptions {
5509 line_length: 40,
5510 atomic_spans: true,
5511 ..Default::default()
5512 };
5513 let text = "**alpha * beta gamma delta epsilon zeta eta theta iota kappa**";
5514 let lines = reflow_line(text, &options);
5515 assert_eq!(lines, vec![text], "stray marker must keep the span whole");
5516 }
5517
5518 #[test]
5519 fn test_overlong_span_never_breaks_inside_a_nested_reference_link() {
5520 let options = ReflowOptions {
5526 line_length: 30,
5527 atomic_spans: true,
5528 defined_references: Some(HashSet::from([
5529 "ref".to_string(),
5530 "one two three four five six seven".to_string(),
5532 ])),
5533 ..Default::default()
5534 };
5535 for (text, link) in [
5536 (
5537 "_**alpha [one two three four five six seven][ref] beta gamma delta**_",
5538 "[one two three four five six seven][ref]",
5539 ),
5540 (
5541 "**alpha [one two three four five six seven][ref] beta gamma delta**",
5542 "[one two three four five six seven][ref]",
5543 ),
5544 (
5545 "_**alpha ![one two three four five six seven][ref] beta gamma delta**_",
5546 "![one two three four five six seven][ref]",
5547 ),
5548 (
5549 "_**alpha [one two three four five six seven][] beta gamma delta**_",
5550 "[one two three four five six seven][]",
5551 ),
5552 (
5553 "_**alpha [one two three four five six seven] beta gamma delta**_",
5554 "[one two three four five six seven]",
5555 ),
5556 ] {
5557 let lines = reflow_line(text, &options);
5558 assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
5559 assert!(
5560 lines.iter().any(|line| line.contains(link)),
5561 "{link} must stay on one line: {lines:?}"
5562 );
5563 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
5564 }
5565 }
5566
5567 #[test]
5568 fn test_overlong_span_breaks_inside_an_undefined_shortcut_reference() {
5569 let options = ReflowOptions {
5573 line_length: 30,
5574 atomic_spans: true,
5575 defined_references: Some(HashSet::new()),
5576 ..Default::default()
5577 };
5578 let text = "_**alpha [one two three four five six seven] beta gamma delta**_";
5579 let lines = reflow_line(text, &options);
5580 assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
5581 assert!(
5582 !lines
5583 .iter()
5584 .any(|line| line.contains("[one two three four five six seven]")),
5585 "an undefined shortcut is prose and should break: {lines:?}"
5586 );
5587 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
5588 }
5589
5590 #[test]
5591 fn test_overlong_span_never_breaks_inside_a_nested_attr_list() {
5592 let attr = "{.highlight key=\"a b c\"}";
5596 let text = format!("_**alpha beta gamma delta epsilon zeta{attr} eta theta iota kappa**_");
5597 let options = ReflowOptions {
5598 line_length: 20,
5599 atomic_spans: true,
5600 attr_lists: true,
5601 ..Default::default()
5602 };
5603 let lines = reflow_line(&text, &options);
5604 assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
5605 assert!(
5606 lines.iter().any(|line| line.contains(attr)),
5607 "attr list must stay on one line: {lines:?}"
5608 );
5609 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
5610
5611 let plain = ReflowOptions {
5614 attr_lists: false,
5615 ..options
5616 };
5617 let lines = reflow_line(&text, &plain);
5618 assert!(
5619 !lines.iter().any(|line| line.contains(attr)),
5620 "without the flavor the braces are prose and should break: {lines:?}"
5621 );
5622 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
5623 }
5624
5625 #[test]
5626 fn test_overlong_emphasis_never_breaks_inside_nested_code_span() {
5627 let options = ReflowOptions {
5631 line_length: 30,
5632 atomic_spans: true,
5633 ..Default::default()
5634 };
5635 let text = "_alpha beta gamma delta epsilon `a b` zeta eta theta iota kappa_";
5636 let lines = reflow_line(text, &options);
5637 assert!(lines.len() > 1, "over-long emphasis should wrap: {lines:?}");
5638 assert!(
5639 lines.iter().any(|line| line.contains("`a b`")),
5640 "nested code span must stay whole with its interior spaces: {lines:?}"
5641 );
5642 for line in &lines {
5643 assert_eq!(
5644 line.matches('`').count() % 2,
5645 0,
5646 "no line may contain half a code span: {line:?}"
5647 );
5648 }
5649 }
5650
5651 #[test]
5652 fn test_definition_list_marker_does_not_start_line() {
5653 let options = ReflowOptions {
5654 line_length: 20,
5655 ..Default::default()
5656 };
5657 let lines = reflow_line("This is a term and : definition here.", &options);
5659 for line in &lines {
5660 assert!(
5661 !line.trim_start().starts_with(": "),
5662 "Wrapped line should not start with definition marker: {line}"
5663 );
5664 }
5665 }
5666
5667 #[test]
5668 fn test_div_marker_does_not_start_line() {
5669 let options = ReflowOptions {
5670 line_length: 20,
5671 ..Default::default()
5672 };
5673 let lines = reflow_line("This is some text with ::: class marker.", &options);
5675 for line in &lines {
5676 assert!(
5677 !line.trim_start().starts_with(":::"),
5678 "Wrapped line should not start with div marker: {line}"
5679 );
5680 }
5681 }
5682}