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 full: usize,
2586 link_saving: usize,
2589 code_saving: usize,
2591 is_hard: bool,
2593}
2594
2595impl ElementSpan {
2596 fn new(start: usize, len: usize, full: usize, width: LineWidth, is_hard: bool) -> Self {
2599 Self {
2600 start,
2601 end: start + len,
2602 full,
2603 link_saving: full - width.link_exempt,
2604 code_saving: full - width.code_exempt,
2605 is_hard,
2606 }
2607 }
2608
2609 fn contains(&self, pos: usize) -> bool {
2610 pos > self.start && pos < self.end
2611 }
2612
2613 fn within(&self, start: usize, end: usize) -> bool {
2614 self.start >= start && self.end <= end
2615 }
2616
2617 fn exempt_width(&self) -> LineWidth {
2618 LineWidth {
2619 link_exempt: self.full - self.link_saving,
2620 code_exempt: self.full - self.code_saving,
2621 }
2622 }
2623}
2624
2625fn compute_element_spans(
2631 elements: &[Element],
2632 mode: ReflowLengthMode,
2633 exemptions: LengthExemptions,
2634) -> Vec<ElementSpan> {
2635 let mut spans = Vec::new();
2636 let mut offset = 0;
2637 for element in elements {
2638 let len = element.display_len(ReflowLengthMode::Bytes);
2639 if !matches!(element, Element::Text(_)) {
2640 let full = element.display_len(mode);
2641 let width = element.exempt_width(mode, exemptions);
2642 let is_hard = match element {
2643 Element::Bold { content, .. }
2644 | Element::Italic { content, .. }
2645 | Element::Strikethrough { content, .. } => content.contains(['[', '`', '<', '$', '{']),
2646 _ => true,
2647 };
2648 spans.push(ElementSpan::new(offset, len, full, width, is_hard));
2649 }
2650 offset += len;
2651 }
2652 spans
2653}
2654
2655fn measure(text: &str, offset: usize, spans: &[ElementSpan], mode: ReflowLengthMode) -> LineWidth {
2663 let full = display_len(text, mode);
2664 let end = offset + text.len();
2665 let mut width = LineWidth::plain(full);
2666 for span in spans.iter().filter(|span| span.within(offset, end)) {
2667 width.link_exempt -= span.link_saving;
2668 width.code_exempt -= span.code_saving;
2669 }
2670 width
2671}
2672
2673fn line_width_components(line: &str, options: &ReflowOptions) -> LineWidth {
2678 let raw = display_len(line, options.length_mode);
2679 if !options.length_exemptions.any() {
2680 return LineWidth::plain(raw);
2681 }
2682 let elements = parse_markdown_elements_inner(
2683 line,
2684 options.attr_lists,
2685 options.myst_roles,
2686 options.defined_references.as_ref(),
2687 );
2688 let spans = compute_element_spans(&elements, options.length_mode, options.length_exemptions);
2689 measure(line, 0, &spans, options.length_mode)
2690}
2691
2692fn line_width(line: &str, options: &ReflowOptions) -> usize {
2694 line_width_components(line, options).effective()
2695}
2696
2697fn line_fits(line: &str, options: &ReflowOptions) -> bool {
2703 display_len(line, options.length_mode) <= options.line_length || line_width(line, options) <= options.line_length
2704}
2705
2706fn element_containing(pos: usize, spans: &[ElementSpan]) -> Option<ElementSpan> {
2708 spans.iter().copied().find(|span| span.contains(pos))
2709}
2710
2711fn is_inside_element(pos: usize, spans: &[ElementSpan]) -> bool {
2713 element_containing(pos, spans).is_some()
2714}
2715
2716const MIN_SPLIT_RATIO: f64 = 0.3;
2719
2720fn split_at_clause_punctuation(
2724 text: &str,
2725 line_length: usize,
2726 element_spans: &[ElementSpan],
2727 length_mode: ReflowLengthMode,
2728) -> Option<(String, String)> {
2729 let chars: Vec<char> = text.chars().collect();
2730 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
2731
2732 let mut width_acc = LineWidth::default();
2738 let mut search_end_char = 0;
2739 let mut byte = 0usize;
2740 let mut idx = 0usize;
2741 while idx < chars.len() {
2742 let (advance_chars, advance_bytes, width) = match element_spans.iter().find(|s| s.start == byte) {
2743 Some(span) => {
2744 let source = &text[span.start..span.end];
2745 (
2746 source.chars().count(),
2747 source.len(),
2748 measure(source, span.start, element_spans, length_mode),
2749 )
2750 }
2751 None => {
2752 let c = chars[idx];
2753 (
2754 1,
2755 c.len_utf8(),
2756 LineWidth::plain(display_len(&c.to_string(), length_mode)),
2757 )
2758 }
2759 };
2760 if !(width_acc + width).fits(line_length) {
2761 break;
2762 }
2763 width_acc += width;
2764 byte += advance_bytes;
2765 idx += advance_chars;
2766 search_end_char = idx;
2767 }
2768
2769 let mut paren_depth: i32 = 0;
2776 let mut best_pos = None;
2777 for i in (0..search_end_char).rev() {
2778 let byte_start: usize = chars[..i].iter().map(|c| c.len_utf8()).sum();
2780 let byte_after: usize = byte_start + chars[i].len_utf8();
2782
2783 if !is_inside_element(byte_start, element_spans) {
2784 match chars[i] {
2785 ')' => paren_depth += 1,
2786 '(' => paren_depth = paren_depth.saturating_sub(1),
2787 _ => {}
2788 }
2789 }
2790
2791 if paren_depth == 0
2792 && is_clause_punctuation(chars[i])
2793 && clause_break_allowed_after(&chars, i)
2794 && !is_inside_element(byte_after, element_spans)
2795 {
2796 best_pos = Some(i);
2797 break;
2798 }
2799 }
2800
2801 let pos = best_pos?;
2802
2803 let first: String = chars[..=pos].iter().collect();
2805 if measure(&first, 0, element_spans, length_mode).effective() < min_first_len {
2806 return None;
2807 }
2808
2809 let rest: String = chars[pos + 1..].iter().collect();
2811 let rest = rest.trim_start().to_string();
2812
2813 if rest.is_empty() {
2814 return None;
2815 }
2816
2817 Some((first, rest))
2818}
2819
2820fn paren_depth_map(text: &str, element_spans: &[ElementSpan]) -> Vec<i32> {
2827 let mut map = vec![0i32; text.len()];
2828 let mut depth = 0i32;
2829 for (byte, c) in text.char_indices() {
2830 if !is_inside_element(byte, element_spans) {
2831 match c {
2832 '(' => depth += 1,
2833 ')' => depth = depth.saturating_sub(1),
2834 _ => {}
2835 }
2836 }
2837 let end = (byte + c.len_utf8()).min(map.len());
2839 for slot in &mut map[byte..end] {
2840 *slot = depth;
2841 }
2842 }
2843 map
2844}
2845
2846fn is_standalone_parenthetical(line: &str) -> bool {
2855 let trimmed = line.trim();
2856 if !trimmed.starts_with('(') {
2857 return false;
2858 }
2859 let Some(close) = trimmed.rfind(')') else {
2862 return false;
2863 };
2864 if trimmed[close + 1..].contains(char::is_whitespace) {
2865 return false;
2866 }
2867 let core = &trimmed[..=close];
2868 let inner = &core[1..core.len() - 1];
2870 if !inner.contains(' ') {
2871 return false;
2872 }
2873 let mut depth = 0i32;
2875 for c in core.chars() {
2876 match c {
2877 '(' => depth += 1,
2878 ')' => depth -= 1,
2879 _ => {}
2880 }
2881 if depth < 0 {
2882 return false;
2883 }
2884 }
2885 depth == 0
2886}
2887
2888fn split_at_break_word(
2892 text: &str,
2893 line_length: usize,
2894 element_spans: &[ElementSpan],
2895 length_mode: ReflowLengthMode,
2896) -> Option<(String, String)> {
2897 let lower = text.to_lowercase();
2898 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
2899 let mut best_split: Option<(usize, usize)> = None; let depth_map = paren_depth_map(text, element_spans);
2904
2905 for &word in BREAK_WORDS {
2906 let mut search_start = 0;
2907 while let Some(pos) = lower[search_start..].find(word) {
2908 let abs_pos = search_start + pos;
2909
2910 let preceded_by_space = abs_pos == 0 || text.as_bytes().get(abs_pos - 1) == Some(&b' ');
2912 let followed_by_space = text.as_bytes().get(abs_pos + word.len()) == Some(&b' ');
2913
2914 if preceded_by_space && followed_by_space {
2915 let first_part = text[..abs_pos].trim_end();
2917 let first_part_len = measure(first_part, 0, element_spans, length_mode).effective();
2918
2919 let inside_paren = depth_map.get(abs_pos).is_some_and(|&d| d > 0);
2921
2922 if first_part_len >= min_first_len
2923 && first_part_len <= line_length
2924 && !is_inside_element(abs_pos, element_spans)
2925 && !inside_paren
2926 {
2927 if best_split.is_none_or(|(prev_pos, _)| abs_pos > prev_pos) {
2929 best_split = Some((abs_pos, word.len()));
2930 }
2931 }
2932 }
2933
2934 search_start = abs_pos + word.len();
2935 }
2936 }
2937
2938 let (byte_start, _word_len) = best_split?;
2939
2940 let first = text[..byte_start].trim_end().to_string();
2941 let rest = text[byte_start..].to_string();
2942
2943 if first.is_empty() || rest.trim().is_empty() {
2944 return None;
2945 }
2946
2947 Some((first, rest))
2948}
2949
2950fn replaces_whitespace(text: &str, first: &str, rest: &str, element_spans: &[ElementSpan]) -> bool {
2961 if !text.starts_with(first) || !text.ends_with(rest) {
2962 return false;
2963 }
2964 let gap_end = text.len() - rest.len();
2965 gap_end > first.len()
2966 && text[first.len()..gap_end].chars().all(is_breakable_whitespace)
2967 && !element_spans
2968 .iter()
2969 .any(|span| first.len() < span.end && span.start < gap_end)
2970}
2971
2972fn cascade_split_line(text: &str, options: &ReflowOptions) -> Vec<String> {
2983 let line_length = options.line_length;
2984 let length_mode = options.length_mode;
2985 let attr_lists = options.attr_lists;
2986 let myst_roles = options.myst_roles;
2987 let defined_references = options.defined_references.as_ref();
2988 if line_length == 0 || display_len(text, length_mode) <= line_length {
2989 return vec![text.to_string()];
2990 }
2991
2992 let elements = parse_markdown_elements_inner(text, attr_lists, myst_roles, defined_references);
2993 let element_spans = compute_element_spans(&elements, length_mode, options.length_exemptions);
2994
2995 if measure(text, 0, &element_spans, length_mode).fits(line_length) {
2998 return vec![text.to_string()];
2999 }
3000
3001 let rebased_spans = |start: usize| -> Vec<ElementSpan> {
3005 if start == 0 {
3006 return element_spans.clone();
3007 }
3008 element_spans
3009 .iter()
3010 .filter(|span| span.end > start)
3011 .map(|span| ElementSpan {
3012 start: span.start.saturating_sub(start),
3013 end: span.end.saturating_sub(start),
3014 ..*span
3015 })
3016 .collect()
3017 };
3018
3019 let mut result = Vec::new();
3020 let mut start = 0usize;
3021
3022 loop {
3023 let remaining = &text[start..];
3024 let spans = rebased_spans(start);
3025 if measure(remaining, 0, &spans, length_mode).fits(line_length) {
3026 result.push(remaining.to_string());
3027 return result;
3028 }
3029
3030 let at_whitespace = |candidate: Option<(String, String)>| {
3039 candidate.filter(|(first, rest)| replaces_whitespace(remaining, first, rest, &spans))
3040 };
3041 let split = at_whitespace(split_at_parenthetical(remaining, line_length, &spans, length_mode))
3042 .or_else(|| at_whitespace(split_at_clause_punctuation(remaining, line_length, &spans, length_mode)))
3043 .or_else(|| at_whitespace(split_at_break_word(remaining, line_length, &spans, length_mode)));
3044
3045 if let Some((first, rest)) = split {
3046 let consumed = remaining.len().saturating_sub(rest.len());
3047 if consumed == 0 {
3050 break;
3051 }
3052 result.push(first);
3053 start += consumed;
3054 continue;
3055 }
3056
3057 break;
3059 }
3060
3061 let mut fallback_options = options.clone();
3063 fallback_options.break_on_sentences = false;
3064 fallback_options.preserve_breaks = false;
3065 fallback_options.sentence_per_line = false;
3066 fallback_options.semantic_line_breaks = false;
3067 fallback_options.require_sentence_capital = true;
3068 fallback_options.max_list_continuation_indent = None;
3069 fallback_options.defined_references = None;
3070 let remaining = &text[start..];
3071 let tail_elements = if start == 0 {
3072 elements
3073 } else {
3074 parse_markdown_elements_inner(remaining, attr_lists, myst_roles, defined_references)
3075 };
3076 result.extend(reflow_elements(&tail_elements, &fallback_options));
3077 result
3078}
3079
3080fn reflow_elements_semantic(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
3084 let sentence_lines =
3086 reflow_elements_sentence_per_line(elements, &options.abbreviations, options.require_sentence_capital);
3087
3088 if options.line_length == 0 {
3091 return sentence_lines;
3092 }
3093
3094 let mut result = Vec::new();
3095 for line in sentence_lines {
3096 if line_fits(&line, options) {
3097 result.push(line);
3098 } else {
3099 result.extend(cascade_split_line(&line, options));
3100 }
3101 }
3102
3103 let min_line_len = ((options.line_length as f64) * MIN_SPLIT_RATIO) as usize;
3106 let mut merged: Vec<String> = Vec::with_capacity(result.len());
3107 for line in result {
3108 if !merged.is_empty() && line_width(&line, options) < min_line_len && !line.trim().is_empty() {
3109 if is_standalone_parenthetical(&line) {
3112 merged.push(line);
3113 continue;
3114 }
3115
3116 let prev_ends_at_sentence = {
3118 let trimmed = merged.last().unwrap().trim_end();
3119 trimmed
3120 .chars()
3121 .rev()
3122 .find(|c| !matches!(c, '"' | '\'' | '\u{201D}' | '\u{2019}' | ')' | ']'))
3123 .is_some_and(|c| matches!(c, '.' | '!' | '?'))
3124 };
3125
3126 if !prev_ends_at_sentence {
3127 let prev = merged.last_mut().unwrap();
3128 let combined = format!("{prev} {line}");
3129 if line_fits(&combined, options) {
3131 *prev = combined;
3132 continue;
3133 }
3134 }
3135 }
3136 merged.push(line);
3137 }
3138 merged
3139}
3140
3141fn rfind_safe_space(
3151 line: &str,
3152 element_spans: &[ElementSpan],
3153 options: &ReflowOptions,
3154 relax_soft_spans: bool,
3155) -> Option<usize> {
3156 line.char_indices().rev().map(|(pos, _)| pos).find(|&pos| {
3157 line.as_bytes()[pos] == b' '
3158 && !is_inside_element_filtered(pos, element_spans, options, relax_soft_spans)
3159 && !starts_block_construct(&line[pos + 1..])
3160 })
3161}
3162
3163fn is_inside_element_filtered(
3164 pos: usize,
3165 spans: &[ElementSpan],
3166 options: &ReflowOptions,
3167 relax_soft_spans: bool,
3168) -> bool {
3169 spans.iter().any(|span| {
3170 span.contains(pos)
3171 && (!relax_soft_spans
3172 || span.is_hard
3173 || (options.atomic_spans && span.exempt_width().fits(options.line_length)))
3174 })
3175}
3176
3177#[derive(Clone, Copy)]
3182struct Attached<'a> {
3183 text: &'a str,
3184 width: LineWidth,
3185 separator: &'a str,
3186}
3187
3188fn break_before_attached(
3205 lines: &mut Vec<String>,
3206 current_line: &mut String,
3207 current_width: &mut LineWidth,
3208 element_spans: &mut Vec<ElementSpan>,
3209 attach: Attached<'_>,
3210 options: &ReflowOptions,
3211) -> Option<usize> {
3212 let length_mode = options.length_mode;
3213 let last_space = rfind_safe_space(current_line, element_spans, options, false)
3214 .or_else(|| rfind_safe_space(current_line, element_spans, options, true))?;
3215 let before = current_line[..last_space]
3216 .trim_end_matches(is_breakable_whitespace)
3217 .to_string();
3218 let after = current_line[last_space + 1..].to_string();
3219 let after_width = measure(&after, last_space + 1, element_spans, length_mode);
3220 lines.push(before);
3221 let carried = after.len();
3222 let Attached { text, width, separator } = attach;
3223 *current_line = format!("{after}{separator}{text}");
3224 *current_width = after_width + LineWidth::plain(display_len(separator, length_mode)) + width;
3225 rebase_spans_after_break(element_spans, last_space + 1);
3226 Some(carried)
3227}
3228
3229fn rebase_spans_after_break(element_spans: &mut Vec<ElementSpan>, carried_start: usize) {
3238 element_spans.retain(|span| span.end > carried_start);
3239 for span in element_spans.iter_mut() {
3240 span.start = span.start.saturating_sub(carried_start);
3241 span.end -= carried_start;
3242 }
3243}
3244
3245fn reflow_elements(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
3247 let mut lines = Vec::new();
3248 let mut current_line = String::new();
3249 let mut current_width = LineWidth::default();
3252 let mut current_line_element_spans: Vec<ElementSpan> = Vec::new();
3254 let length_mode = options.length_mode;
3255 let exemptions = options.length_exemptions;
3256
3257 for (idx, element) in elements.iter().enumerate() {
3258 let element_len = element.display_len(length_mode);
3259 let element_width = element.exempt_width(length_mode, exemptions);
3260 let is_hard = match element {
3261 Element::Bold { content, .. }
3262 | Element::Italic { content, .. }
3263 | Element::Strikethrough { content, .. } => content.contains(['[', '`', '<', '$', '{']),
3264 _ => true,
3265 };
3266
3267 let is_adjacent_to_prev = if idx > 0 {
3276 match (&elements[idx - 1], element) {
3277 (Element::Text(t), _) => !t.is_empty() && !t.ends_with(is_breakable_whitespace),
3278 (_, Element::Text(t)) => !t.is_empty() && !t.starts_with(is_breakable_whitespace),
3279 _ => true,
3280 }
3281 } else {
3282 false
3283 };
3284
3285 if let Element::Text(text) = element {
3287 let has_leading_space = text.starts_with(is_breakable_whitespace);
3289 let words: Vec<&str> = split_breakable_words(text).collect();
3291
3292 for (i, word) in words.iter().enumerate() {
3293 let word_width = LineWidth::plain(display_len(word, length_mode));
3295 let is_trailing_punct = word.chars().all(|c| {
3301 matches!(c, ',' | '.' | ':' | ';' | '!' | '?' | ')' | ']' | '}') || is_non_breaking_space(c)
3302 });
3303
3304 let is_first_adjacent = i == 0 && is_adjacent_to_prev;
3307
3308 if is_first_adjacent {
3309 if !(current_width + word_width).fits(options.line_length)
3311 && !current_width.is_empty()
3312 && break_before_attached(
3313 &mut lines,
3314 &mut current_line,
3315 &mut current_width,
3316 &mut current_line_element_spans,
3317 Attached {
3318 text: word,
3319 width: word_width,
3320 separator: "",
3321 },
3322 options,
3323 )
3324 .is_some()
3325 {
3326 } else {
3331 current_line.push_str(word);
3332 current_width += word_width;
3333 }
3334 } else if !current_width.is_empty()
3335 && !(current_width + LineWidth::plain(1) + word_width).fits(options.line_length)
3336 {
3337 if is_trailing_punct {
3338 if break_before_attached(
3345 &mut lines,
3346 &mut current_line,
3347 &mut current_width,
3348 &mut current_line_element_spans,
3349 Attached {
3350 text: word,
3351 width: word_width,
3352 separator: " ",
3353 },
3354 options,
3355 )
3356 .is_none()
3357 {
3358 current_line.push(' ');
3359 current_line.push_str(word);
3360 current_width += LineWidth::plain(1) + word_width;
3361 }
3362 } else if !starts_block_construct(word) {
3363 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
3365 current_line = word.to_string();
3366 current_width = word_width;
3367 current_line_element_spans.clear();
3368 } else if break_before_attached(
3369 &mut lines,
3370 &mut current_line,
3371 &mut current_width,
3372 &mut current_line_element_spans,
3373 Attached {
3374 text: word,
3375 width: word_width,
3376 separator: " ",
3377 },
3378 options,
3379 )
3380 .is_some()
3381 {
3382 } else {
3387 if i > 0 || has_leading_space {
3390 current_line.push(' ');
3391 current_width += LineWidth::plain(1);
3392 }
3393 current_line.push_str(word);
3394 current_width += word_width;
3395 }
3396 } else {
3397 let add_space = !current_width.is_empty() && (i > 0 || has_leading_space);
3409 if add_space {
3410 current_line.push(' ');
3411 current_width += LineWidth::plain(1);
3412 }
3413 current_line.push_str(word);
3414 current_width += word_width;
3415 }
3416 }
3417 } else {
3418 let span_info = match element {
3419 Element::Italic { content, underscore } => {
3420 Some((content.as_str(), if *underscore { "_" } else { "*" }, false))
3421 }
3422 Element::Bold { content, underscore } => {
3423 Some((content.as_str(), if *underscore { "__" } else { "**" }, false))
3424 }
3425 Element::Strikethrough { content, double } => {
3426 Some((content.as_str(), if *double { "~~" } else { "~" }, false))
3427 }
3428 Element::Code { content, marker } => Some((content.as_str(), marker.as_str(), true)),
3429 _ => None,
3430 };
3431
3432 let breakable: Option<Vec<&str>> = match span_info {
3436 Some((content, _, is_code)) => {
3437 if is_code {
3438 (!options.atomic_spans && code_span_wraps_losslessly(content))
3439 .then(|| split_breakable_words(content).collect())
3440 } else {
3441 (!options.atomic_spans || element_len > options.line_length)
3442 .then(|| breakable_units(content, options.defined_references.as_ref(), options.attr_lists))
3443 .flatten()
3444 }
3445 }
3446 None => None,
3447 };
3448
3449 if let Some(words) = breakable {
3450 let (_, marker, is_code) = span_info.expect("breakable implies a span");
3451 let n = words.len();
3452 if n == 0 {
3453 let full = format!("{marker}{marker}");
3455 let full_width = LineWidth::plain(display_len(&full, length_mode));
3456 if !is_adjacent_to_prev && !current_width.is_empty() {
3457 current_line.push(' ');
3458 current_width += LineWidth::plain(1);
3459 }
3460 current_line.push_str(&full);
3461 current_width += full_width;
3462 } else {
3463 for (i, word) in words.iter().enumerate() {
3464 let is_first = i == 0;
3465 let is_last = i == n - 1;
3466
3467 let space_start = if is_first && is_code && word.starts_with('`') {
3468 " "
3469 } else {
3470 ""
3471 };
3472 let space_end = if is_last && is_code && word.ends_with('`') {
3473 " "
3474 } else {
3475 ""
3476 };
3477
3478 let word_str: String = match (is_first, is_last) {
3479 (true, true) => format!("{marker}{space_start}{word}{space_end}{marker}"),
3480 (true, false) => format!("{marker}{space_start}{word}"),
3481 (false, true) => format!("{word}{space_end}{marker}"),
3482 (false, false) => word.to_string(),
3483 };
3484 let word_elements = parse_elements(&word_str, options);
3485 let word_spans = compute_element_spans(&word_elements, length_mode, exemptions);
3486 let word_width = measure(&word_str, 0, &word_spans, length_mode);
3487
3488 let needs_space = if is_first {
3489 !is_adjacent_to_prev && !current_width.is_empty()
3490 } else {
3491 !current_width.is_empty()
3492 };
3493
3494 if needs_space
3495 && !(current_width + LineWidth::plain(1) + word_width).fits(options.line_length)
3496 && !starts_block_construct(&word_str)
3497 {
3498 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
3499 current_line = word_str;
3500 current_width = word_width;
3501 current_line_element_spans.clear();
3502 for span in word_spans {
3503 current_line_element_spans.push(span);
3504 }
3505 } else {
3506 let mut start_pos = current_line.len();
3507 if needs_space {
3508 current_line.push(' ');
3509 current_width += LineWidth::plain(1);
3510 start_pos += 1;
3511 }
3512 current_line.push_str(&word_str);
3513 current_width += word_width;
3514 for mut span in word_spans {
3515 span.start += start_pos;
3516 span.end += start_pos;
3517 current_line_element_spans.push(span);
3518 }
3519 }
3520 }
3521 }
3522 } else {
3523 let element_str = format!("{element}");
3526
3527 if is_adjacent_to_prev {
3528 if !(current_width + element_width).fits(options.line_length)
3530 && let Some(carried) = break_before_attached(
3531 &mut lines,
3532 &mut current_line,
3533 &mut current_width,
3534 &mut current_line_element_spans,
3535 Attached {
3536 text: &element_str,
3537 width: element_width,
3538 separator: "",
3539 },
3540 options,
3541 )
3542 {
3543 current_line_element_spans.push(ElementSpan::new(
3547 carried,
3548 element_str.len(),
3549 element_len,
3550 element_width,
3551 is_hard,
3552 ));
3553 } else {
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 is_hard,
3563 ));
3564 }
3565 } else if !current_width.is_empty()
3566 && !(current_width + LineWidth::plain(1) + element_width).fits(options.line_length)
3567 {
3568 if !starts_block_construct(&element_str) {
3569 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
3571 current_line.clone_from(&element_str);
3572 current_width = element_width;
3573 current_line_element_spans.clear();
3574 current_line_element_spans.push(ElementSpan::new(
3575 0,
3576 element_str.len(),
3577 element_len,
3578 element_width,
3579 is_hard,
3580 ));
3581 } else if let Some(carried) = break_before_attached(
3582 &mut lines,
3583 &mut current_line,
3584 &mut current_width,
3585 &mut current_line_element_spans,
3586 Attached {
3587 text: &element_str,
3588 width: element_width,
3589 separator: " ",
3590 },
3591 options,
3592 ) {
3593 let start = carried + 1;
3597 current_line_element_spans.push(ElementSpan::new(
3598 start,
3599 element_str.len(),
3600 element_len,
3601 element_width,
3602 is_hard,
3603 ));
3604 } else {
3605 let ends_with_opener =
3608 current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
3609 if !ends_with_opener {
3610 current_line.push(' ');
3611 current_width += LineWidth::plain(1);
3612 }
3613 let start = current_line.len();
3614 current_line.push_str(&element_str);
3615 current_width += element_width;
3616 current_line_element_spans.push(ElementSpan::new(
3617 start,
3618 element_str.len(),
3619 element_len,
3620 element_width,
3621 is_hard,
3622 ));
3623 }
3624 } else {
3625 let ends_with_opener =
3627 current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
3628 if !current_width.is_empty() && !ends_with_opener {
3629 current_line.push(' ');
3630 current_width += LineWidth::plain(1);
3631 }
3632 let start = current_line.len();
3633 current_line.push_str(&element_str);
3634 current_width += element_width;
3635 current_line_element_spans.push(ElementSpan::new(
3636 start,
3637 element_str.len(),
3638 element_len,
3639 element_width,
3640 is_hard,
3641 ));
3642 }
3643 }
3644 }
3645 }
3646
3647 if !current_line.is_empty() {
3649 lines.push(current_line.trim_end_matches(is_breakable_whitespace).to_string());
3650 }
3651
3652 lines
3653}
3654
3655pub fn reflow_markdown(content: &str, options: &ReflowOptions) -> String {
3657 let lines: Vec<&str> = content.lines().collect();
3658 let mut result = Vec::new();
3659 let mut i = 0;
3660
3661 while i < lines.len() {
3662 let line = lines[i];
3663 let trimmed = line.trim();
3664
3665 if trimmed.is_empty() {
3667 result.push(String::new());
3668 i += 1;
3669 continue;
3670 }
3671
3672 if trimmed.starts_with('#') {
3674 result.push(line.to_string());
3675 i += 1;
3676 continue;
3677 }
3678
3679 if trimmed.starts_with(":::") {
3681 result.push(line.to_string());
3682 i += 1;
3683 continue;
3684 }
3685
3686 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
3688 result.push(line.to_string());
3689 i += 1;
3690 while i < lines.len() {
3692 result.push(lines[i].to_string());
3693 if lines[i].trim().starts_with("```") || lines[i].trim().starts_with("~~~") {
3694 i += 1;
3695 break;
3696 }
3697 i += 1;
3698 }
3699 continue;
3700 }
3701
3702 if calculate_indentation_width_default(line) >= 4 {
3704 result.push(line.to_string());
3706 i += 1;
3707 while i < lines.len() {
3708 let next_line = lines[i];
3709 if calculate_indentation_width_default(next_line) >= 4 || next_line.trim().is_empty() {
3711 result.push(next_line.to_string());
3712 i += 1;
3713 } else {
3714 break;
3715 }
3716 }
3717 continue;
3718 }
3719
3720 if trimmed.starts_with('>') {
3722 let gt_pos = line.find('>').expect("'>' must exist since trimmed.starts_with('>')");
3725 let quote_prefix = line[0..=gt_pos].to_string();
3726 let quote_content = &line[quote_prefix.len()..].trim_start();
3727
3728 let reflowed = reflow_line(quote_content, options);
3729 for reflowed_line in &reflowed {
3730 result.push(format!("{quote_prefix} {reflowed_line}"));
3731 }
3732 i += 1;
3733 continue;
3734 }
3735
3736 if is_horizontal_rule(trimmed) {
3738 result.push(line.to_string());
3739 i += 1;
3740 continue;
3741 }
3742
3743 if is_unordered_list_marker(trimmed) || is_numbered_list_item(trimmed) {
3745 let indent = line.len() - line.trim_start().len();
3747 let indent_str = " ".repeat(indent);
3748
3749 let mut marker_end = indent;
3752 let mut content_start = indent;
3753
3754 if trimmed.chars().next().is_some_and(char::is_numeric) {
3755 if let Some(period_pos) = line[indent..].find('.') {
3757 marker_end = indent + period_pos + 1; content_start = marker_end;
3759 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
3763 content_start += 1;
3764 }
3765 }
3766 } else {
3767 marker_end = indent + 1; content_start = marker_end;
3770 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
3774 content_start += 1;
3775 }
3776 }
3777
3778 let min_continuation_indent = content_start;
3780
3781 let rest = &line[content_start..];
3784 if rest.starts_with("[ ] ") || rest.starts_with("[x] ") || rest.starts_with("[X] ") {
3785 marker_end = content_start + 3; content_start += 4; }
3788
3789 let marker = &line[indent..marker_end];
3790
3791 let mut list_content = vec![trim_preserving_hard_break(&line[content_start..])];
3794 i += 1;
3795
3796 while i < lines.len() {
3800 let next_line = lines[i];
3801 let next_trimmed = next_line.trim();
3802
3803 if is_block_boundary(next_trimmed) {
3805 break;
3806 }
3807
3808 let next_indent = next_line.len() - next_line.trim_start().len();
3810 if next_indent >= min_continuation_indent {
3811 let trimmed_start = next_line.trim_start();
3814 list_content.push(trim_preserving_hard_break(trimmed_start));
3815 i += 1;
3816 } else {
3817 break;
3819 }
3820 }
3821
3822 let combined_content = if options.preserve_breaks {
3825 list_content[0].clone()
3826 } else {
3827 let has_hard_breaks = list_content.iter().any(|line| has_hard_break(line));
3829 if has_hard_breaks {
3830 list_content.join("\n")
3832 } else {
3833 list_content.join(" ")
3835 }
3836 };
3837
3838 let trimmed_marker = marker;
3840 let continuation_spaces = if let Some(max_indent) = options.max_list_continuation_indent {
3841 indent + (content_start - indent).min(max_indent)
3844 } else {
3845 content_start
3846 };
3847
3848 let prefix_length = indent + trimmed_marker.len() + 1;
3850
3851 let adjusted_options = ReflowOptions {
3853 line_length: options.line_length.saturating_sub(prefix_length),
3854 ..options.clone()
3855 };
3856
3857 let reflowed = reflow_line(&combined_content, &adjusted_options);
3858 for (j, reflowed_line) in reflowed.iter().enumerate() {
3859 if j == 0 {
3860 result.push(format!("{indent_str}{trimmed_marker} {reflowed_line}"));
3861 } else {
3862 let continuation_indent = " ".repeat(continuation_spaces);
3864 result.push(format!("{continuation_indent}{reflowed_line}"));
3865 }
3866 }
3867 continue;
3868 }
3869
3870 if crate::utils::table_utils::TableUtils::is_potential_table_row(line) {
3872 result.push(line.to_string());
3873 i += 1;
3874 continue;
3875 }
3876
3877 if trimmed.starts_with('[') && line.contains("]:") {
3879 result.push(line.to_string());
3880 i += 1;
3881 continue;
3882 }
3883
3884 if is_definition_list_item(trimmed) {
3886 result.push(line.to_string());
3887 i += 1;
3888 continue;
3889 }
3890
3891 let mut is_single_line_paragraph = true;
3893 if i + 1 < lines.len() {
3894 let next_trimmed = lines[i + 1].trim();
3895 if !is_block_boundary(next_trimmed) {
3897 is_single_line_paragraph = false;
3898 }
3899 }
3900
3901 if is_single_line_paragraph && line_fits(line, options) {
3903 result.push(line.to_string());
3904 i += 1;
3905 continue;
3906 }
3907
3908 let mut paragraph_parts = Vec::new();
3910 let mut current_part = vec![line];
3911 i += 1;
3912
3913 if options.preserve_breaks {
3915 let hard_break_type = if line.strip_suffix('\r').unwrap_or(line).ends_with('\\') {
3917 Some("\\")
3918 } else if line.ends_with(" ") {
3919 Some(" ")
3920 } else {
3921 None
3922 };
3923 let reflowed = reflow_line(line, options);
3924
3925 if let Some(break_marker) = hard_break_type {
3927 if !reflowed.is_empty() {
3928 let mut reflowed_with_break = reflowed;
3929 let last_idx = reflowed_with_break.len() - 1;
3930 if !has_hard_break(&reflowed_with_break[last_idx]) {
3931 reflowed_with_break[last_idx].push_str(break_marker);
3932 }
3933 result.extend(reflowed_with_break);
3934 }
3935 } else {
3936 result.extend(reflowed);
3937 }
3938 } else {
3939 while i < lines.len() {
3941 let prev_line = if !current_part.is_empty() {
3942 current_part.last().unwrap()
3943 } else {
3944 ""
3945 };
3946 let next_line = lines[i];
3947 let next_trimmed = next_line.trim();
3948
3949 if is_block_boundary(next_trimmed) {
3951 break;
3952 }
3953
3954 let prev_trimmed = prev_line.trim();
3957 let abbreviations = get_abbreviations(&options.abbreviations);
3958 let ends_with_sentence = (prev_trimmed.ends_with('.')
3959 || prev_trimmed.ends_with('!')
3960 || prev_trimmed.ends_with('?')
3961 || prev_trimmed.ends_with(".*")
3962 || prev_trimmed.ends_with("!*")
3963 || prev_trimmed.ends_with("?*")
3964 || prev_trimmed.ends_with("._")
3965 || prev_trimmed.ends_with("!_")
3966 || prev_trimmed.ends_with("?_")
3967 || prev_trimmed.ends_with(".\"")
3969 || prev_trimmed.ends_with("!\"")
3970 || prev_trimmed.ends_with("?\"")
3971 || prev_trimmed.ends_with(".'")
3972 || prev_trimmed.ends_with("!'")
3973 || prev_trimmed.ends_with("?'")
3974 || prev_trimmed.ends_with(".\u{201D}")
3975 || prev_trimmed.ends_with("!\u{201D}")
3976 || prev_trimmed.ends_with("?\u{201D}")
3977 || prev_trimmed.ends_with(".\u{2019}")
3978 || prev_trimmed.ends_with("!\u{2019}")
3979 || prev_trimmed.ends_with("?\u{2019}"))
3980 && !text_ends_with_abbreviation(
3981 prev_trimmed.trim_end_matches(['*', '_', '"', '\'', '\u{201D}', '\u{2019}']),
3982 &abbreviations,
3983 );
3984
3985 if has_hard_break(prev_line) || (options.sentence_per_line && ends_with_sentence) {
3986 paragraph_parts.push(current_part.join(" "));
3988 current_part = vec![next_line];
3989 } else {
3990 current_part.push(next_line);
3991 }
3992 i += 1;
3993 }
3994
3995 if !current_part.is_empty() {
3997 if current_part.len() == 1 {
3998 paragraph_parts.push(current_part[0].to_string());
4000 } else {
4001 paragraph_parts.push(current_part.join(" "));
4002 }
4003 }
4004
4005 for (j, part) in paragraph_parts.iter().enumerate() {
4007 let reflowed = reflow_line(part, options);
4008 result.extend(reflowed);
4009
4010 if j < paragraph_parts.len() - 1 && !result.is_empty() && !options.sentence_per_line {
4014 let last_idx = result.len() - 1;
4015 if !has_hard_break(&result[last_idx]) {
4016 result[last_idx].push_str(" ");
4017 }
4018 }
4019 }
4020 }
4021 }
4022
4023 let result_text = result.join("\n");
4025 if content.ends_with('\n') && !result_text.ends_with('\n') {
4026 format!("{result_text}\n")
4027 } else {
4028 result_text
4029 }
4030}
4031
4032#[derive(Debug, Clone)]
4034pub struct ParagraphReflow {
4035 pub start_byte: usize,
4037 pub end_byte: usize,
4039 pub reflowed_text: String,
4041}
4042
4043#[derive(Debug, Clone)]
4049pub struct BlockquoteLineData {
4050 pub(crate) content: String,
4052 pub(crate) is_explicit: bool,
4054 pub(crate) prefix: Option<String>,
4056}
4057
4058impl BlockquoteLineData {
4059 pub fn explicit(content: String, prefix: String) -> Self {
4061 Self {
4062 content,
4063 is_explicit: true,
4064 prefix: Some(prefix),
4065 }
4066 }
4067
4068 pub fn lazy(content: String) -> Self {
4070 Self {
4071 content,
4072 is_explicit: false,
4073 prefix: None,
4074 }
4075 }
4076}
4077
4078#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4080pub enum BlockquoteContinuationStyle {
4081 Explicit,
4082 Lazy,
4083}
4084
4085pub fn blockquote_continuation_style(lines: &[BlockquoteLineData]) -> BlockquoteContinuationStyle {
4093 let mut explicit_count = 0usize;
4094 let mut lazy_count = 0usize;
4095
4096 for line in lines.iter().skip(1) {
4097 if line.is_explicit {
4098 explicit_count += 1;
4099 } else {
4100 lazy_count += 1;
4101 }
4102 }
4103
4104 if explicit_count > 0 && lazy_count == 0 {
4105 BlockquoteContinuationStyle::Explicit
4106 } else if lazy_count > 0 && explicit_count == 0 {
4107 BlockquoteContinuationStyle::Lazy
4108 } else if explicit_count >= lazy_count {
4109 BlockquoteContinuationStyle::Explicit
4110 } else {
4111 BlockquoteContinuationStyle::Lazy
4112 }
4113}
4114
4115pub fn dominant_blockquote_prefix(lines: &[BlockquoteLineData], fallback: &str) -> String {
4120 let mut counts: std::collections::HashMap<String, (usize, usize)> = std::collections::HashMap::new();
4121
4122 for (idx, line) in lines.iter().enumerate() {
4123 let Some(prefix) = line.prefix.as_ref() else {
4124 continue;
4125 };
4126 counts
4127 .entry(prefix.clone())
4128 .and_modify(|entry| entry.0 += 1)
4129 .or_insert((1, idx));
4130 }
4131
4132 counts
4133 .into_iter()
4134 .max_by(|(_, (count_a, first_idx_a)), (_, (count_b, first_idx_b))| {
4135 count_a.cmp(count_b).then_with(|| first_idx_b.cmp(first_idx_a))
4136 })
4137 .map_or_else(|| fallback.to_string(), |(prefix, _)| prefix)
4138}
4139
4140pub(crate) fn should_force_explicit_blockquote_line(content_line: &str) -> bool {
4145 let trimmed = content_line.trim_start();
4146 trimmed.starts_with('>')
4147 || trimmed.starts_with('#')
4148 || trimmed.starts_with("```")
4149 || trimmed.starts_with("~~~")
4150 || is_unordered_list_marker(trimmed)
4151 || is_numbered_list_item(trimmed)
4152 || is_horizontal_rule(trimmed)
4153 || is_definition_list_item(trimmed)
4154 || (trimmed.starts_with('[') && trimmed.contains("]:"))
4155 || trimmed.starts_with(":::")
4156 || (trimmed.starts_with('<')
4157 && !trimmed.starts_with("<http")
4158 && !trimmed.starts_with("<https")
4159 && !trimmed.starts_with("<mailto:"))
4160}
4161
4162pub fn reflow_blockquote_content(
4171 lines: &[BlockquoteLineData],
4172 explicit_prefix: &str,
4173 continuation_style: BlockquoteContinuationStyle,
4174 options: &ReflowOptions,
4175) -> Vec<String> {
4176 let content_strs: Vec<&str> = lines.iter().map(|l| l.content.as_str()).collect();
4177 let segments = split_into_segments_strs(&content_strs);
4178 let mut reflowed_content_lines: Vec<String> = Vec::new();
4179
4180 for segment in segments {
4181 let hard_break_type = segment.last().and_then(|&line| {
4182 let line = line.strip_suffix('\r').unwrap_or(line);
4183 if line.ends_with('\\') {
4184 Some("\\")
4185 } else if line.ends_with(" ") {
4186 Some(" ")
4187 } else {
4188 None
4189 }
4190 });
4191
4192 let pieces: Vec<&str> = segment
4193 .iter()
4194 .map(|&line| {
4195 if let Some(l) = line.strip_suffix('\\') {
4196 l.trim_end()
4197 } else if let Some(l) = line.strip_suffix(" ") {
4198 l.trim_end()
4199 } else {
4200 line.trim_end()
4201 }
4202 })
4203 .collect();
4204
4205 let segment_text = pieces.join(" ");
4206 let segment_text = segment_text.trim();
4207 if segment_text.is_empty() {
4208 continue;
4209 }
4210
4211 let mut reflowed = reflow_line(segment_text, options);
4212 if let Some(break_marker) = hard_break_type
4213 && !reflowed.is_empty()
4214 {
4215 let last_idx = reflowed.len() - 1;
4216 if !has_hard_break(&reflowed[last_idx]) {
4217 reflowed[last_idx].push_str(break_marker);
4218 }
4219 }
4220 reflowed_content_lines.extend(reflowed);
4221 }
4222
4223 let mut styled_lines: Vec<String> = Vec::new();
4224 for (idx, line) in reflowed_content_lines.iter().enumerate() {
4225 let force_explicit = idx == 0
4226 || continuation_style == BlockquoteContinuationStyle::Explicit
4227 || should_force_explicit_blockquote_line(line);
4228 if force_explicit {
4229 styled_lines.push(format!("{explicit_prefix}{line}"));
4230 } else {
4231 styled_lines.push(line.clone());
4232 }
4233 }
4234
4235 styled_lines
4236}
4237
4238fn is_blockquote_content_boundary(content: &str) -> bool {
4239 let trimmed = content.trim();
4240 trimmed.is_empty()
4241 || is_block_boundary(trimmed)
4242 || crate::utils::table_utils::TableUtils::is_potential_table_row(content)
4243 || trimmed.starts_with(":::")
4244 || crate::utils::is_template_directive_only(content)
4245 || is_standalone_attr_list(content)
4246 || is_snippet_block_delimiter(content)
4247}
4248
4249fn split_into_segments_strs<'a>(lines: &[&'a str]) -> Vec<Vec<&'a str>> {
4250 let mut segments = Vec::new();
4251 let mut current = Vec::new();
4252
4253 for &line in lines {
4254 current.push(line);
4255 if has_hard_break(line) {
4256 segments.push(current);
4257 current = Vec::new();
4258 }
4259 }
4260
4261 if !current.is_empty() {
4262 segments.push(current);
4263 }
4264
4265 segments
4266}
4267
4268fn reflow_blockquote_paragraph_at_line(
4269 content: &str,
4270 lines: &[&str],
4271 target_idx: usize,
4272 options: &ReflowOptions,
4273) -> Option<ParagraphReflow> {
4274 let mut anchor_idx = target_idx;
4275 let mut target_level = if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[target_idx]) {
4276 parsed.nesting_level
4277 } else {
4278 let mut found = None;
4279 let mut idx = target_idx;
4280 loop {
4281 if lines[idx].trim().is_empty() {
4282 break;
4283 }
4284 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[idx]) {
4285 found = Some((idx, parsed.nesting_level));
4286 break;
4287 }
4288 if idx == 0 {
4289 break;
4290 }
4291 idx -= 1;
4292 }
4293 let (idx, level) = found?;
4294 anchor_idx = idx;
4295 level
4296 };
4297
4298 let mut para_start = anchor_idx;
4300 while para_start > 0 {
4301 let prev_idx = para_start - 1;
4302 let prev_line = lines[prev_idx];
4303
4304 if prev_line.trim().is_empty() {
4305 break;
4306 }
4307
4308 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(prev_line) {
4309 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
4310 break;
4311 }
4312 para_start = prev_idx;
4313 continue;
4314 }
4315
4316 let prev_lazy = prev_line.trim_start();
4317 if is_blockquote_content_boundary(prev_lazy) {
4318 break;
4319 }
4320 para_start = prev_idx;
4321 }
4322
4323 while para_start < lines.len() {
4325 let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[para_start]) else {
4326 para_start += 1;
4327 continue;
4328 };
4329 target_level = parsed.nesting_level;
4330 break;
4331 }
4332
4333 if para_start >= lines.len() || para_start > target_idx {
4334 return None;
4335 }
4336
4337 let mut collected: Vec<(usize, BlockquoteLineData)> = Vec::new();
4340 let mut idx = para_start;
4341 while idx < lines.len() {
4342 if !collected.is_empty() && has_hard_break(&collected[collected.len() - 1].1.content) {
4343 break;
4344 }
4345
4346 let line = lines[idx];
4347 if line.trim().is_empty() {
4348 break;
4349 }
4350
4351 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(line) {
4352 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
4353 break;
4354 }
4355 collected.push((
4356 idx,
4357 BlockquoteLineData::explicit(trim_preserving_hard_break(parsed.content), parsed.prefix.to_string()),
4358 ));
4359 idx += 1;
4360 continue;
4361 }
4362
4363 let lazy_content = line.trim_start();
4364 if is_blockquote_content_boundary(lazy_content) {
4365 break;
4366 }
4367
4368 collected.push((idx, BlockquoteLineData::lazy(trim_preserving_hard_break(lazy_content))));
4369 idx += 1;
4370 }
4371
4372 if collected.is_empty() {
4373 return None;
4374 }
4375
4376 let para_end = collected[collected.len() - 1].0;
4377 if target_idx < para_start || target_idx > para_end {
4378 return None;
4379 }
4380
4381 let line_data: Vec<BlockquoteLineData> = collected.iter().map(|(_, d)| d.clone()).collect();
4382
4383 let fallback_prefix = line_data
4384 .iter()
4385 .find_map(|d| d.prefix.clone())
4386 .unwrap_or_else(|| "> ".to_string());
4387 let explicit_prefix = dominant_blockquote_prefix(&line_data, &fallback_prefix);
4388 let continuation_style = blockquote_continuation_style(&line_data);
4389
4390 let adjusted_line_length = options
4391 .line_length
4392 .saturating_sub(display_len(&explicit_prefix, options.length_mode))
4393 .max(1);
4394
4395 let adjusted_options = ReflowOptions {
4396 line_length: adjusted_line_length,
4397 ..options.clone()
4398 };
4399
4400 let styled_lines = reflow_blockquote_content(&line_data, &explicit_prefix, continuation_style, &adjusted_options);
4401
4402 if styled_lines.is_empty() {
4403 return None;
4404 }
4405
4406 let mut start_byte = 0;
4408 for line in lines.iter().take(para_start) {
4409 start_byte += line.len() + 1;
4410 }
4411
4412 let mut end_byte = start_byte;
4413 for line in lines.iter().take(para_end + 1).skip(para_start) {
4414 end_byte += line.len() + 1;
4415 }
4416
4417 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
4418 if !includes_trailing_newline {
4419 end_byte -= 1;
4420 }
4421
4422 let reflowed_joined = styled_lines.join("\n");
4423 let reflowed_text = if includes_trailing_newline {
4424 if reflowed_joined.ends_with('\n') {
4425 reflowed_joined
4426 } else {
4427 format!("{reflowed_joined}\n")
4428 }
4429 } else if reflowed_joined.ends_with('\n') {
4430 reflowed_joined.trim_end_matches('\n').to_string()
4431 } else {
4432 reflowed_joined
4433 };
4434
4435 Some(ParagraphReflow {
4436 start_byte,
4437 end_byte,
4438 reflowed_text,
4439 })
4440}
4441
4442pub fn reflow_paragraph_at_line(content: &str, line_number: usize, line_length: usize) -> Option<ParagraphReflow> {
4460 reflow_paragraph_at_line_with_mode(content, line_number, line_length, ReflowLengthMode::default())
4461}
4462
4463pub fn reflow_paragraph_at_line_with_mode(
4465 content: &str,
4466 line_number: usize,
4467 line_length: usize,
4468 length_mode: ReflowLengthMode,
4469) -> Option<ParagraphReflow> {
4470 let options = ReflowOptions {
4471 line_length,
4472 length_mode,
4473 ..Default::default()
4474 };
4475 reflow_paragraph_at_line_with_options(content, line_number, &options)
4476}
4477
4478pub fn reflow_paragraph_at_line_with_options(
4489 content: &str,
4490 line_number: usize,
4491 options: &ReflowOptions,
4492) -> Option<ParagraphReflow> {
4493 if line_number == 0 {
4494 return None;
4495 }
4496
4497 let lines: Vec<&str> = content.lines().collect();
4498
4499 if line_number > lines.len() {
4501 return None;
4502 }
4503
4504 let target_idx = line_number - 1; let target_line = lines[target_idx];
4506 let trimmed = target_line.trim();
4507
4508 if let Some(blockquote_reflow) = reflow_blockquote_paragraph_at_line(content, &lines, target_idx, options) {
4511 return Some(blockquote_reflow);
4512 }
4513
4514 if is_paragraph_boundary(trimmed, target_line) {
4516 return None;
4517 }
4518
4519 let mut para_start = target_idx;
4521 while para_start > 0 {
4522 let prev_idx = para_start - 1;
4523 let prev_line = lines[prev_idx];
4524 let prev_trimmed = prev_line.trim();
4525
4526 if is_paragraph_boundary(prev_trimmed, prev_line) {
4528 break;
4529 }
4530
4531 para_start = prev_idx;
4532 }
4533
4534 let mut para_end = target_idx;
4536 while para_end + 1 < lines.len() {
4537 let next_idx = para_end + 1;
4538 let next_line = lines[next_idx];
4539 let next_trimmed = next_line.trim();
4540
4541 if is_paragraph_boundary(next_trimmed, next_line) {
4543 break;
4544 }
4545
4546 para_end = next_idx;
4547 }
4548
4549 let paragraph_lines = &lines[para_start..=para_end];
4551
4552 let mut start_byte = 0;
4554 for line in lines.iter().take(para_start) {
4555 start_byte += line.len() + 1; }
4557
4558 let mut end_byte = start_byte;
4559 for line in paragraph_lines {
4560 end_byte += line.len() + 1; }
4562
4563 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
4566
4567 if !includes_trailing_newline {
4569 end_byte -= 1;
4570 }
4571
4572 let paragraph_text = paragraph_lines.join("\n");
4574
4575 let reflowed = reflow_markdown(¶graph_text, options);
4577
4578 let reflowed_text = if includes_trailing_newline {
4582 if reflowed.ends_with('\n') {
4584 reflowed
4585 } else {
4586 format!("{reflowed}\n")
4587 }
4588 } else {
4589 if reflowed.ends_with('\n') {
4591 reflowed.trim_end_matches('\n').to_string()
4592 } else {
4593 reflowed
4594 }
4595 };
4596
4597 Some(ParagraphReflow {
4598 start_byte,
4599 end_byte,
4600 reflowed_text,
4601 })
4602}
4603fn decompose_code_span(raw: &str) -> Option<(&str, &str)> {
4609 let marker_len = raw.bytes().take_while(|&b| b == b'`').count();
4610 if marker_len == 0 {
4611 return None;
4612 }
4613 let marker = &raw[..marker_len];
4614 if raw.len() < marker_len * 2 {
4615 return None;
4616 }
4617 let content = &raw[marker_len..raw.len() - marker_len];
4618 Some((content, marker))
4619}
4620
4621#[cfg(test)]
4622mod tests {
4623 use super::*;
4624
4625 #[test]
4629 fn preserves_content_accepts_whitespace_changes_and_rejects_the_rest() {
4630 let accepted: &[(&str, &[&str])] = &[
4631 ("one two three", &["one two three"]),
4632 ("one two three", &["one two", "three"]),
4633 ("one two three", &["one", "two", "three"]),
4634 ("one two ", &["one two"]),
4636 ("日本語のテキスト", &["日本語の", "テキスト"]),
4638 ("_First. Second._", &["_First.", "Second._"]),
4640 ];
4641 for (original, reflowed) in accepted {
4642 let reflowed: Vec<String> = reflowed.iter().map(ToString::to_string).collect();
4643 assert!(
4644 preserves_content(original, &reflowed),
4645 "{original:?} -> {reflowed:?} only moves whitespace"
4646 );
4647 }
4648
4649 let rejected: &[(&str, &[&str])] = &[
4650 ("one two three", &["one two"]),
4652 ("one two", &["one two three"]),
4654 ("one two", &["two one"]),
4656 ("_First. Second._", &["_First._", "_Second._"]),
4658 ("alpha and beta", &["alpha", "andbeta"]),
4660 ("mot suivant : autre", &["mot suivant: autre"]),
4662 ];
4663 for (original, reflowed) in rejected {
4664 let reflowed: Vec<String> = reflowed.iter().map(ToString::to_string).collect();
4665 assert!(
4666 !preserves_content(original, &reflowed),
4667 "{original:?} -> {reflowed:?} changes the text, not just its line breaks"
4668 );
4669 }
4670 }
4671
4672 #[test]
4674 fn reflow_line_falls_back_to_the_input_when_content_would_change() {
4675 let options = ReflowOptions {
4676 line_length: 40,
4677 ..Default::default()
4678 };
4679 let line = "one two three four five six seven eight nine ten";
4680
4681 assert!(preserves_content(line, &reflow_line(line, &options)));
4682 assert_eq!(reflow_line(line, &options), reflow_line_unchecked(line, &options));
4683 }
4684
4685 #[test]
4686 fn cascade_split_line_handles_a_very_long_line_without_overflowing() {
4687 let words: Vec<String> = (0..4000).map(|i| format!("word{i}")).collect();
4693 let line = words.join(" ");
4694
4695 let options = ReflowOptions {
4696 line_length: 80,
4697 length_mode: ReflowLengthMode::Chars,
4698 ..Default::default()
4699 };
4700 let out = cascade_split_line(&line, &options);
4701
4702 assert!(out.len() > 1, "a very long line should split into many lines");
4703 for segment in &out {
4704 assert!(
4705 display_len(segment, ReflowLengthMode::Chars) <= 80 || !segment.contains(' '),
4706 "each wrapped line should fit the width (or be a single unbreakable token)"
4707 );
4708 }
4709 let rejoined = out.join(" ");
4711 let original_words: Vec<&str> = line.split(' ').collect();
4712 let result_words: Vec<&str> = rejoined.split_whitespace().collect();
4713 assert_eq!(original_words, result_words, "reflow must preserve all words in order");
4714 }
4715
4716 #[test]
4721 fn test_helper_function_text_ends_with_abbreviation() {
4722 let abbreviations = get_abbreviations(&None);
4724
4725 assert!(text_ends_with_abbreviation("Dr.", &abbreviations));
4727 assert!(text_ends_with_abbreviation("word Dr.", &abbreviations));
4728 assert!(text_ends_with_abbreviation("e.g.", &abbreviations));
4729 assert!(text_ends_with_abbreviation("i.e.", &abbreviations));
4730 assert!(text_ends_with_abbreviation("Mr.", &abbreviations));
4731 assert!(text_ends_with_abbreviation("Mrs.", &abbreviations));
4732 assert!(text_ends_with_abbreviation("Ms.", &abbreviations));
4733 assert!(text_ends_with_abbreviation("Prof.", &abbreviations));
4734
4735 assert!(!text_ends_with_abbreviation("etc.", &abbreviations));
4737 assert!(!text_ends_with_abbreviation("paradigms.", &abbreviations));
4738 assert!(!text_ends_with_abbreviation("programs.", &abbreviations));
4739 assert!(!text_ends_with_abbreviation("items.", &abbreviations));
4740 assert!(!text_ends_with_abbreviation("systems.", &abbreviations));
4741 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)); }
4747
4748 #[test]
4749 fn test_footnote_after_period_splits_sentence() {
4750 let text = "First sentence.[^1] Second sentence.";
4754 let sentences = split_into_sentences(text);
4755 assert_eq!(
4756 sentences,
4757 vec!["First sentence.[^1]".to_string(), "Second sentence.".to_string()],
4758 "footnote glued to the period should keep the boundary and stay attached to the first sentence"
4759 );
4760 }
4761
4762 #[test]
4763 fn test_multiple_consecutive_footnotes_after_period_splits_sentence() {
4764 let text = "Notes here.[^1][^2] Second sentence.";
4766 let sentences = split_into_sentences(text);
4767 assert_eq!(
4768 sentences,
4769 vec!["Notes here.[^1][^2]".to_string(), "Second sentence.".to_string()]
4770 );
4771 }
4772
4773 #[test]
4774 fn test_footnote_before_period_still_splits_sentence() {
4775 let text = "Annotation here[^1]. Second sentence.";
4779 let sentences = split_into_sentences(text);
4780 assert_eq!(
4781 sentences,
4782 vec!["Annotation here[^1].".to_string(), "Second sentence.".to_string()]
4783 );
4784 }
4785
4786 #[test]
4787 fn test_mid_sentence_footnote_does_not_split() {
4788 let text = "The system word[^1] more words. Next sentence.";
4791 let sentences = split_into_sentences(text);
4792 assert_eq!(
4793 sentences,
4794 vec![
4795 "The system word[^1] more words.".to_string(),
4796 "Next sentence.".to_string()
4797 ]
4798 );
4799 }
4800
4801 #[test]
4802 fn test_bare_numeric_bracket_after_period_does_not_split() {
4803 let text = "Citation here.[1] Second sentence.";
4806 let sentences = split_into_sentences(text);
4807 assert_eq!(
4808 sentences,
4809 vec![text.to_string()],
4810 "a bare numeric bracket must not be treated as a sentence boundary"
4811 );
4812 }
4813
4814 #[test]
4815 fn test_footnote_glued_to_following_word_does_not_split() {
4816 let text = "First sentence.[^1]Continued glued text.";
4819 let sentences = split_into_sentences(text);
4820 assert_eq!(sentences, vec![text.to_string()]);
4821 }
4822
4823 #[test]
4824 fn test_footnote_at_end_of_text_is_preserved() {
4825 let text = "Sentence.[^1]";
4828 let sentences = split_into_sentences(text);
4829 assert_eq!(sentences, vec![text.to_string()]);
4830 }
4831
4832 #[test]
4833 fn test_abbreviation_before_footnote_does_not_split() {
4834 let text = "See the notes, e.g.[^1] this one.";
4837 let sentences = split_into_sentences(text);
4838 assert_eq!(
4839 sentences,
4840 vec![text.to_string()],
4841 "e.g. is an abbreviation, not a sentence boundary"
4842 );
4843 }
4844
4845 #[test]
4846 fn test_is_unordered_list_marker() {
4847 assert!(is_unordered_list_marker("- item"));
4849 assert!(is_unordered_list_marker("* item"));
4850 assert!(is_unordered_list_marker("+ item"));
4851 assert!(is_unordered_list_marker("-")); assert!(is_unordered_list_marker("*"));
4853 assert!(is_unordered_list_marker("+"));
4854
4855 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")); }
4866
4867 #[test]
4868 fn test_is_block_boundary() {
4869 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"));
4891 assert!(!is_block_boundary("*emphasis*")); assert!(!is_block_boundary("[link](url)")); assert!(!is_block_boundary("some words here"));
4894 }
4895
4896 #[test]
4897 fn test_definition_list_boundary_in_single_line_paragraph() {
4898 let options = ReflowOptions {
4901 line_length: 80,
4902 ..Default::default()
4903 };
4904 let input = "Term\n: Definition of the term";
4905 let result = reflow_markdown(input, &options);
4906 assert!(
4908 result.contains(": Definition"),
4909 "Definition list item should not be merged into previous line. Got: {result:?}"
4910 );
4911 let lines: Vec<&str> = result.lines().collect();
4912 assert_eq!(lines.len(), 2, "Should remain two separate lines. Got: {lines:?}");
4913 assert_eq!(lines[0], "Term");
4914 assert_eq!(lines[1], ": Definition of the term");
4915 }
4916
4917 #[test]
4918 fn test_is_paragraph_boundary() {
4919 assert!(is_paragraph_boundary("# Heading", "# Heading"));
4921 assert!(is_paragraph_boundary("- item", "- item"));
4922 assert!(is_paragraph_boundary(":::", ":::"));
4923 assert!(is_paragraph_boundary(": definition", ": definition"));
4924
4925 assert!(is_paragraph_boundary("code", " code"));
4927 assert!(is_paragraph_boundary("code", "\tcode"));
4928
4929 assert!(is_paragraph_boundary("| a | b |", "| a | b |"));
4931 assert!(is_paragraph_boundary("a | b", "a | b")); assert!(!is_paragraph_boundary("regular text", "regular text"));
4935 assert!(!is_paragraph_boundary("text", " text")); }
4937
4938 #[test]
4939 fn test_div_marker_boundary_in_reflow_paragraph_at_line() {
4940 let content = "Some paragraph text here.\n\n::: {.callout-note}\nThis is a callout.\n:::\n";
4943 let result = reflow_paragraph_at_line(content, 3, 80);
4945 assert!(result.is_none(), "Div marker line should not be reflowed");
4946 }
4947
4948 #[test]
4949 fn starts_block_construct_detects_block_openers() {
4950 for case in ["- item", "-", "* item", "*", "+ item", "+", "-\titem"] {
4952 assert!(starts_block_construct(case), "bullet: {case:?}");
4953 }
4954 for case in ["1. item", "1) item", "01. x", "000000001. x", "1.\titem"] {
4957 assert!(starts_block_construct(case), "ordered: {case:?}");
4958 }
4959 for case in ["> quote", ">quote", ">"] {
4961 assert!(starts_block_construct(case), "blockquote: {case:?}");
4962 }
4963 for case in ["# heading", "###### h6", "#", "##"] {
4965 assert!(starts_block_construct(case), "heading: {case:?}");
4966 }
4967 for case in ["```", "```rust", "````", "~~~", "~~~text"] {
4969 assert!(starts_block_construct(case), "fence: {case:?}");
4970 }
4971 for case in ["---", "--", "===", "=", "***", "___", "_ _ _", "- - -"] {
4973 assert!(starts_block_construct(case), "setext/thematic: {case:?}");
4974 }
4975 for case in [
4978 "[^1]: text",
4979 "[^note]:",
4980 "[ref]: http://example.com",
4981 "[wat]: url follows",
4982 ] {
4983 assert!(starts_block_construct(case), "definition: {case:?}");
4984 }
4985 for case in ["<div>content", "</div>", "<p>text", "<table>", "<pre>code", "<h1>x"] {
4987 assert!(starts_block_construct(case), "html block: {case:?}");
4988 }
4989 }
4990
4991 #[test]
4992 fn starts_block_construct_allows_ordinary_prose() {
4993 for case in [
4994 "",
4995 "word",
4996 "-5 degrees",
4997 "--flag",
4998 "-item",
4999 "#hashtag",
5000 "####### seven hashes is not a heading",
5001 "1.5 million",
5002 "1234567890. ten digits is not a list marker",
5003 "0000000001. ten digits is not a list marker either",
5004 "2. item",
5007 "7. item",
5008 "0. item",
5009 "42) x",
5010 "123456. item",
5011 "1.",
5012 "1)",
5013 "123456.",
5014 "123456)",
5015 "1.item",
5016 "1:30 pm",
5017 "*emphasis*",
5018 "**bold** text",
5019 "__bold__ text",
5020 "_emphasis_ text",
5021 "`code` span",
5022 "`` double backtick span ``",
5023 "~~strikethrough~~",
5024 "=x",
5025 "== ==",
5026 "(parenthetical)",
5027 "[link](url)",
5028 "[text][ref] more",
5029 "[bracketed] aside",
5030 "[a](b) [ref]: first bracket is a link, not a label",
5031 "[esc\\]: not a close] text",
5032 "<span>inline</span>",
5033 "<b>bold</b>",
5034 "<https://example.com> autolink",
5035 "<mailto:a@b.com>",
5036 "<notarealtag>",
5037 ] {
5038 assert!(!starts_block_construct(case), "prose: {case:?}");
5039 }
5040 }
5041
5042 #[test]
5043 fn merge_block_construct_continuations_merges_marker_led_lines() {
5044 let lines = vec![
5045 "First sentence?".to_string(),
5046 "- looks like a list item".to_string(),
5047 "Second sentence.".to_string(),
5048 ];
5049 assert_eq!(
5050 merge_block_construct_continuations(lines),
5051 vec![
5052 "First sentence? - looks like a list item".to_string(),
5053 "Second sentence.".to_string(),
5054 ]
5055 );
5056
5057 let lines = vec!["- real list content".to_string(), "continuation".to_string()];
5060 assert_eq!(
5061 merge_block_construct_continuations(lines.clone()),
5062 lines,
5063 "first line must never be merged"
5064 );
5065
5066 let lines = vec!["prose".to_string(), "1.".to_string(), "[ref]:".to_string()];
5069 assert_eq!(
5070 merge_block_construct_continuations(lines),
5071 vec!["prose 1. [ref]:".to_string()],
5072 "a merge that creates an opener must fold again"
5073 );
5074 }
5075
5076 #[test]
5077 fn wrap_never_starts_a_line_with_a_block_marker() {
5078 let options = ReflowOptions {
5079 line_length: 25,
5080 ..Default::default()
5081 };
5082 let lines = reflow_line(
5085 "Some words here and then - a dash clause that wraps around the limit.",
5086 &options,
5087 );
5088 assert_eq!(
5089 lines,
5090 vec![
5091 "Some words here and",
5092 "then - a dash clause that",
5093 "wraps around the limit."
5094 ]
5095 );
5096
5097 for input in [
5099 "Alpha beta gamma delta epsilon - dash clause here to wrap",
5100 "Alpha beta gamma delta epsilon > quote lookalike here to wrap",
5101 "Alpha beta gamma delta epsilon # heading lookalike here to wrap",
5102 "Alpha beta gamma delta epsilon 1. ordered lookalike here to wrap",
5103 "Alpha beta gamma delta epsilon * star clause here to wrap",
5104 "Alpha beta gamma delta epsilon + plus clause here to wrap",
5105 ] {
5106 for width in 10..40 {
5107 let options = ReflowOptions {
5108 line_length: width,
5109 ..Default::default()
5110 };
5111 for line in reflow_line(input, &options) {
5112 assert!(
5113 !starts_block_construct(&line),
5114 "width {width}: wrapped line opens a block construct: {line:?} (input {input:?})"
5115 );
5116 }
5117 }
5118 }
5119 }
5120
5121 #[test]
5122 fn sentence_per_line_keeps_block_markers_mid_line() {
5123 let options = ReflowOptions {
5124 line_length: 80,
5125 sentence_per_line: true,
5126 ..Default::default()
5127 };
5128 let lines = reflow_line(
5131 "Google Calendar (Can't we get rid of this dependency? - I don't really see the need)",
5132 &options,
5133 );
5134 assert_eq!(
5135 lines,
5136 vec!["Google Calendar (Can't we get rid of this dependency? - I don't really see the need)".to_string()]
5137 );
5138
5139 let lines = reflow_line("See section 4? # is the marker we use. Fine.", &options);
5141 assert_eq!(lines, vec!["See section 4? # is the marker we use.", "Fine."]);
5142
5143 let lines = reflow_line("Is this a problem? > I quote someone here.", &options);
5144 assert_eq!(lines, vec!["Is this a problem? > I quote someone here."]);
5145
5146 let lines = reflow_line("Another case! 1. Not a list. More text follows here.", &options);
5147 for line in &lines {
5148 assert!(
5149 !starts_block_construct(line),
5150 "sentence-per-line output opens a block construct: {line:?}"
5151 );
5152 }
5153 }
5154
5155 #[test]
5156 fn inline_math_directly_after_display_math_stays_atomic() {
5157 let options = ReflowOptions {
5165 line_length: 8,
5166 ..Default::default()
5167 };
5168 let lines = reflow_line("$$a$$$bb cc dd$ x", &options);
5169 assert_eq!(lines, vec!["$$a$$$bb cc dd$".to_string(), "x".to_string()]);
5170 }
5171
5172 #[test]
5173 fn test_code_span_parsing() {
5174 let elements = parse_markdown_elements_inner("`code`", false, false, None);
5176 assert_eq!(elements.len(), 1);
5177 assert!(matches!(&elements[0], Element::Code { content, marker } if content == "code" && marker == "`"));
5178
5179 let elements = parse_markdown_elements_inner("``code``", false, false, None);
5181 assert_eq!(elements.len(), 1);
5182 assert!(matches!(&elements[0], Element::Code { content, marker } if content == "code" && marker == "``"));
5183
5184 let elements = parse_markdown_elements_inner("``code`inside``", false, false, None);
5186 assert_eq!(elements.len(), 1);
5187 assert!(
5188 matches!(&elements[0], Element::Code { content, marker } if content == "code`inside" && marker == "``")
5189 );
5190
5191 let elements = parse_markdown_elements_inner("`` code ``", false, false, None);
5193 assert_eq!(elements.len(), 1);
5194 assert!(matches!(&elements[0], Element::Code { content, marker } if content == " code " && marker == "``"));
5195
5196 let elements = parse_markdown_elements_inner("`unclosed", false, false, None);
5198 assert_eq!(elements.len(), 1);
5199 assert!(matches!(&elements[0], Element::Text(s) if s == "`unclosed"));
5200
5201 let elements = parse_markdown_elements_inner("`unclosed [link](url)", false, false, None);
5203 assert_eq!(elements.len(), 2);
5205 assert!(matches!(&elements[0], Element::Text(s) if s == "`unclosed "));
5206 assert!(matches!(&elements[1], Element::Link(s) if s == "[link](url)"));
5207 }
5208
5209 #[test]
5210 fn test_reflow_performance_long_input() {
5211 let mut text = String::new();
5214 for i in 1..400 {
5215 let backticks = "`".repeat(i);
5216 text.push_str(&backticks);
5217 text.push(' ');
5218 }
5219
5220 let start = std::time::Instant::now();
5221 let elements = parse_markdown_elements_inner(&text, false, false, None);
5222 let duration = start.elapsed();
5223
5224 assert!(duration.as_millis() < 100, "Parsing took too long: {duration:?}");
5226 assert!(!elements.is_empty());
5227 }
5228
5229 #[test]
5230 fn test_reflow_performance_display_math_heavy() {
5231 let text = "$$a$$".repeat(4000);
5236
5237 let start = std::time::Instant::now();
5238 let elements = parse_markdown_elements_inner(&text, false, false, None);
5239 let duration = start.elapsed();
5240
5241 assert!(duration.as_millis() < 100, "Parsing took too long: {duration:?}");
5242 assert_eq!(elements.len(), 4000);
5243 }
5244
5245 #[test]
5246 fn inline_math_len_at_start_matches_regex_at_slice_start() {
5247 let alphabet = ['$', 'a', ' '];
5252 let mut inputs: Vec<String> = vec![String::new()];
5253 let mut frontier: Vec<String> = vec![String::new()];
5254 for _ in 0..6 {
5255 let mut longer = Vec::new();
5256 for prefix in &frontier {
5257 for ch in alphabet {
5258 let mut s = prefix.clone();
5259 s.push(ch);
5260 longer.push(s);
5261 }
5262 }
5263 inputs.extend(longer.iter().cloned());
5264 frontier = longer;
5265 }
5266 inputs.push("$αβ$x".to_string());
5268 inputs.push("$α$$".to_string());
5269
5270 for s in &inputs {
5271 let expected = INLINE_MATH_REGEX
5272 .find(s)
5273 .ok()
5274 .flatten()
5275 .filter(|m| m.start() == 0)
5276 .map(|m| m.end());
5277 assert_eq!(inline_math_len_at_start(s), expected, "input: {s:?}");
5278 }
5279 }
5280
5281 #[test]
5282 fn inline_math_probe_after_dollar_matches_uncached_parse() {
5283 let cases = [
5289 ("$$a$$$b c$ x", r#"[DisplayMath("a"), InlineMath("b c"), Text(" x")]"#),
5290 (
5291 "$$a$$$b$ $$a$$$b$",
5292 r#"[DisplayMath("a"), InlineMath("b"), Text(" "), DisplayMath("a"), InlineMath("b")]"#,
5293 ),
5294 (
5296 "$$a$$$ x $y z$",
5297 r#"[DisplayMath("a"), InlineMath(" x "), Text("y z$")]"#,
5298 ),
5299 ("$$a$$$$ x", r#"[DisplayMath("a"), Text("$$ x")]"#),
5301 ("$$a$$$$b$$ x", r#"[DisplayMath("a"), DisplayMath("b"), Text(" x")]"#),
5302 (
5304 "$a$$b$$c$$d$ tail",
5305 r#"[Text("$a"), DisplayMath("b"), Text("c$$d$ tail")]"#,
5306 ),
5307 ];
5308 for (input, expected) in cases {
5309 let elements = parse_markdown_elements_inner(input, false, false, None);
5310 assert_eq!(format!("{elements:?}"), expected, "input: {input:?}");
5311 }
5312 }
5313
5314 #[test]
5315 fn test_atomic_spans() {
5316 let text_emphasis = "hello **word1 word2**";
5318
5319 let options_disabled = ReflowOptions {
5320 line_length: 18,
5321 atomic_spans: true,
5322 ..Default::default()
5323 };
5324 let lines_disabled = reflow_line(text_emphasis, &options_disabled);
5325 assert_eq!(lines_disabled, vec!["hello", "**word1 word2**"]);
5326
5327 let options_enabled = ReflowOptions {
5328 line_length: 18,
5329 atomic_spans: false,
5330 ..Default::default()
5331 };
5332 let lines_enabled = reflow_line(text_emphasis, &options_enabled);
5333 assert_eq!(lines_enabled, vec!["hello **word1", "word2**"]);
5334
5335 let text_code = "hello `word1 word2`";
5337
5338 let lines_code_disabled = reflow_line(text_code, &options_disabled);
5339 assert_eq!(lines_code_disabled, vec!["hello", "`word1 word2`"]);
5340
5341 let lines_code_enabled = reflow_line(text_code, &options_enabled);
5342 assert_eq!(lines_code_enabled, vec!["hello `word1", "word2`"]);
5343
5344 let text_code_padding = "hello `` `word1` `word2` ``";
5346 let lines_padding_enabled = reflow_line(text_code_padding, &options_enabled);
5347 assert_eq!(lines_padding_enabled, vec![r#"hello `` `word1`"#, r#"`word2` ``"#]);
5348
5349 let text_attached = "**one two**,"; let options_11 = ReflowOptions {
5354 line_length: 11,
5355 atomic_spans: true,
5356 ..Default::default()
5357 };
5358 assert_eq!(reflow_line(text_attached, &options_11), vec!["**one two**,"]);
5359
5360 let options_10 = ReflowOptions {
5362 line_length: 10,
5363 atomic_spans: true,
5364 ..Default::default()
5365 };
5366 assert_eq!(reflow_line(text_attached, &options_10), vec!["**one", "two**,"]);
5367 }
5368
5369 #[test]
5370 fn test_emphasis_containing_markers_is_not_split() {
5371 let options = ReflowOptions {
5372 line_length: 5,
5373 atomic_spans: false,
5374 ..Default::default()
5375 };
5376 let lines = reflow_line(r#"*foo \*bar*"#, &options);
5378 assert_eq!(lines, vec![r#"*foo \*bar*"#.to_string()]);
5379 }
5380
5381 fn semantic_shape(markdown: &str) -> String {
5386 let mut options = Options::empty();
5387 options.insert(Options::ENABLE_STRIKETHROUGH);
5388 let mut out = String::new();
5389 let push_prose = |out: &mut String, text: &str| {
5390 for c in text.chars() {
5391 if c.is_whitespace() {
5392 if !out.ends_with(char::is_whitespace) {
5393 out.push(' ');
5394 }
5395 } else {
5396 out.push(c);
5397 }
5398 }
5399 };
5400 for event in Parser::new_ext(markdown, options) {
5401 match event {
5402 Event::Text(text) => push_prose(&mut out, &text),
5403 Event::SoftBreak | Event::HardBreak => push_prose(&mut out, " "),
5404 Event::Code(code) => out.push_str(&format!("<code>{code}</code>")),
5406 Event::Start(tag) => out.push_str(&format!("<{tag:?}>")),
5407 Event::End(tag) => out.push_str(&format!("</{tag:?}>")),
5408 other => out.push_str(&format!("{other:?}")),
5409 }
5410 }
5411 out.trim().to_string()
5412 }
5413
5414 #[test]
5415 fn test_wrapping_a_span_never_changes_what_it_parses_to() {
5416 let corpus = [
5420 "_This is a very, very, very, very, very long line with some `code` inside._",
5421 "_alpha beta gamma delta epsilon `a b` zeta eta theta iota kappa lambda_",
5422 "**strong text with `code` and more words than fit on one single line**",
5423 "~~struck text with `code` and more words than fit on one single line~~",
5424 "_emphasis with **nested strong that is quite long** and trailing words_",
5425 "***A doubly nested bold italic span with more words than fit on a line***",
5428 "___Another doubly nested span with more words than fit on a single line___",
5429 "**_mixed strong then emphasis with more words than fit on a single line_**",
5430 "*__mixed emphasis then strong with more words than fit on a single line__*",
5431 "**~~strong strikethrough with more words than fit on a single line here~~**",
5432 "**a * b with a stray marker and plenty more words to pass the budget**",
5435 "_foo `a` bar `b` baz qux quux corge grault garply waldo fred plugh xyzzy_",
5436 "text before _a long emphasis with `code` inside of it here_ and after",
5437 "(_a parenthesized long emphasis with `code` inside of it right here_)",
5438 r#"*foo \*bar baz qux quux corge grault garply waldo fred plugh xyzzy*"#,
5439 "_tab\tseparated `a\tb` words spread out over quite a long emphasis span_",
5440 "_This has [text](<a b c d e f g h i j k>) and trailing words to wrap._",
5443 "_This has [`code`](<a b c d e f g h i j k>) and trailing words to wrap._",
5444 r#"_See [x](https://example.com "a long link title here") and `code` too._"#,
5445 "_A [link with a long label](https://example.com/path) and `code` here._",
5446 "_An image  plus `code` and more text_",
5447 ];
5448 for text in corpus {
5449 let expected = semantic_shape(text);
5450 for line_length in [20, 30, 40, 80] {
5451 for atomic_spans in [true, false] {
5452 let options = ReflowOptions {
5453 line_length,
5454 atomic_spans,
5455 ..Default::default()
5456 };
5457 let wrapped = reflow_line(text, &options).join("\n");
5458 assert_eq!(
5459 semantic_shape(&wrapped),
5460 expected,
5461 "reflow changed the parse of {text:?} at line_length={line_length} \
5462 atomic_spans={atomic_spans}\n wrapped: {wrapped:?}"
5463 );
5464 }
5465 }
5466 }
5467 }
5468
5469 #[test]
5470 fn test_wrapping_a_span_keeps_whole_the_constructs_pulldown_cannot_see() {
5471 let cases = [
5475 (
5476 "_alpha beta gamma [[a wiki link]] delta epsilon zeta eta_",
5477 "[[a wiki link]]",
5478 ),
5479 (
5480 "_alpha beta gamma {{< foo bar >}} delta epsilon zeta eta_",
5481 "{{< foo bar >}}",
5482 ),
5483 ("_alpha beta gamma $a + b$ delta epsilon zeta eta theta_", "$a + b$"),
5484 ("_alpha beta gamma $$a + b$$ delta epsilon zeta eta theta_", "$$a + b$$"),
5485 ];
5486 for (text, construct) in cases {
5487 for line_length in [12, 20, 30] {
5488 for atomic_spans in [true, false] {
5489 let options = ReflowOptions {
5490 line_length,
5491 atomic_spans,
5492 ..Default::default()
5493 };
5494 let wrapped = reflow_line(text, &options).join("\n");
5495 assert!(
5496 wrapped.contains(construct),
5497 "{construct} was broken at line_length={line_length} \
5498 atomic_spans={atomic_spans}: {wrapped:?}"
5499 );
5500 }
5501 }
5502 }
5503 }
5504
5505 #[test]
5506 fn test_overlong_emphasis_with_nested_code_span_wraps() {
5507 let options = ReflowOptions {
5511 line_length: 80,
5512 atomic_spans: true,
5513 ..Default::default()
5514 };
5515 let text = "_This is a very, very, very, very, very, very, very long line that exceeds 80 characters with some `code` inside._";
5516 let lines = reflow_line(text, &options);
5517 assert_eq!(
5518 lines,
5519 vec![
5520 "_This is a very, very, very, very, very, very, very long line that exceeds 80",
5521 "characters with some `code` inside._",
5522 ]
5523 );
5524 }
5525
5526 #[test]
5527 fn test_overlong_emphasis_with_nested_strong_wraps() {
5528 let options = ReflowOptions {
5530 line_length: 80,
5531 atomic_spans: true,
5532 ..Default::default()
5533 };
5534 let text = "_This is a very, very, very, very, very, very, very long line that exceeds 80 characters with some **bold** inside._";
5535 let lines = reflow_line(text, &options);
5536 assert_eq!(
5537 lines,
5538 vec![
5539 "_This is a very, very, very, very, very, very, very long line that exceeds 80",
5540 "characters with some **bold** inside._",
5541 ]
5542 );
5543 }
5544
5545 #[test]
5546 fn test_overlong_doubly_nested_span_wraps() {
5547 let options = ReflowOptions {
5552 line_length: 80,
5553 atomic_spans: true,
5554 ..Default::default()
5555 };
5556 let body = "This is a very, very, very, very, very, very, very, very, very, very long line that is emphasised.";
5557 for (open, close) in [
5558 ("***", "***"),
5559 ("___", "___"),
5560 ("**_", "_**"),
5561 ("*__", "__*"),
5562 ("**~~", "~~**"),
5563 ] {
5564 let text = format!("{open}{body}{close}");
5565 assert!(text.len() > options.line_length, "case must start over budget");
5566 let lines = reflow_line(&text, &options);
5567 assert!(
5568 lines.len() > 1,
5569 "{open}...{close} should wrap but stayed on one line: {lines:?}"
5570 );
5571 assert!(
5572 lines.iter().all(|line| line.len() <= options.line_length),
5573 "{open}...{close} left a line over the budget: {lines:?}"
5574 );
5575 assert_eq!(
5576 lines.join(" "),
5577 text,
5578 "{open}...{close} wrapping must only replace a space with a newline"
5579 );
5580 }
5581 }
5582
5583 #[test]
5584 fn test_overlong_span_with_stray_marker_stays_whole() {
5585 let options = ReflowOptions {
5589 line_length: 40,
5590 atomic_spans: true,
5591 ..Default::default()
5592 };
5593 let text = "**alpha * beta gamma delta epsilon zeta eta theta iota kappa**";
5594 let lines = reflow_line(text, &options);
5595 assert_eq!(lines, vec![text], "stray marker must keep the span whole");
5596 }
5597
5598 #[test]
5599 fn test_overlong_span_never_breaks_inside_a_nested_reference_link() {
5600 let options = ReflowOptions {
5606 line_length: 30,
5607 atomic_spans: true,
5608 defined_references: Some(HashSet::from([
5609 "ref".to_string(),
5610 "one two three four five six seven".to_string(),
5612 ])),
5613 ..Default::default()
5614 };
5615 for (text, link) in [
5616 (
5617 "_**alpha [one two three four five six seven][ref] beta gamma delta**_",
5618 "[one two three four five six seven][ref]",
5619 ),
5620 (
5621 "**alpha [one two three four five six seven][ref] beta gamma delta**",
5622 "[one two three four five six seven][ref]",
5623 ),
5624 (
5625 "_**alpha ![one two three four five six seven][ref] beta gamma delta**_",
5626 "![one two three four five six seven][ref]",
5627 ),
5628 (
5629 "_**alpha [one two three four five six seven][] beta gamma delta**_",
5630 "[one two three four five six seven][]",
5631 ),
5632 (
5633 "_**alpha [one two three four five six seven] beta gamma delta**_",
5634 "[one two three four five six seven]",
5635 ),
5636 ] {
5637 let lines = reflow_line(text, &options);
5638 assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
5639 assert!(
5640 lines.iter().any(|line| line.contains(link)),
5641 "{link} must stay on one line: {lines:?}"
5642 );
5643 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
5644 }
5645 }
5646
5647 #[test]
5648 fn test_overlong_span_breaks_inside_an_undefined_shortcut_reference() {
5649 let options = ReflowOptions {
5653 line_length: 30,
5654 atomic_spans: true,
5655 defined_references: Some(HashSet::new()),
5656 ..Default::default()
5657 };
5658 let text = "_**alpha [one two three four five six seven] beta gamma delta**_";
5659 let lines = reflow_line(text, &options);
5660 assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
5661 assert!(
5662 !lines
5663 .iter()
5664 .any(|line| line.contains("[one two three four five six seven]")),
5665 "an undefined shortcut is prose and should break: {lines:?}"
5666 );
5667 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
5668 }
5669
5670 #[test]
5671 fn test_overlong_span_never_breaks_inside_a_nested_attr_list() {
5672 let attr = "{.highlight key=\"a b c\"}";
5676 let text = format!("_**alpha beta gamma delta epsilon zeta{attr} eta theta iota kappa**_");
5677 let options = ReflowOptions {
5678 line_length: 20,
5679 atomic_spans: true,
5680 attr_lists: true,
5681 ..Default::default()
5682 };
5683 let lines = reflow_line(&text, &options);
5684 assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
5685 assert!(
5686 lines.iter().any(|line| line.contains(attr)),
5687 "attr list must stay on one line: {lines:?}"
5688 );
5689 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
5690
5691 let plain = ReflowOptions {
5694 attr_lists: false,
5695 ..options
5696 };
5697 let lines = reflow_line(&text, &plain);
5698 assert!(
5699 !lines.iter().any(|line| line.contains(attr)),
5700 "without the flavor the braces are prose and should break: {lines:?}"
5701 );
5702 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
5703 }
5704
5705 #[test]
5706 fn test_overlong_emphasis_never_breaks_inside_nested_code_span() {
5707 let options = ReflowOptions {
5711 line_length: 30,
5712 atomic_spans: true,
5713 ..Default::default()
5714 };
5715 let text = "_alpha beta gamma delta epsilon `a b` zeta eta theta iota kappa_";
5716 let lines = reflow_line(text, &options);
5717 assert!(lines.len() > 1, "over-long emphasis should wrap: {lines:?}");
5718 assert!(
5719 lines.iter().any(|line| line.contains("`a b`")),
5720 "nested code span must stay whole with its interior spaces: {lines:?}"
5721 );
5722 for line in &lines {
5723 assert_eq!(
5724 line.matches('`').count() % 2,
5725 0,
5726 "no line may contain half a code span: {line:?}"
5727 );
5728 }
5729 }
5730
5731 #[test]
5732 fn test_definition_list_marker_does_not_start_line() {
5733 let options = ReflowOptions {
5734 line_length: 20,
5735 ..Default::default()
5736 };
5737 let lines = reflow_line("This is a term and : definition here.", &options);
5739 for line in &lines {
5740 assert!(
5741 !line.trim_start().starts_with(": "),
5742 "Wrapped line should not start with definition marker: {line}"
5743 );
5744 }
5745 }
5746
5747 #[test]
5748 fn test_div_marker_does_not_start_line() {
5749 let options = ReflowOptions {
5750 line_length: 20,
5751 ..Default::default()
5752 };
5753 let lines = reflow_line("This is some text with ::: class marker.", &options);
5755 for line in &lines {
5756 assert!(
5757 !line.trim_start().starts_with(":::"),
5758 "Wrapped line should not start with div marker: {line}"
5759 );
5760 }
5761 }
5762}