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 links: Vec<(usize, usize)>,
103}
104
105struct OpenSpan {
107 span: (usize, usize),
109 content: Option<(usize, usize)>,
112}
113
114fn note_span_content(open: &mut [OpenSpan], start: usize, end: usize) {
117 for open_span in open.iter_mut() {
118 if start >= open_span.span.0 && end <= open_span.span.1 {
119 open_span.content = Some(match open_span.content {
120 Some((known_start, known_end)) => (known_start.min(start), known_end.max(end)),
121 None => (start, end),
122 });
123 }
124 }
125}
126
127fn merge_ranges(mut ranges: Vec<(usize, usize)>) -> Vec<(usize, usize)> {
128 ranges.sort_unstable();
131 let mut merged: Vec<(usize, usize)> = Vec::with_capacity(ranges.len());
132 for (start, end) in ranges {
133 match merged.last_mut() {
134 Some(last) if start <= last.1 => last.1 = last.1.max(end),
135 _ => merged.push((start, end)),
136 }
137 }
138 merged
139}
140
141fn nested_structure(content: &str, defined_references: Option<&HashSet<String>>, attr_lists: bool) -> NestedStructure {
143 let mut options = Options::empty();
144 options.insert(Options::ENABLE_STRIKETHROUGH);
145
146 let mut atomic: Vec<(usize, usize)> = Vec::new();
147 let mut markers: Vec<(usize, usize)> = Vec::new();
148 let mut links: Vec<(usize, usize)> = Vec::new();
149 let mut open: Vec<OpenSpan> = Vec::new();
152
153 for (event, range) in Parser::new_ext(content, options).into_offset_iter() {
154 let (start, end) = (range.start, range.end);
155 if !matches!(event, Event::End(_)) {
159 note_span_content(&mut open, start, end);
160 }
161 match event {
162 Event::Start(Tag::Link { .. } | Tag::Image { .. }) => {
163 atomic.push((start, end));
164 links.push((start, end));
165 }
166 Event::Code(_) | Event::InlineHtml(_) => {
167 atomic.push((start, end));
168 }
169 Event::Start(Tag::Emphasis | Tag::Strong | Tag::Strikethrough) => {
170 open.push(OpenSpan {
171 span: (start, end),
172 content: None,
173 });
174 }
175 Event::End(TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough) => {
176 if let Some(OpenSpan {
177 span: (span_start, span_end),
178 content,
179 }) = open.pop()
180 {
181 match content {
182 Some((content_start, content_end)) => {
186 markers.push((span_start, content_start));
187 markers.push((content_end, span_end));
188 }
189 None => atomic.push((span_start, span_end)),
192 }
193 }
194 }
195 _ => {}
196 }
197 }
198
199 for span in all_link_spans(content, defined_references) {
206 atomic.push((span.start, span.end));
207 links.push((span.start, span.end));
208 }
209
210 for found in WIKI_LINK_REGEX.find_iter(content) {
214 atomic.push((found.start(), found.end()));
215 links.push((found.start(), found.end()));
216 }
217 for found in HUGO_SHORTCODE_REGEX
218 .find_iter(content)
219 .chain(DISPLAY_MATH_REGEX.find_iter(content))
220 {
221 atomic.push((found.start(), found.end()));
222 }
223 let mut from = 0;
224 while let Ok(Some(found)) = INLINE_MATH_REGEX.find_from_pos(content, from) {
225 atomic.push((found.start(), found.end()));
226 from = found.end();
227 }
228
229 if attr_lists {
235 for found in ATTR_LIST_PATTERN.find_iter(content) {
236 atomic.push((found.start(), found.end()));
237 }
238 }
239
240 links.sort_unstable();
243 links.dedup();
244
245 NestedStructure {
246 atomic: merge_ranges(atomic),
247 markers: merge_ranges(markers),
248 links,
249 }
250}
251
252fn breakable_units<'a>(
275 content: &'a str,
276 defined_references: Option<&HashSet<String>>,
277 attr_lists: bool,
278) -> Option<Vec<&'a str>> {
279 if !content.contains(['`', '*', '_', '~', '[', '<', '$', '{']) {
282 return Some(split_breakable_words(content).collect());
283 }
284
285 let NestedStructure { atomic, markers, .. } = nested_structure(content, defined_references, attr_lists);
286
287 let mut units = Vec::new();
288 let mut unit_start = None;
289 let mut next_atomic = 0;
290 let mut next_marker = 0;
291 for (offset, ch) in content.char_indices() {
292 while atomic.get(next_atomic).is_some_and(|&(_, end)| end <= offset) {
293 next_atomic += 1;
294 }
295 if atomic.get(next_atomic).is_some_and(|&(start, _)| offset >= start) {
296 if unit_start.is_none() {
299 unit_start = Some(offset);
300 }
301 continue;
302 }
303 while markers.get(next_marker).is_some_and(|&(_, end)| end <= offset) {
304 next_marker += 1;
305 }
306 if matches!(ch, '`' | '*' | '_' | '~') && markers.get(next_marker).is_none_or(|&(start, _)| offset < start) {
307 return None;
308 }
309 if is_breakable_whitespace(ch) {
310 if let Some(start) = unit_start.take() {
311 units.push(&content[start..offset]);
312 }
313 } else if unit_start.is_none() {
314 unit_start = Some(offset);
315 }
316 }
317 if let Some(start) = unit_start {
318 units.push(&content[start..]);
319 }
320 Some(units)
321}
322
323#[derive(Clone)]
325pub struct ReflowOptions {
326 pub line_length: usize,
328 pub break_on_sentences: bool,
330 pub preserve_breaks: bool,
332 pub sentence_per_line: bool,
334 pub semantic_line_breaks: bool,
336 pub abbreviations: Option<Vec<String>>,
340 pub length_mode: ReflowLengthMode,
342 pub attr_lists: bool,
345 pub myst_roles: bool,
349 pub require_sentence_capital: bool,
354 pub max_list_continuation_indent: Option<usize>,
358 pub defined_references: Option<HashSet<String>>,
372 pub atomic_spans: bool,
376 pub length_exemptions: LengthExemptions,
379}
380
381#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
389pub struct LengthExemptions {
390 pub link_urls: bool,
393 pub code_spans: bool,
395}
396
397impl LengthExemptions {
398 fn any(&self) -> bool {
401 self.link_urls || self.code_spans
402 }
403}
404
405impl Default for ReflowOptions {
406 fn default() -> Self {
407 Self {
408 line_length: 80,
409 break_on_sentences: true,
410 preserve_breaks: false,
411 sentence_per_line: false,
412 semantic_line_breaks: false,
413 abbreviations: None,
414 length_mode: ReflowLengthMode::default(),
415 attr_lists: false,
416 myst_roles: false,
417 require_sentence_capital: true,
418 max_list_continuation_indent: None,
419 defined_references: None,
420 atomic_spans: true,
421 length_exemptions: LengthExemptions::default(),
422 }
423 }
424}
425
426#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
433struct LineWidth {
434 link_exempt: usize,
436 code_exempt: usize,
438}
439
440impl LineWidth {
441 fn plain(width: usize) -> Self {
444 Self {
445 link_exempt: width,
446 code_exempt: width,
447 }
448 }
449
450 fn effective(self) -> usize {
453 self.link_exempt.min(self.code_exempt)
454 }
455
456 fn fits(self, line_length: usize) -> bool {
457 self.effective() <= line_length
458 }
459
460 fn is_empty(self) -> bool {
464 self.link_exempt == 0 && self.code_exempt == 0
465 }
466}
467
468impl std::ops::Add for LineWidth {
469 type Output = Self;
470
471 fn add(self, other: Self) -> Self {
472 Self {
473 link_exempt: self.link_exempt + other.link_exempt,
474 code_exempt: self.code_exempt + other.code_exempt,
475 }
476 }
477}
478
479impl std::ops::AddAssign for LineWidth {
480 fn add_assign(&mut self, other: Self) {
481 *self = *self + other;
482 }
483}
484
485pub fn normalize_reference_label(label: &str) -> String {
492 label.split_whitespace().collect::<Vec<_>>().join(" ").to_lowercase()
493}
494
495fn footnote_refs_end(chars: &[char], start: usize) -> Option<usize> {
501 let mut pos = start;
502 let mut found = false;
503
504 loop {
505 if chars.get(pos) != Some(&'[') || chars.get(pos + 1) != Some(&'^') {
506 break;
507 }
508 let label_start = pos + 2;
509 let mut label_end = label_start;
510 while matches!(chars.get(label_end), Some(c) if c.is_ascii_alphanumeric() || *c == '_' || *c == '-') {
511 label_end += 1;
512 }
513 if label_end == label_start || chars.get(label_end) != Some(&']') {
514 break;
515 }
516 pos = label_end + 1;
517 found = true;
518 }
519
520 found.then_some(pos)
521}
522
523fn char_byte_offsets(chars: &[char]) -> Vec<usize> {
526 let mut offsets = Vec::with_capacity(chars.len() + 1);
527 let mut offset = 0;
528 for c in chars {
529 offsets.push(offset);
530 offset += c.len_utf8();
531 }
532 offsets.push(offset);
533 offsets
534}
535
536struct SentenceText<'a> {
541 text: &'a str,
542 chars: &'a [char],
543 char_offsets: &'a [usize],
544 links: &'a [(usize, usize)],
545}
546
547impl SentenceText<'_> {
548 fn link_end_at(&self, pos: usize) -> Option<usize> {
557 let range_start = match self.chars.get(pos) {
558 Some('[') => pos,
559 Some('!') if self.chars.get(pos + 1) == Some(&'[') => match self.link_range_end_at(pos) {
560 Some(end) => return Some(end),
561 None => pos + 1,
562 },
563 _ => return None,
564 };
565 self.link_range_end_at(range_start)
566 }
567
568 fn link_range_end_at(&self, pos: usize) -> Option<usize> {
570 let start = self.char_offsets[pos];
571 let idx = self.links.binary_search_by_key(&start, |&(s, _)| s).ok()?;
572 let end = self.links[idx].1;
573 Some(self.char_offsets.binary_search(&end).unwrap_or_else(|i| i))
574 }
575}
576
577fn is_sentence_boundary(
581 st: &SentenceText<'_>,
582 pos: usize,
583 abbreviations: &HashSet<String>,
584 require_sentence_capital: bool,
585) -> bool {
586 let SentenceText { text, chars, .. } = *st;
587 if pos + 1 >= chars.len() {
588 return false;
589 }
590 let byte_offset_after_punct = st.char_offsets[pos + 1];
591
592 let c = chars[pos];
593 let next_char = chars[pos + 1];
594
595 if is_cjk_sentence_ending(c) {
598 let mut after_punct_pos = pos + 1;
600 while after_punct_pos < chars.len()
601 && (chars[after_punct_pos] == '*' || chars[after_punct_pos] == '_' || chars[after_punct_pos] == '~')
602 {
603 after_punct_pos += 1;
604 }
605
606 while after_punct_pos < chars.len() && chars[after_punct_pos].is_whitespace() {
608 after_punct_pos += 1;
609 }
610
611 if after_punct_pos >= chars.len() {
613 return false;
614 }
615
616 if opens_ordered_list_marker(&chars[after_punct_pos..]) {
619 return false;
620 }
621
622 while after_punct_pos < chars.len()
624 && (chars[after_punct_pos] == '*' || chars[after_punct_pos] == '_' || chars[after_punct_pos] == '~')
625 {
626 after_punct_pos += 1;
627 }
628
629 if after_punct_pos >= chars.len() {
630 return false;
631 }
632
633 return true;
636 }
637
638 if c != '.' && c != '!' && c != '?' {
640 return false;
641 }
642
643 let inside_quotation = is_closing_quote(next_char);
646
647 let (_space_pos, after_space_pos) = if next_char == ' ' {
649 (pos + 1, pos + 2)
651 } else if is_closing_quote(next_char) && pos + 2 < chars.len() {
652 if chars[pos + 2] == ' ' {
654 (pos + 2, pos + 3)
656 } else if (chars[pos + 2] == '*' || chars[pos + 2] == '_') && pos + 3 < chars.len() && chars[pos + 3] == ' ' {
657 (pos + 3, pos + 4)
659 } else if (chars[pos + 2] == '*' || chars[pos + 2] == '_')
660 && pos + 4 < chars.len()
661 && chars[pos + 3] == chars[pos + 2]
662 && chars[pos + 4] == ' '
663 {
664 (pos + 4, pos + 5)
666 } else {
667 return false;
668 }
669 } else if (next_char == '*' || next_char == '_') && pos + 2 < chars.len() && chars[pos + 2] == ' ' {
670 (pos + 2, pos + 3)
672 } else if (next_char == '*' || next_char == '_')
673 && pos + 3 < chars.len()
674 && chars[pos + 2] == next_char
675 && chars[pos + 3] == ' '
676 {
677 (pos + 3, pos + 4)
679 } else if next_char == '~' && pos + 3 < chars.len() && chars[pos + 2] == '~' && chars[pos + 3] == ' ' {
680 (pos + 3, pos + 4)
682 } else if next_char == '[' {
683 match footnote_refs_end(chars, pos + 1) {
689 Some(end_pos) if chars.get(end_pos) == Some(&' ') => (end_pos, end_pos + 1),
690 _ => return false,
691 }
692 } else {
693 return false;
694 };
695
696 let mut next_char_pos = after_space_pos;
698 while next_char_pos < chars.len() && chars[next_char_pos].is_whitespace() {
699 next_char_pos += 1;
700 }
701
702 if next_char_pos >= chars.len() {
704 return false;
705 }
706
707 if opens_ordered_list_marker(&chars[next_char_pos..]) {
716 return false;
717 }
718
719 let mut first_letter_pos = next_char_pos;
725 while first_letter_pos < chars.len() {
726 let ch = chars[first_letter_pos];
727 if let Some(end) = st.link_end_at(first_letter_pos) {
728 first_letter_pos += link_opener_len(chars, first_letter_pos, end);
729 } else if matches!(ch, '*' | '_' | '~') || is_opening_quote(ch) {
730 first_letter_pos += 1;
731 } else {
732 break;
733 }
734 }
735
736 if first_letter_pos >= chars.len() {
738 return false;
739 }
740
741 let first_char = chars[first_letter_pos];
742
743 if c == '!' || c == '?' {
749 return !inside_quotation || !require_sentence_capital || opens_sentence_in_strict_mode(first_char);
750 }
751
752 if pos > 0 {
758 if text_ends_with_abbreviation(&text[..byte_offset_after_punct], abbreviations) {
760 return false;
761 }
762
763 if chars[pos - 1].is_ascii_uppercase() && (pos == 1 || (pos >= 2 && chars[pos - 2].is_whitespace())) {
767 return false;
768 }
769 }
770
771 if require_sentence_capital && !opens_sentence_in_strict_mode(first_char) {
774 return false;
775 }
776
777 true
778}
779
780fn opens_sentence_in_strict_mode(first_char: char) -> bool {
788 first_char.is_uppercase() || first_char.is_numeric() || is_cjk_char(first_char)
789}
790
791fn opens_ordered_list_marker(chars: &[char]) -> bool {
797 let digits = chars.iter().take_while(|c| c.is_ascii_digit()).count();
798 digits > 0 && matches!(chars.get(digits), Some('.' | ')')) && matches!(chars.get(digits + 1), Some(' ' | '\t'))
799}
800
801fn link_opener_len(chars: &[char], pos: usize, end: usize) -> usize {
807 let open = if chars[pos] == '!' { pos + 1 } else { pos };
808 let body = open + 1;
809 if chars.get(body) != Some(&'[') {
810 return body - pos;
811 }
812 let body = body + 1;
813 let alias = chars[body..end.saturating_sub(2).max(body)]
814 .iter()
815 .position(|&c| c == '|')
816 .map_or(body, |p| body + p + 1);
817 alias - pos
818}
819
820pub fn split_into_sentences(text: &str, defined_references: Option<&HashSet<String>>) -> Vec<String> {
828 let abbreviations = get_abbreviations(&None);
829 split_into_sentences_with_set(text, &abbreviations, true, None, defined_references)
830}
831
832fn split_into_sentences_with_set(
842 text: &str,
843 abbreviations: &HashSet<String>,
844 require_sentence_capital: bool,
845 appended_span_start: Option<usize>,
846 defined_references: Option<&HashSet<String>>,
847) -> Vec<String> {
848 let char_vec: Vec<char> = text.chars().collect();
849 let char_offsets = char_byte_offsets(&char_vec);
850
851 let NestedStructure { atomic, links, .. } = sentence_structure(text, defined_references);
854 let mut atomic_it = atomic.iter().peekable();
855 let st = SentenceText {
856 text,
857 chars: &char_vec,
858 char_offsets: &char_offsets,
859 links: &links,
860 };
861
862 let mut sentences = Vec::new();
863 let mut current_sentence = String::new();
864 let mut pos = 0;
865
866 while pos < char_vec.len() {
867 let c = char_vec[pos];
868 current_sentence.push(c);
869
870 let byte_idx = char_offsets[pos];
871
872 while let Some(&&(_, end)) = atomic_it.peek() {
874 if end <= byte_idx {
875 atomic_it.next();
876 } else {
877 break;
878 }
879 }
880
881 let in_atomic = atomic_it
883 .peek()
884 .is_some_and(|&&(start, end)| byte_idx >= start && byte_idx < end);
885
886 if !in_atomic && is_sentence_boundary(&st, pos, abbreviations, require_sentence_capital) {
887 if let Some(end_pos) = footnote_refs_end(&char_vec, pos + 1) {
889 while pos + 1 < end_pos {
890 pos += 1;
891 current_sentence.push(char_vec[pos]);
892 }
893 }
894
895 while pos + 1 < char_vec.len() {
897 let next = char_vec[pos + 1];
898 if matches!(next, '*' | '_' | '~') && Some(char_offsets[pos + 1]) == appended_span_start {
899 break;
900 }
901 if next == '*' || next == '_' || next == '~' || is_closing_quote(next) {
902 pos += 1;
903 current_sentence.push(char_vec[pos]);
904 } else {
905 break;
906 }
907 }
908
909 if pos + 1 < char_vec.len() && char_vec[pos + 1] == ' ' {
911 pos += 1; }
913
914 sentences.push(current_sentence.trim().to_string());
915 current_sentence.clear();
916 }
917
918 pos += 1;
919 }
920
921 if !current_sentence.trim().is_empty() {
923 sentences.push(current_sentence.trim().to_string());
924 }
925 sentences
926}
927
928fn sentence_structure(text: &str, defined_references: Option<&HashSet<String>>) -> NestedStructure {
944 if !text.contains(['`', '[', '<', '$']) {
947 return NestedStructure {
948 atomic: Vec::new(),
949 markers: Vec::new(),
950 links: Vec::new(),
951 };
952 }
953 nested_structure(text, defined_references, false)
954}
955
956fn is_horizontal_rule(line: &str) -> bool {
958 if line.len() < 3 {
959 return false;
960 }
961
962 let mut chars = line.chars();
965 let Some(first_char) = chars.next() else {
966 return false;
967 };
968 if first_char != '-' && first_char != '_' && first_char != '*' {
969 return false;
970 }
971
972 let mut non_space_count = 1usize; for c in chars {
974 if c == ' ' {
975 continue;
976 }
977 if c != first_char {
978 return false;
979 }
980 non_space_count += 1;
981 }
982 non_space_count >= 3
983}
984
985fn is_numbered_list_item(line: &str) -> bool {
987 let mut chars = line.chars();
988
989 if !chars.next().is_some_and(char::is_numeric) {
991 return false;
992 }
993
994 while let Some(c) = chars.next() {
996 if c == '.' {
997 return chars.next() == Some(' ');
1000 }
1001 if !c.is_numeric() {
1002 return false;
1003 }
1004 }
1005
1006 false
1007}
1008
1009fn is_unordered_list_marker(s: &str) -> bool {
1011 matches!(s.as_bytes().first(), Some(b'-' | b'*' | b'+'))
1012 && !is_horizontal_rule(s)
1013 && (s.len() == 1 || s.as_bytes().get(1) == Some(&b' '))
1014}
1015
1016fn is_block_boundary_core(trimmed: &str) -> bool {
1019 trimmed.is_empty()
1020 || trimmed.starts_with('#')
1021 || trimmed.starts_with("```")
1022 || trimmed.starts_with("~~~")
1023 || trimmed.starts_with('>')
1024 || (trimmed.starts_with('[') && trimmed.contains("]:"))
1025 || is_horizontal_rule(trimmed)
1026 || is_unordered_list_marker(trimmed)
1027 || is_numbered_list_item(trimmed)
1028 || is_definition_list_item(trimmed)
1029 || trimmed.starts_with(":::")
1030}
1031
1032fn is_block_boundary(trimmed: &str) -> bool {
1035 is_block_boundary_core(trimmed) || trimmed.starts_with('|')
1036}
1037
1038fn is_paragraph_boundary(trimmed: &str, line: &str) -> bool {
1042 is_block_boundary_core(trimmed)
1043 || calculate_indentation_width_default(line) >= 4
1044 || crate::utils::table_utils::TableUtils::is_potential_table_row(line)
1045}
1046
1047fn has_hard_break(line: &str) -> bool {
1053 let line = line.strip_suffix('\r').unwrap_or(line);
1054 line.ends_with(" ") || line.ends_with('\\')
1055}
1056
1057fn ends_with_sentence_punct(text: &str) -> bool {
1059 text.ends_with('.') || text.ends_with('!') || text.ends_with('?')
1060}
1061
1062fn trim_preserving_hard_break(s: &str) -> String {
1068 let s = s.strip_suffix('\r').unwrap_or(s);
1070
1071 if s.ends_with('\\') {
1073 return s.to_string();
1075 }
1076
1077 if s.ends_with(" ") {
1079 let content_end = s.trim_end().len();
1081 if content_end == 0 {
1082 return String::new();
1084 }
1085 format!("{} ", &s[..content_end])
1087 } else {
1088 s.trim_end().to_string()
1090 }
1091}
1092
1093fn parse_elements(text: &str, options: &ReflowOptions) -> Vec<Element> {
1095 parse_markdown_elements_inner(
1096 text,
1097 options.attr_lists,
1098 options.myst_roles,
1099 options.defined_references.as_ref(),
1100 )
1101}
1102
1103pub fn reflow_line(line: &str, options: &ReflowOptions) -> Vec<String> {
1113 let reflowed = reflow_line_unchecked(line, options);
1114 if preserves_content(line, &reflowed) {
1115 reflowed
1116 } else {
1117 vec![line.to_string()]
1118 }
1119}
1120
1121fn preserves_content(original: &str, reflowed: &[String]) -> bool {
1128 let (original_text, original_breaks) = visible_text_and_breaks(original.chars());
1129 let (reflowed_text, reflowed_breaks) =
1130 visible_text_and_breaks(reflowed.iter().flat_map(|line| line.chars().chain(['\n'])));
1131
1132 original_text == reflowed_text && contains_all(&reflowed_breaks, &original_breaks)
1133}
1134
1135fn visible_text_and_breaks(text: impl Iterator<Item = char>) -> (String, Vec<usize>) {
1138 let mut visible = String::new();
1139 let mut breaks = Vec::new();
1140 let mut count = 0usize;
1141 let mut pending_break = false;
1142
1143 for c in text {
1144 if c.is_whitespace() {
1145 pending_break = count > 0;
1146 } else {
1147 if pending_break {
1148 breaks.push(count);
1149 pending_break = false;
1150 }
1151 visible.push(c);
1152 count += 1;
1153 }
1154 }
1155
1156 (visible, breaks)
1157}
1158
1159fn contains_all(superset: &[usize], subset: &[usize]) -> bool {
1161 let mut candidates = superset.iter();
1162 subset
1163 .iter()
1164 .all(|wanted| candidates.by_ref().any(|found| found == wanted))
1165}
1166
1167fn reflow_line_unchecked(line: &str, options: &ReflowOptions) -> Vec<String> {
1168 if options.sentence_per_line {
1170 let elements = parse_elements(line, options);
1171 return merge_block_construct_continuations(reflow_elements_sentence_per_line(&elements, options));
1172 }
1173
1174 if options.semantic_line_breaks {
1176 let elements = parse_elements(line, options);
1177 return merge_block_construct_continuations(reflow_elements_semantic(&elements, options));
1178 }
1179
1180 if options.line_length == 0 || line_fits(line, options) {
1183 return vec![line.to_string()];
1184 }
1185
1186 let elements = parse_elements(line, options);
1188
1189 merge_block_construct_continuations(reflow_elements(&elements, options))
1191}
1192
1193#[derive(Debug, Clone)]
1195enum Element {
1196 Text(String),
1198 Link(String),
1200 ReferenceLink(String),
1202 EmptyReferenceLink(String),
1204 ShortcutReference(String),
1206 InlineImage(String),
1208 ReferenceImage(String),
1210 EmptyReferenceImage(String),
1212 LinkedImage(String),
1214 FootnoteReference(String),
1216 Strikethrough {
1218 content: String,
1219 double: bool,
1221 },
1222 WikiLink(String),
1224 InlineMath(String),
1226 DisplayMath(String),
1228 EmojiShortcode(String),
1230 Autolink(String),
1232 HtmlTag(String),
1234 HtmlEntity(String),
1236 HugoShortcode(String),
1238 AttrList(String),
1240 MystRole(String),
1244 Code { content: String, marker: String },
1246 Bold {
1248 content: String,
1249 underscore: bool,
1251 },
1252 Italic {
1254 content: String,
1255 underscore: bool,
1257 },
1258}
1259
1260impl Element {
1261 fn opens_with_bracket(&self) -> bool {
1266 matches!(
1267 self,
1268 Element::Link(_)
1269 | Element::ReferenceLink(_)
1270 | Element::EmptyReferenceLink(_)
1271 | Element::ShortcutReference(_)
1272 | Element::FootnoteReference(_)
1273 | Element::InlineImage(_)
1274 | Element::ReferenceImage(_)
1275 | Element::EmptyReferenceImage(_)
1276 | Element::LinkedImage(_)
1277 | Element::WikiLink(_)
1278 )
1279 }
1280}
1281
1282impl std::fmt::Display for Element {
1283 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1284 match self {
1285 Element::Text(s) => write!(f, "{s}"),
1286 Element::Link(s) => write!(f, "{s}"),
1287 Element::ReferenceLink(s) => write!(f, "{s}"),
1288 Element::EmptyReferenceLink(s) => write!(f, "{s}"),
1289 Element::ShortcutReference(s) => write!(f, "{s}"),
1290 Element::InlineImage(s) => write!(f, "{s}"),
1291 Element::ReferenceImage(s) => write!(f, "{s}"),
1292 Element::EmptyReferenceImage(s) => write!(f, "{s}"),
1293 Element::LinkedImage(s) => write!(f, "{s}"),
1294 Element::FootnoteReference(s) => write!(f, "{s}"),
1295 Element::Strikethrough { content, double } => {
1296 let marker = if *double { "~~" } else { "~" };
1297 write!(f, "{marker}{content}{marker}")
1298 }
1299 Element::WikiLink(s) => write!(f, "[[{s}]]"),
1300 Element::InlineMath(s) => write!(f, "${s}$"),
1301 Element::DisplayMath(s) => write!(f, "$${s}$$"),
1302 Element::EmojiShortcode(s) => write!(f, ":{s}:"),
1303 Element::Autolink(s) => write!(f, "{s}"),
1304 Element::HtmlTag(s) => write!(f, "{s}"),
1305 Element::HtmlEntity(s) => write!(f, "{s}"),
1306 Element::HugoShortcode(s) => write!(f, "{s}"),
1307 Element::AttrList(s) => write!(f, "{s}"),
1308 Element::MystRole(s) => write!(f, "{s}"),
1309 Element::Code { content, marker } => write!(f, "{marker}{content}{marker}"),
1310 Element::Bold { content, underscore } => {
1311 if *underscore {
1312 write!(f, "__{content}__")
1313 } else {
1314 write!(f, "**{content}**")
1315 }
1316 }
1317 Element::Italic { content, underscore } => {
1318 if *underscore {
1319 write!(f, "_{content}_")
1320 } else {
1321 write!(f, "*{content}*")
1322 }
1323 }
1324 }
1325 }
1326}
1327
1328impl Element {
1329 fn display_len(&self, mode: ReflowLengthMode) -> usize {
1330 match self {
1331 Element::Text(s)
1332 | Element::Link(s)
1333 | Element::ReferenceLink(s)
1334 | Element::EmptyReferenceLink(s)
1335 | Element::ShortcutReference(s)
1336 | Element::InlineImage(s)
1337 | Element::ReferenceImage(s)
1338 | Element::EmptyReferenceImage(s)
1339 | Element::LinkedImage(s)
1340 | Element::FootnoteReference(s)
1341 | Element::Autolink(s)
1342 | Element::HtmlTag(s)
1343 | Element::HtmlEntity(s)
1344 | Element::HugoShortcode(s)
1345 | Element::AttrList(s)
1346 | Element::MystRole(s) => display_len(s, mode),
1347 Element::WikiLink(s) => display_len(s, mode) + 4,
1348 Element::InlineMath(s) => display_len(s, mode) + 2,
1349 Element::DisplayMath(s) => display_len(s, mode) + 4,
1350 Element::EmojiShortcode(s) => display_len(s, mode) + 2,
1351 Element::Strikethrough { content, double } => display_len(content, mode) + if *double { 4 } else { 2 },
1352 Element::Code { content, marker } => display_len(content, mode) + display_len(marker, mode) * 2,
1353 Element::Bold { content, .. } => display_len(content, mode) + 4,
1354 Element::Italic { content, .. } => display_len(content, mode) + 2,
1355 }
1356 }
1357
1358 fn exempt_width(&self, mode: ReflowLengthMode, exemptions: LengthExemptions) -> LineWidth {
1369 let full = self.display_len(mode);
1370 let mut width = LineWidth::plain(full);
1371 match self {
1372 Element::Link(s) | Element::LinkedImage(s) if exemptions.link_urls => {
1373 if let Some(text) = bracketed_text(s, 0) {
1374 width.link_exempt = (2 + display_len(text, mode)).min(full);
1375 }
1376 }
1377 Element::InlineImage(s) if exemptions.link_urls => {
1378 if let Some(alt) = bracketed_text(s, 1) {
1379 width.link_exempt = (3 + display_len(alt, mode)).min(full);
1380 }
1381 }
1382 Element::Code { .. } if exemptions.code_spans => width.code_exempt = 0,
1383 _ => {}
1384 }
1385 width
1386 }
1387}
1388
1389fn bracketed_text(s: &str, open: usize) -> Option<&str> {
1396 let bytes = s.as_bytes();
1397 if bytes.get(open) != Some(&b'[') {
1398 return None;
1399 }
1400 let mut depth = 0usize;
1401 let mut in_code_span = false;
1402 let mut escaped = false;
1403 for (i, &byte) in bytes.iter().enumerate().skip(open + 1) {
1404 if escaped {
1405 escaped = false;
1406 continue;
1407 }
1408 match byte {
1409 b'\\' => escaped = true,
1410 b'`' => in_code_span = !in_code_span,
1411 b'[' if !in_code_span => depth += 1,
1412 b']' if !in_code_span => match depth.checked_sub(1) {
1413 Some(next) => depth = next,
1414 None => return s.get(open + 1..i),
1415 },
1416 _ => {}
1417 }
1418 }
1419 None
1420}
1421
1422#[derive(Debug, Clone)]
1424struct EmphasisSpan {
1425 start: usize,
1427 end: usize,
1429 content: String,
1431 is_strong: bool,
1433 is_strikethrough: bool,
1435 uses_underscore: bool,
1437 strikethrough_double: bool,
1440}
1441
1442fn extract_emphasis_and_code_spans(text: &str) -> (Vec<EmphasisSpan>, Vec<CodeSpan>) {
1452 let has_emphasis = text.contains(['*', '_', '~']);
1454 let has_code = text.contains('`');
1455 if !has_emphasis && !has_code {
1456 return (Vec::new(), Vec::new());
1457 }
1458
1459 let mut emphasis_spans = Vec::new();
1460 let mut code_spans = Vec::new();
1461
1462 let mut options = Options::empty();
1463 if has_emphasis {
1464 options.insert(Options::ENABLE_STRIKETHROUGH);
1465 }
1466
1467 let mut emphasis_stack: Vec<(usize, bool)> = Vec::new(); let mut strong_stack: Vec<(usize, bool)> = Vec::new();
1470 let mut strikethrough_stack: Vec<usize> = Vec::new();
1471
1472 let parser = Parser::new_ext(text, options).into_offset_iter();
1473
1474 for (event, range) in parser {
1475 match event {
1476 Event::Code(_) => {
1477 code_spans.push(CodeSpan {
1478 start: range.start,
1479 end: range.end,
1480 });
1481 }
1482 Event::Start(Tag::Emphasis) => {
1483 let uses_underscore = text.get(range.start..range.start + 1) == Some("_");
1485 emphasis_stack.push((range.start, uses_underscore));
1486 }
1487 Event::End(TagEnd::Emphasis) => {
1488 if let Some((start_byte, uses_underscore)) = emphasis_stack.pop() {
1489 let content_start = start_byte + 1;
1490 let content_end = range.end - 1;
1491 if content_end > content_start
1492 && let Some(content) = text.get(content_start..content_end)
1493 {
1494 emphasis_spans.push(EmphasisSpan {
1495 start: start_byte,
1496 end: range.end,
1497 content: content.to_string(),
1498 is_strong: false,
1499 is_strikethrough: false,
1500 uses_underscore,
1501 strikethrough_double: false,
1502 });
1503 }
1504 }
1505 }
1506 Event::Start(Tag::Strong) => {
1507 let uses_underscore = text.get(range.start..range.start + 2) == Some("__");
1508 strong_stack.push((range.start, uses_underscore));
1509 }
1510 Event::End(TagEnd::Strong) => {
1511 if let Some((start_byte, uses_underscore)) = strong_stack.pop() {
1512 let content_start = start_byte + 2;
1513 let content_end = range.end - 2;
1514 if content_end > content_start
1515 && let Some(content) = text.get(content_start..content_end)
1516 {
1517 emphasis_spans.push(EmphasisSpan {
1518 start: start_byte,
1519 end: range.end,
1520 content: content.to_string(),
1521 is_strong: true,
1522 is_strikethrough: false,
1523 uses_underscore,
1524 strikethrough_double: false,
1525 });
1526 }
1527 }
1528 }
1529 Event::Start(Tag::Strikethrough) => {
1530 strikethrough_stack.push(range.start);
1531 }
1532 Event::End(TagEnd::Strikethrough) => {
1533 if let Some(start_byte) = strikethrough_stack.pop() {
1534 let double = text.get(start_byte..start_byte + 2) == Some("~~");
1535 let marker_len = if double { 2 } else { 1 };
1536 let content_start = start_byte + marker_len;
1537 let content_end = range.end - marker_len;
1538 if content_end > content_start
1539 && let Some(content) = text.get(content_start..content_end)
1540 {
1541 emphasis_spans.push(EmphasisSpan {
1542 start: start_byte,
1543 end: range.end,
1544 content: content.to_string(),
1545 is_strong: false,
1546 is_strikethrough: true,
1547 uses_underscore: false,
1548 strikethrough_double: double,
1549 });
1550 }
1551 }
1552 }
1553 _ => {}
1554 }
1555 }
1556
1557 emphasis_spans.sort_by_key(|s| s.start);
1558 (emphasis_spans, code_spans)
1559}
1560
1561#[derive(Debug, Clone)]
1562struct CodeSpan {
1563 start: usize,
1564 end: usize,
1565}
1566
1567#[derive(Debug, Clone)]
1568struct LinkSpan {
1569 start: usize,
1570 end: usize,
1571 link_type: Option<LinkType>,
1572 is_image: bool,
1573 is_footnote: bool,
1574 depth: usize,
1577}
1578
1579fn extract_link_spans(text: &str, defined_references: Option<&HashSet<String>>) -> Vec<LinkSpan> {
1583 let mut spans = all_link_spans(text, defined_references);
1584 spans.retain(|span| span.depth == 0);
1585 spans
1586}
1587
1588fn all_link_spans(text: &str, defined_references: Option<&HashSet<String>>) -> Vec<LinkSpan> {
1591 if !text.contains('[') {
1594 return Vec::new();
1595 }
1596
1597 let mut spans = Vec::new();
1598 let mut options = Options::empty();
1599 options.insert(Options::ENABLE_FOOTNOTES);
1600
1601 let resolve = move |link: BrokenLink<'_>| -> Option<(CowStr<'_>, CowStr<'_>)> {
1618 let atomic = match link.link_type {
1623 LinkType::Shortcut | LinkType::ShortcutUnknown => match defined_references {
1624 Some(defs) => defs.contains(&normalize_reference_label(link.reference.as_ref())),
1625 None => true,
1626 },
1627 _ => true,
1628 };
1629 atomic.then_some((CowStr::Borrowed(""), CowStr::Borrowed("")))
1630 };
1631 let parser = Parser::new_with_broken_link_callback(text, options, Some(resolve)).into_offset_iter();
1632 let mut stack = Vec::new();
1633
1634 for (event, range) in parser {
1635 match event {
1636 Event::Start(Tag::Link { link_type, .. }) => {
1637 stack.push((range.start, Some(link_type), false));
1638 }
1639 Event::Start(Tag::Image { link_type, .. }) => {
1640 stack.push((range.start, Some(link_type), true));
1641 }
1642 Event::End(TagEnd::Link | TagEnd::Image) => {
1643 if let Some((start_byte, link_type, is_image)) = stack.pop() {
1644 spans.push(LinkSpan {
1645 start: start_byte,
1646 end: range.end,
1647 link_type,
1648 is_image,
1649 is_footnote: false,
1650 depth: stack.len(),
1651 });
1652 }
1653 }
1654 Event::FootnoteReference(_) => {
1655 spans.push(LinkSpan {
1656 start: range.start,
1657 end: range.end,
1658 link_type: None,
1659 is_image: false,
1660 is_footnote: true,
1661 depth: stack.len(),
1662 });
1663 }
1664 _ => {}
1665 }
1666 }
1667
1668 spans.sort_by_key(|s| s.start);
1669 spans
1670}
1671
1672fn myst_role_len_at(text: &str, absolute_pos: usize, code_spans: &[CodeSpan]) -> Option<usize> {
1680 let bytes = text.as_bytes();
1681 if bytes.first() != Some(&b'{') {
1682 return None;
1683 }
1684
1685 let mut j = 1;
1687 match bytes.get(j) {
1688 Some(&b) if b.is_ascii_alphabetic() || b == b'_' => {}
1689 _ => return None,
1690 }
1691 while let Some(&b) = bytes.get(j) {
1692 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b':' | b'.') {
1693 j += 1;
1694 } else {
1695 break;
1696 }
1697 }
1698 if bytes.get(j) != Some(&b'}') {
1699 return None;
1700 }
1701 j += 1; let code_span_start = absolute_pos + j;
1705 if let Ok(idx) = code_spans.binary_search_by_key(&code_span_start, |span| span.start) {
1706 let span = &code_spans[idx];
1707 let code_span_len = span.end - span.start;
1708 return Some(j + code_span_len);
1709 }
1710
1711 None
1712}
1713
1714fn inline_math_len_at_start(s: &str) -> Option<usize> {
1721 let bytes = s.as_bytes();
1722 if bytes.first() != Some(&b'$') || bytes.get(1) == Some(&b'$') {
1724 return None;
1725 }
1726 let close = 1 + s[1..].find('$')?;
1729 if bytes.get(close + 1) == Some(&b'$') {
1731 return None;
1732 }
1733 Some(close + 1)
1734}
1735
1736#[derive(Clone, Copy, Debug)]
1738struct PatternMatch {
1739 start: usize,
1740 end: usize,
1741}
1742
1743#[derive(Clone, Copy)]
1757enum PatternCache {
1758 Unsearched,
1759 NotFound,
1760 Found(PatternMatch),
1761}
1762
1763impl PatternCache {
1764 fn earliest_in(
1768 &mut self,
1769 remaining: &str,
1770 cursor: usize,
1771 find: impl FnOnce(&str) -> Option<(usize, usize)>,
1772 ) -> Option<(usize, usize)> {
1773 let stale = match self {
1774 PatternCache::Found(pm) => pm.start < cursor,
1775 PatternCache::NotFound => false,
1776 PatternCache::Unsearched => true,
1777 };
1778 if stale {
1779 *self = match find(remaining) {
1780 Some((start, end)) => PatternCache::Found(PatternMatch {
1781 start: cursor + start,
1782 end: cursor + end,
1783 }),
1784 None => PatternCache::NotFound,
1785 };
1786 }
1787 match self {
1788 PatternCache::Found(pm) => Some((pm.start - cursor, pm.end - cursor)),
1789 _ => None,
1790 }
1791 }
1792}
1793
1794fn parse_markdown_elements_inner(
1805 text: &str,
1806 attr_lists: bool,
1807 myst_roles: bool,
1808 defined_references: Option<&HashSet<String>>,
1809) -> Vec<Element> {
1810 let mut elements = Vec::new();
1811 let mut remaining = text;
1812
1813 let (emphasis_spans, code_spans) = extract_emphasis_and_code_spans(text);
1818 let link_spans = extract_link_spans(text, defined_references);
1819
1820 let mut cached_wiki_link = PatternCache::Unsearched;
1823 let mut cached_display_math = PatternCache::Unsearched;
1824 let mut cached_inline_math = PatternCache::Unsearched;
1825 let mut cached_emoji = PatternCache::Unsearched;
1826 let mut cached_html_entity = PatternCache::Unsearched;
1827 let mut cached_hugo_shortcode = PatternCache::Unsearched;
1828 let mut cached_html_tag = PatternCache::Unsearched;
1829 let mut cached_next_curly = PatternCache::Unsearched;
1830
1831 let mut link_span_idx = 0usize;
1835 let mut emphasis_span_idx = 0usize;
1836 let mut code_span_idx = 0usize;
1837
1838 while !remaining.is_empty() {
1839 let current_offset = text.len() - remaining.len();
1841 let mut earliest_match: Option<(usize, usize, &str)> = None;
1844
1845 while link_span_idx < link_spans.len() && link_spans[link_span_idx].start < current_offset {
1847 link_span_idx += 1;
1848 }
1849 let next_link: Option<&LinkSpan> = link_spans.get(link_span_idx);
1850
1851 if let Some(span) = next_link {
1852 let pos_in_remaining = span.start - current_offset;
1853 if earliest_match
1854 .as_ref()
1855 .is_none_or(|(start, _, _)| pos_in_remaining < *start)
1856 {
1857 let match_end = span.end - current_offset;
1858 earliest_match = Some((pos_in_remaining, match_end, "link_span"));
1859 }
1860 }
1861
1862 if let Some((start, end)) = cached_wiki_link.earliest_in(remaining, current_offset, |suffix| {
1864 WIKI_LINK_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1865 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1866 {
1867 earliest_match = Some((start, end, "wiki_link"));
1868 }
1869
1870 if let Some((start, end)) = cached_display_math.earliest_in(remaining, current_offset, |suffix| {
1872 DISPLAY_MATH_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1873 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1874 {
1875 earliest_match = Some((start, end, "display_math"));
1876 }
1877
1878 let inline_math_probe = if current_offset > 0 && text.as_bytes()[current_offset - 1] == b'$' {
1892 inline_math_len_at_start(remaining).map(|len| (0, len))
1893 } else {
1894 None
1895 };
1896 if let Some((start, end)) = inline_math_probe.or_else(|| {
1897 cached_inline_math.earliest_in(remaining, current_offset, |suffix| {
1898 INLINE_MATH_REGEX
1899 .find(suffix)
1900 .ok()
1901 .flatten()
1902 .map(|m| (m.start(), m.end()))
1903 })
1904 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1905 {
1906 earliest_match = Some((start, end, "inline_math"));
1907 }
1908
1909 if let Some((start, end)) = cached_emoji.earliest_in(remaining, current_offset, |suffix| {
1911 EMOJI_SHORTCODE_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1912 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1913 {
1914 earliest_match = Some((start, end, "emoji"));
1915 }
1916
1917 if let Some((start, end)) = cached_html_entity.earliest_in(remaining, current_offset, |suffix| {
1919 HTML_ENTITY_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1920 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1921 {
1922 earliest_match = Some((start, end, "html_entity"));
1923 }
1924
1925 if let Some((start, end)) = cached_hugo_shortcode.earliest_in(remaining, current_offset, |suffix| {
1928 HUGO_SHORTCODE_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1929 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1930 {
1931 earliest_match = Some((start, end, "hugo_shortcode"));
1932 }
1933
1934 if let Some((start, end)) = cached_html_tag.earliest_in(remaining, current_offset, |suffix| {
1941 let mut from = 0;
1942 while let Some(m) = HTML_TAG_PATTERN.find(&suffix[from..]) {
1943 let (tag_start, tag_end) = (from + m.start(), from + m.end());
1944 let tag = &suffix[tag_start..tag_end];
1945 let is_url_autolink = tag.starts_with("<http://")
1947 || tag.starts_with("<https://")
1948 || tag.starts_with("<mailto:")
1949 || tag.starts_with("<ftp://")
1950 || tag.starts_with("<ftps://");
1951 let is_email_autolink = {
1954 let content = tag.trim_start_matches('<').trim_end_matches('>');
1955 EMAIL_PATTERN.is_match(content)
1956 };
1957 if is_url_autolink || is_email_autolink {
1958 from = tag_end;
1959 } else {
1960 return Some((tag_start, tag_end));
1961 }
1962 }
1963 None
1964 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1965 {
1966 earliest_match = Some((start, end, "html_tag"));
1967 }
1968
1969 let mut next_special = remaining.len();
1971 let mut special_type = "";
1972 let mut pulldown_emphasis: Option<&EmphasisSpan> = None;
1973 let mut attr_list_len: usize = 0;
1974 let mut myst_role_len: usize = 0;
1975
1976 while code_span_idx < code_spans.len() && code_spans[code_span_idx].start < current_offset {
1978 code_span_idx += 1;
1979 }
1980 let next_code_span: Option<&CodeSpan> = code_spans.get(code_span_idx);
1981 if let Some(span) = next_code_span {
1982 let pos_in_remaining = span.start - current_offset;
1983 if pos_in_remaining < next_special {
1984 next_special = pos_in_remaining;
1985 special_type = "pulldown_code";
1986 }
1987 }
1988
1989 let next_curly_pos = cached_next_curly
1992 .earliest_in(remaining, current_offset, |suffix| {
1993 suffix.find('{').map(|pos| (pos, pos + 1))
1994 })
1995 .map(|(start, _)| start);
1996
1997 if myst_roles
2002 && let Some(pos) = next_curly_pos
2003 && pos < next_special
2004 && let Some(role_len) = myst_role_len_at(&remaining[pos..], current_offset + pos, &code_spans)
2005 {
2006 next_special = pos;
2007 special_type = "myst_role";
2008 myst_role_len = role_len;
2009 }
2010
2011 if attr_lists
2013 && let Some(pos) = next_curly_pos
2014 && pos < next_special
2015 && let Some(m) = ATTR_LIST_PATTERN.find(&remaining[pos..])
2016 && m.start() == 0
2017 {
2018 next_special = pos;
2019 special_type = "attr_list";
2020 attr_list_len = m.end();
2021 }
2022
2023 while emphasis_span_idx < emphasis_spans.len() && emphasis_spans[emphasis_span_idx].start < current_offset {
2025 emphasis_span_idx += 1;
2026 }
2027 if let Some(span) = emphasis_spans.get(emphasis_span_idx) {
2028 let pos_in_remaining = span.start - current_offset;
2029 if pos_in_remaining < next_special {
2030 next_special = pos_in_remaining;
2031 special_type = "pulldown_emphasis";
2032 pulldown_emphasis = Some(span);
2033 }
2034 }
2035
2036 let should_process_markdown_link = if let Some((pos, _, _)) = earliest_match {
2038 pos < next_special
2039 } else {
2040 false
2041 };
2042
2043 if should_process_markdown_link {
2044 let (pos, match_end, pattern_type) = earliest_match.unwrap();
2045
2046 if pos > 0 {
2048 elements.push(Element::Text(remaining[..pos].to_string()));
2049 }
2050
2051 match pattern_type {
2053 "link_span" => {
2054 let span = next_link.unwrap();
2055 let raw_text = remaining[pos..match_end].to_string();
2056 if span.is_footnote {
2057 elements.push(Element::FootnoteReference(raw_text));
2058 } else if span.is_image {
2059 match span.link_type {
2060 Some(LinkType::Inline) => elements.push(Element::InlineImage(raw_text)),
2061 Some(LinkType::Reference)
2064 | Some(LinkType::ReferenceUnknown)
2065 | Some(LinkType::Shortcut)
2066 | Some(LinkType::ShortcutUnknown) => elements.push(Element::ReferenceImage(raw_text)),
2067 Some(LinkType::Collapsed) | Some(LinkType::CollapsedUnknown) => {
2068 elements.push(Element::EmptyReferenceImage(raw_text))
2069 }
2070 _ => elements.push(Element::InlineImage(raw_text)),
2071 }
2072 } else {
2073 match span.link_type {
2074 Some(LinkType::Inline) => {
2075 if raw_text.starts_with('[') && raw_text.contains("![") {
2076 elements.push(Element::LinkedImage(raw_text));
2077 } else {
2078 elements.push(Element::Link(raw_text));
2079 }
2080 }
2081 Some(LinkType::Reference) | Some(LinkType::ReferenceUnknown) => {
2084 elements.push(Element::ReferenceLink(raw_text))
2085 }
2086 Some(LinkType::Collapsed) | Some(LinkType::CollapsedUnknown) => {
2087 elements.push(Element::EmptyReferenceLink(raw_text))
2088 }
2089 Some(LinkType::Shortcut) | Some(LinkType::ShortcutUnknown) => {
2090 elements.push(Element::ShortcutReference(raw_text))
2091 }
2092 Some(LinkType::Autolink) | Some(LinkType::Email) => {
2093 elements.push(Element::Autolink(raw_text))
2094 }
2095 _ => elements.push(Element::Link(raw_text)),
2096 }
2097 }
2098 remaining = &remaining[match_end..];
2099 }
2100 "wiki_link" => {
2101 if let Some(caps) = WIKI_LINK_REGEX.captures(remaining) {
2102 let content = caps.get(1).map_or("", |m| m.as_str());
2103 elements.push(Element::WikiLink(content.to_string()));
2104 remaining = &remaining[match_end..];
2105 } else {
2106 elements.push(Element::Text("[[".to_string()));
2107 remaining = &remaining[2..];
2108 }
2109 }
2110 "display_math" => {
2111 if let Some(caps) = DISPLAY_MATH_REGEX.captures(remaining) {
2112 let math = caps.get(1).map_or("", |m| m.as_str());
2113 elements.push(Element::DisplayMath(math.to_string()));
2114 remaining = &remaining[match_end..];
2115 } else {
2116 elements.push(Element::Text("$$".to_string()));
2117 remaining = &remaining[2..];
2118 }
2119 }
2120 "inline_math" => {
2121 if let Ok(Some(caps)) = INLINE_MATH_REGEX.captures(remaining) {
2122 let math = caps.get(1).map_or("", |m| m.as_str());
2123 elements.push(Element::InlineMath(math.to_string()));
2124 remaining = &remaining[match_end..];
2125 } else {
2126 elements.push(Element::Text("$".to_string()));
2127 remaining = &remaining[1..];
2128 }
2129 }
2130 "emoji" => {
2131 if let Some(caps) = EMOJI_SHORTCODE_REGEX.captures(remaining) {
2132 let emoji = caps.get(1).map_or("", |m| m.as_str());
2133 elements.push(Element::EmojiShortcode(emoji.to_string()));
2134 remaining = &remaining[match_end..];
2135 } else {
2136 elements.push(Element::Text(":".to_string()));
2137 remaining = &remaining[1..];
2138 }
2139 }
2140 "html_entity" => {
2141 elements.push(Element::HtmlEntity(remaining[pos..match_end].to_string()));
2143 remaining = &remaining[match_end..];
2144 }
2145 "hugo_shortcode" => {
2146 elements.push(Element::HugoShortcode(remaining[pos..match_end].to_string()));
2148 remaining = &remaining[match_end..];
2149 }
2150 "html_tag" => {
2151 elements.push(Element::HtmlTag(remaining[pos..match_end].to_string()));
2153 remaining = &remaining[match_end..];
2154 }
2155 _ => unreachable!("unknown pattern type: {}", pattern_type),
2156 }
2157 } else {
2158 if next_special > 0 && next_special < remaining.len() {
2162 elements.push(Element::Text(remaining[..next_special].to_string()));
2163 remaining = &remaining[next_special..];
2164 }
2165
2166 match special_type {
2168 "pulldown_code" => {
2169 let span = next_code_span.unwrap();
2170 let span_len = span.end - span.start;
2171 let code_raw = &remaining[..span_len];
2172 if let Some((content, marker)) = decompose_code_span(code_raw) {
2173 elements.push(Element::Code {
2174 content: content.to_string(),
2175 marker: marker.to_string(),
2176 });
2177 } else {
2178 elements.push(Element::Text(code_raw.to_string()));
2179 }
2180 remaining = &remaining[span_len..];
2181 }
2182 "attr_list" => {
2183 elements.push(Element::AttrList(remaining[..attr_list_len].to_string()));
2184 remaining = &remaining[attr_list_len..];
2185 }
2186 "myst_role" => {
2187 elements.push(Element::MystRole(remaining[..myst_role_len].to_string()));
2188 remaining = &remaining[myst_role_len..];
2189 }
2190 "pulldown_emphasis" => {
2191 let span = pulldown_emphasis.expect("pulldown_emphasis must be set");
2193 let span_len = span.end - span.start;
2194 if span.is_strikethrough {
2195 elements.push(Element::Strikethrough {
2196 content: span.content.clone(),
2197 double: span.strikethrough_double,
2198 });
2199 } else if span.is_strong {
2200 elements.push(Element::Bold {
2201 content: span.content.clone(),
2202 underscore: span.uses_underscore,
2203 });
2204 } else {
2205 elements.push(Element::Italic {
2206 content: span.content.clone(),
2207 underscore: span.uses_underscore,
2208 });
2209 }
2210 remaining = &remaining[span_len..];
2211 }
2212 _ => {
2213 elements.push(Element::Text(remaining.to_string()));
2215 break;
2216 }
2217 }
2218 }
2219 }
2220
2221 let mut merged_elements = Vec::new();
2223 for el in elements {
2224 match el {
2225 Element::Text(s) => {
2226 if let Some(Element::Text(last_s)) = merged_elements.last_mut() {
2227 last_s.push_str(&s);
2228 } else {
2229 merged_elements.push(Element::Text(s));
2230 }
2231 }
2232 other => merged_elements.push(other),
2233 }
2234 }
2235 merged_elements
2236}
2237
2238fn source_gap_before(elements: &[Element], idx: usize) -> &str {
2252 let Some(Element::Text(previous)) = idx.checked_sub(1).map(|prev| &elements[prev]) else {
2253 return "";
2254 };
2255
2256 let gap = &previous[previous.trim_end_matches(char::is_whitespace).len()..];
2257 if gap.is_empty() {
2258 ""
2259 } else if gap.contains(is_non_breaking_space) {
2260 gap
2261 } else {
2262 " "
2263 }
2264}
2265
2266fn push_source_gap(current_line: &mut String, gap: &str) {
2269 if !gap.is_empty() && !current_line.is_empty() && !current_line.ends_with(char::is_whitespace) {
2270 current_line.push_str(gap);
2271 }
2272}
2273
2274fn is_setext_or_thematic(text: &str) -> bool {
2280 let mut marker = 0u8;
2281 let mut count = 0usize;
2282 let mut has_space = false;
2283 for &b in text.as_bytes() {
2284 match b {
2285 b' ' | b'\t' => has_space = true,
2286 b'-' | b'=' | b'*' | b'_' => {
2287 if marker == 0 {
2288 marker = b;
2289 } else if b != marker {
2290 return false;
2291 }
2292 count += 1;
2293 }
2294 _ => return false,
2295 }
2296 }
2297 match marker {
2298 b'=' => !has_space,
2299 b'-' => !has_space || count >= 3,
2300 b'*' | b'_' => count >= 3,
2301 _ => false,
2302 }
2303}
2304
2305fn starts_block_construct(text: &str) -> bool {
2317 let text = text.trim_start();
2318 let bytes = text.as_bytes();
2319 let Some(&first) = bytes.first() else {
2320 return false;
2321 };
2322 let marker_then_boundary = |len: usize| bytes.len() == len || bytes[len] == b' ' || bytes[len] == b'\t';
2323 match first {
2324 b'>' => true,
2326 b'-' | b'*' | b'+' => marker_then_boundary(1) || is_setext_or_thematic(text),
2327 b'_' | b'=' => is_setext_or_thematic(text),
2328 b':' => is_definition_list_item(text) || text.starts_with(":::"),
2329 b'|' => true,
2330 b'#' => {
2331 let hashes = bytes.iter().take_while(|&&b| b == b'#').count();
2332 hashes <= 6 && marker_then_boundary(hashes)
2333 }
2334 b'`' => bytes.iter().take_while(|&&b| b == b'`').count() >= 3,
2335 b'~' => bytes.iter().take_while(|&&b| b == b'~').count() >= 3,
2336 b'0'..=b'9' => {
2343 let digits = bytes.iter().take_while(|b| b.is_ascii_digit()).count();
2344 digits <= 9
2345 && text[..digits].trim_start_matches('0') == "1"
2346 && bytes.len() > digits + 1
2347 && (bytes[digits] == b'.' || bytes[digits] == b')')
2348 && (bytes[digits + 1] == b' ' || bytes[digits + 1] == b'\t')
2349 }
2350 b'[' => {
2358 let mut escaped = false;
2359 let mut label_close = None;
2360 for (i, &b) in bytes.iter().enumerate().skip(1) {
2361 if escaped {
2362 escaped = false;
2363 } else if b == b'\\' {
2364 escaped = true;
2365 } else if b == b']' {
2366 label_close = Some(i);
2367 break;
2368 }
2369 }
2370 label_close.is_some_and(|i| bytes.get(i + 1) == Some(&b':'))
2371 }
2372 b'<' => crate::utils::html_block::parse_html_block_start(text).is_some(),
2375 _ => false,
2376 }
2377}
2378
2379fn merge_block_construct_continuations(lines: Vec<String>) -> Vec<String> {
2388 let mut merged: Vec<String> = Vec::with_capacity(lines.len());
2389 for line in lines {
2390 merged.push(line);
2391 while merged.len() > 1 && starts_block_construct(merged.last().expect("non-empty")) {
2395 let last = merged.pop().expect("non-empty");
2396 let prev = merged.last_mut().expect("len > 1");
2397 prev.push(' ');
2398 prev.push_str(last.trim_start());
2399 }
2400 }
2401 merged
2402}
2403
2404fn reflow_elements_sentence_per_line(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
2406 let abbreviations = get_abbreviations(&options.abbreviations);
2407 let require_sentence_capital = options.require_sentence_capital;
2408 let mut lines = Vec::new();
2409 let mut current_line = String::new();
2410
2411 for (idx, element) in elements.iter().enumerate() {
2412 let is_span = matches!(
2418 element,
2419 Element::Italic { .. } | Element::Bold { .. } | Element::Strikethrough { .. }
2420 );
2421 let piece = match element {
2422 Element::Text(text) => Some(text.clone()),
2424 Element::Italic { content, underscore } => Some(wrap_emphasis(
2425 content,
2426 if *underscore { "_" } else { "*" },
2427 &mut current_line,
2428 source_gap_before(elements, idx),
2429 )),
2430 Element::Bold { content, underscore } => Some(wrap_emphasis(
2431 content,
2432 if *underscore { "__" } else { "**" },
2433 &mut current_line,
2434 source_gap_before(elements, idx),
2435 )),
2436 Element::Strikethrough { content, double } => Some(wrap_emphasis(
2437 content,
2438 if *double { "~~" } else { "~" },
2439 &mut current_line,
2440 source_gap_before(elements, idx),
2441 )),
2442 _ => None,
2443 };
2444
2445 if let Some(piece) = piece {
2446 let appended_span_start = is_span.then_some(current_line.len());
2450 let combined = format!("{current_line}{piece}");
2451 let sentences = split_into_sentences_with_set(
2453 &combined,
2454 &abbreviations,
2455 require_sentence_capital,
2456 appended_span_start,
2457 options.defined_references.as_ref(),
2458 );
2459
2460 let next_bracketed = elements
2469 .get(idx + 1)
2470 .filter(|next| next.opens_with_bracket())
2471 .map(|next| (source_gap_before(elements, idx + 1), next.to_string()));
2472 let closes_before_next = |sentence: &str| -> bool {
2473 let Some((gap, next_str)) = &next_bracketed else {
2474 return true;
2475 };
2476 let mut probe = sentence.to_string();
2477 push_source_gap(&mut probe, gap);
2478 probe.push_str(next_str);
2479 let probe_sentences = split_into_sentences_with_set(
2480 &probe,
2481 &abbreviations,
2482 require_sentence_capital,
2483 None,
2484 options.defined_references.as_ref(),
2485 );
2486 probe_sentences.last().is_some_and(|last| last == next_str)
2487 };
2488
2489 if sentences.len() > 1 {
2490 let mut pending = String::new();
2494 let last = sentences.len() - 1;
2495 for (i, sentence) in sentences.iter().enumerate() {
2496 if !pending.is_empty() {
2497 pending.push(' ');
2498 }
2499 pending.push_str(sentence);
2500
2501 let closed = i < last || (ends_with_sentence_punct(&pending) && closes_before_next(&pending));
2506 if closed && !text_ends_with_abbreviation(&pending, &abbreviations) {
2507 lines.push(std::mem::take(&mut pending));
2508 }
2509 }
2510 current_line = pending;
2511 } else {
2512 let trimmed = combined.trim();
2514
2515 if trimmed.is_empty() {
2519 continue;
2520 }
2521
2522 let ends_with_sentence_punct = ends_with_sentence_punct(trimmed);
2523
2524 if ends_with_sentence_punct
2525 && !text_ends_with_abbreviation(trimmed, &abbreviations)
2526 && closes_before_next(trimmed)
2527 {
2528 lines.push(combined.trim_matches(is_breakable_whitespace).to_string());
2531 current_line.clear();
2532 } else {
2533 current_line = combined;
2535 }
2536 }
2537 } else {
2538 let element_str = format!("{element}");
2540 push_source_gap(&mut current_line, source_gap_before(elements, idx));
2541 current_line.push_str(&element_str);
2542 }
2543 }
2544
2545 if !current_line.is_empty() {
2547 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
2548 }
2549 lines
2550}
2551
2552fn wrap_emphasis(content: &str, marker: &str, current_line: &mut String, gap: &str) -> String {
2556 push_source_gap(current_line, gap);
2557 format!("{marker}{content}{marker}")
2558}
2559
2560const BREAK_WORDS: &[&str] = &[
2564 "and",
2565 "or",
2566 "but",
2567 "nor",
2568 "yet",
2569 "so",
2570 "for",
2571 "which",
2572 "that",
2573 "because",
2574 "when",
2575 "if",
2576 "while",
2577 "where",
2578 "although",
2579 "though",
2580 "unless",
2581 "since",
2582 "after",
2583 "before",
2584 "until",
2585 "as",
2586 "once",
2587 "whether",
2588 "however",
2589 "therefore",
2590 "moreover",
2591 "furthermore",
2592 "nevertheless",
2593 "whereas",
2594];
2595
2596fn is_clause_punctuation(c: char) -> bool {
2598 matches!(c, ',' | ';' | ':' | '\u{2014}') }
2600
2601fn clause_break_allowed_after(chars: &[char], i: usize) -> bool {
2611 match chars.get(i + 1) {
2612 None => true,
2613 Some(next) => is_breakable_whitespace(*next),
2614 }
2615}
2616
2617fn paren_group_end<'a>(slice: &'a str, element_spans: &[ElementSpan], offset: usize) -> Option<(usize, &'a str)> {
2631 debug_assert!(slice.starts_with('('));
2632 let mut depth: i32 = 0;
2633 for (local_byte, c) in slice.char_indices() {
2634 let global_byte = offset + local_byte;
2635 if depth > 0 && is_inside_element(global_byte, element_spans) {
2640 continue;
2641 }
2642 match c {
2643 '(' => depth += 1,
2644 ')' => {
2645 depth -= 1;
2646 if depth == 0 {
2647 let end = local_byte + 1;
2648 let inner = &slice[1..local_byte];
2649 return Some((end, inner));
2650 }
2651 }
2652 _ => {}
2653 }
2654 }
2655 None
2656}
2657
2658fn split_at_parenthetical(
2675 text: &str,
2676 line_length: usize,
2677 element_spans: &[ElementSpan],
2678 length_mode: ReflowLengthMode,
2679) -> Option<(String, String)> {
2680 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
2681
2682 if text.starts_with('(')
2684 && let Some((end_local, inner)) = paren_group_end(text, element_spans, 0)
2685 && inner.contains(' ')
2686 {
2687 let mut first_end = end_local;
2694 loop {
2695 first_end += text[first_end..]
2696 .char_indices()
2697 .take_while(|(_, c)| !is_breakable_whitespace(*c))
2698 .last()
2699 .map_or(0, |(idx, c)| idx + c.len_utf8());
2700 match element_containing(first_end, element_spans) {
2701 Some(span) => first_end = span.end,
2702 None => break,
2703 }
2704 }
2705 let rest_start = first_end;
2706 let first = &text[..first_end];
2707 if measure(first, 0, element_spans, length_mode).fits(line_length) {
2710 let rest = text[rest_start..].trim_start();
2711 if !rest.is_empty() {
2712 return Some((first.to_string(), rest.to_string()));
2713 }
2714 }
2715 }
2716
2717 let mut best_open_byte: Option<usize> = None;
2719 let mut pos = 0usize;
2720 while pos < text.len() {
2721 if text.as_bytes()[pos] != b'(' {
2723 let c = text[pos..].chars().next().unwrap();
2724 pos += c.len_utf8();
2725 continue;
2726 }
2727 if is_inside_element(pos, element_spans) {
2729 pos += 1;
2730 continue;
2731 }
2732 if let Some((end_local, inner)) = paren_group_end(&text[pos..], element_spans, pos) {
2733 let first = text[..pos].trim_end_matches(is_breakable_whitespace);
2734 let first_len = measure(first, 0, element_spans, length_mode).effective();
2735 if first.len() < pos
2738 && !first.is_empty()
2739 && first_len >= min_first_len
2740 && first_len <= line_length
2741 && inner.contains(' ')
2742 && best_open_byte.is_none_or(|prev| pos > prev)
2743 {
2744 best_open_byte = Some(pos);
2745 }
2746 pos += end_local;
2747 } else {
2748 pos += 1;
2749 }
2750 }
2751
2752 let open_byte = best_open_byte?;
2753 let first = text[..open_byte].trim_end_matches(is_breakable_whitespace).to_string();
2754 let rest = text[open_byte..].to_string();
2755 if first.is_empty() || rest.trim().is_empty() {
2756 return None;
2757 }
2758 Some((first, rest))
2759}
2760
2761#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2769struct ElementSpan {
2770 start: usize,
2771 end: usize,
2772 full: usize,
2773 link_saving: usize,
2776 code_saving: usize,
2778 is_hard: bool,
2780}
2781
2782impl ElementSpan {
2783 fn new(start: usize, len: usize, full: usize, width: LineWidth, is_hard: bool) -> Self {
2786 Self {
2787 start,
2788 end: start + len,
2789 full,
2790 link_saving: full - width.link_exempt,
2791 code_saving: full - width.code_exempt,
2792 is_hard,
2793 }
2794 }
2795
2796 fn contains(&self, pos: usize) -> bool {
2797 pos > self.start && pos < self.end
2798 }
2799
2800 fn within(&self, start: usize, end: usize) -> bool {
2801 self.start >= start && self.end <= end
2802 }
2803
2804 fn exempt_width(&self) -> LineWidth {
2805 LineWidth {
2806 link_exempt: self.full - self.link_saving,
2807 code_exempt: self.full - self.code_saving,
2808 }
2809 }
2810}
2811
2812fn compute_element_spans(
2818 elements: &[Element],
2819 mode: ReflowLengthMode,
2820 exemptions: LengthExemptions,
2821) -> Vec<ElementSpan> {
2822 let mut spans = Vec::new();
2823 let mut offset = 0;
2824 for element in elements {
2825 let len = element.display_len(ReflowLengthMode::Bytes);
2826 if !matches!(element, Element::Text(_)) {
2827 let full = element.display_len(mode);
2828 let width = element.exempt_width(mode, exemptions);
2829 let is_hard = match element {
2830 Element::Bold { content, .. }
2831 | Element::Italic { content, .. }
2832 | Element::Strikethrough { content, .. } => content.contains(['[', '`', '<', '$', '{']),
2833 _ => true,
2834 };
2835 spans.push(ElementSpan::new(offset, len, full, width, is_hard));
2836 }
2837 offset += len;
2838 }
2839 spans
2840}
2841
2842fn measure(text: &str, offset: usize, spans: &[ElementSpan], mode: ReflowLengthMode) -> LineWidth {
2850 let full = display_len(text, mode);
2851 let end = offset + text.len();
2852 let mut width = LineWidth::plain(full);
2853 for span in spans.iter().filter(|span| span.within(offset, end)) {
2854 width.link_exempt -= span.link_saving;
2855 width.code_exempt -= span.code_saving;
2856 }
2857 width
2858}
2859
2860fn line_width_components(line: &str, options: &ReflowOptions) -> LineWidth {
2865 let raw = display_len(line, options.length_mode);
2866 if !options.length_exemptions.any() {
2867 return LineWidth::plain(raw);
2868 }
2869 let elements = parse_markdown_elements_inner(
2870 line,
2871 options.attr_lists,
2872 options.myst_roles,
2873 options.defined_references.as_ref(),
2874 );
2875 let spans = compute_element_spans(&elements, options.length_mode, options.length_exemptions);
2876 measure(line, 0, &spans, options.length_mode)
2877}
2878
2879fn line_width(line: &str, options: &ReflowOptions) -> usize {
2881 line_width_components(line, options).effective()
2882}
2883
2884fn line_fits(line: &str, options: &ReflowOptions) -> bool {
2890 display_len(line, options.length_mode) <= options.line_length || line_width(line, options) <= options.line_length
2891}
2892
2893fn element_containing(pos: usize, spans: &[ElementSpan]) -> Option<ElementSpan> {
2895 spans.iter().copied().find(|span| span.contains(pos))
2896}
2897
2898fn is_inside_element(pos: usize, spans: &[ElementSpan]) -> bool {
2900 element_containing(pos, spans).is_some()
2901}
2902
2903const MIN_SPLIT_RATIO: f64 = 0.3;
2906
2907fn split_at_clause_punctuation(
2911 text: &str,
2912 line_length: usize,
2913 element_spans: &[ElementSpan],
2914 length_mode: ReflowLengthMode,
2915) -> Option<(String, String)> {
2916 let chars: Vec<char> = text.chars().collect();
2917 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
2918
2919 let mut width_acc = LineWidth::default();
2925 let mut search_end_char = 0;
2926 let mut byte = 0usize;
2927 let mut idx = 0usize;
2928 while idx < chars.len() {
2929 let (advance_chars, advance_bytes, width) = match element_spans.iter().find(|s| s.start == byte) {
2930 Some(span) => {
2931 let source = &text[span.start..span.end];
2932 (
2933 source.chars().count(),
2934 source.len(),
2935 measure(source, span.start, element_spans, length_mode),
2936 )
2937 }
2938 None => {
2939 let c = chars[idx];
2940 (
2941 1,
2942 c.len_utf8(),
2943 LineWidth::plain(display_len(&c.to_string(), length_mode)),
2944 )
2945 }
2946 };
2947 if !(width_acc + width).fits(line_length) {
2948 break;
2949 }
2950 width_acc += width;
2951 byte += advance_bytes;
2952 idx += advance_chars;
2953 search_end_char = idx;
2954 }
2955
2956 let mut paren_depth: i32 = 0;
2963 let mut best_pos = None;
2964 for i in (0..search_end_char).rev() {
2965 let byte_start: usize = chars[..i].iter().map(|c| c.len_utf8()).sum();
2967 let byte_after: usize = byte_start + chars[i].len_utf8();
2969
2970 if !is_inside_element(byte_start, element_spans) {
2971 match chars[i] {
2972 ')' => paren_depth += 1,
2973 '(' => paren_depth = paren_depth.saturating_sub(1),
2974 _ => {}
2975 }
2976 }
2977
2978 if paren_depth == 0
2979 && is_clause_punctuation(chars[i])
2980 && clause_break_allowed_after(&chars, i)
2981 && !is_inside_element(byte_after, element_spans)
2982 {
2983 best_pos = Some(i);
2984 break;
2985 }
2986 }
2987
2988 let pos = best_pos?;
2989
2990 let first: String = chars[..=pos].iter().collect();
2992 if measure(&first, 0, element_spans, length_mode).effective() < min_first_len {
2993 return None;
2994 }
2995
2996 let rest: String = chars[pos + 1..].iter().collect();
2998 let rest = rest.trim_start().to_string();
2999
3000 if rest.is_empty() {
3001 return None;
3002 }
3003
3004 Some((first, rest))
3005}
3006
3007fn paren_depth_map(text: &str, element_spans: &[ElementSpan]) -> Vec<i32> {
3014 let mut map = vec![0i32; text.len()];
3015 let mut depth = 0i32;
3016 for (byte, c) in text.char_indices() {
3017 if !is_inside_element(byte, element_spans) {
3018 match c {
3019 '(' => depth += 1,
3020 ')' => depth = depth.saturating_sub(1),
3021 _ => {}
3022 }
3023 }
3024 let end = (byte + c.len_utf8()).min(map.len());
3026 for slot in &mut map[byte..end] {
3027 *slot = depth;
3028 }
3029 }
3030 map
3031}
3032
3033fn is_standalone_parenthetical(line: &str) -> bool {
3042 let trimmed = line.trim();
3043 if !trimmed.starts_with('(') {
3044 return false;
3045 }
3046 let Some(close) = trimmed.rfind(')') else {
3049 return false;
3050 };
3051 if trimmed[close + 1..].contains(char::is_whitespace) {
3052 return false;
3053 }
3054 let core = &trimmed[..=close];
3055 let inner = &core[1..core.len() - 1];
3057 if !inner.contains(' ') {
3058 return false;
3059 }
3060 let mut depth = 0i32;
3062 for c in core.chars() {
3063 match c {
3064 '(' => depth += 1,
3065 ')' => depth -= 1,
3066 _ => {}
3067 }
3068 if depth < 0 {
3069 return false;
3070 }
3071 }
3072 depth == 0
3073}
3074
3075fn split_at_break_word(
3079 text: &str,
3080 line_length: usize,
3081 element_spans: &[ElementSpan],
3082 length_mode: ReflowLengthMode,
3083) -> Option<(String, String)> {
3084 let lower = text.to_lowercase();
3085 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
3086 let mut best_split: Option<(usize, usize)> = None; let depth_map = paren_depth_map(text, element_spans);
3091
3092 for &word in BREAK_WORDS {
3093 let mut search_start = 0;
3094 while let Some(pos) = lower[search_start..].find(word) {
3095 let abs_pos = search_start + pos;
3096
3097 let preceded_by_space = abs_pos == 0 || text.as_bytes().get(abs_pos - 1) == Some(&b' ');
3099 let followed_by_space = text.as_bytes().get(abs_pos + word.len()) == Some(&b' ');
3100
3101 if preceded_by_space && followed_by_space {
3102 let first_part = text[..abs_pos].trim_end();
3104 let first_part_len = measure(first_part, 0, element_spans, length_mode).effective();
3105
3106 let inside_paren = depth_map.get(abs_pos).is_some_and(|&d| d > 0);
3108
3109 if first_part_len >= min_first_len
3110 && first_part_len <= line_length
3111 && !is_inside_element(abs_pos, element_spans)
3112 && !inside_paren
3113 {
3114 if best_split.is_none_or(|(prev_pos, _)| abs_pos > prev_pos) {
3116 best_split = Some((abs_pos, word.len()));
3117 }
3118 }
3119 }
3120
3121 search_start = abs_pos + word.len();
3122 }
3123 }
3124
3125 let (byte_start, _word_len) = best_split?;
3126
3127 let first = text[..byte_start].trim_end().to_string();
3128 let rest = text[byte_start..].to_string();
3129
3130 if first.is_empty() || rest.trim().is_empty() {
3131 return None;
3132 }
3133
3134 Some((first, rest))
3135}
3136
3137fn replaces_whitespace(text: &str, first: &str, rest: &str, element_spans: &[ElementSpan]) -> bool {
3148 if !text.starts_with(first) || !text.ends_with(rest) {
3149 return false;
3150 }
3151 let gap_end = text.len() - rest.len();
3152 gap_end > first.len()
3153 && text[first.len()..gap_end].chars().all(is_breakable_whitespace)
3154 && !element_spans
3155 .iter()
3156 .any(|span| first.len() < span.end && span.start < gap_end)
3157}
3158
3159fn cascade_split_line(text: &str, options: &ReflowOptions) -> Vec<String> {
3170 let line_length = options.line_length;
3171 let length_mode = options.length_mode;
3172 let attr_lists = options.attr_lists;
3173 let myst_roles = options.myst_roles;
3174 let defined_references = options.defined_references.as_ref();
3175 if line_length == 0 || display_len(text, length_mode) <= line_length {
3176 return vec![text.to_string()];
3177 }
3178
3179 let elements = parse_markdown_elements_inner(text, attr_lists, myst_roles, defined_references);
3180 let element_spans = compute_element_spans(&elements, length_mode, options.length_exemptions);
3181
3182 if measure(text, 0, &element_spans, length_mode).fits(line_length) {
3185 return vec![text.to_string()];
3186 }
3187
3188 let rebased_spans = |start: usize| -> Vec<ElementSpan> {
3192 if start == 0 {
3193 return element_spans.clone();
3194 }
3195 element_spans
3196 .iter()
3197 .filter(|span| span.end > start)
3198 .map(|span| ElementSpan {
3199 start: span.start.saturating_sub(start),
3200 end: span.end.saturating_sub(start),
3201 ..*span
3202 })
3203 .collect()
3204 };
3205
3206 let mut result = Vec::new();
3207 let mut start = 0usize;
3208
3209 loop {
3210 let remaining = &text[start..];
3211 let spans = rebased_spans(start);
3212 if measure(remaining, 0, &spans, length_mode).fits(line_length) {
3213 result.push(remaining.to_string());
3214 return result;
3215 }
3216
3217 let at_whitespace = |candidate: Option<(String, String)>| {
3226 candidate.filter(|(first, rest)| replaces_whitespace(remaining, first, rest, &spans))
3227 };
3228 let split = at_whitespace(split_at_parenthetical(remaining, line_length, &spans, length_mode))
3229 .or_else(|| at_whitespace(split_at_clause_punctuation(remaining, line_length, &spans, length_mode)))
3230 .or_else(|| at_whitespace(split_at_break_word(remaining, line_length, &spans, length_mode)));
3231
3232 if let Some((first, rest)) = split {
3233 let consumed = remaining.len().saturating_sub(rest.len());
3234 if consumed == 0 {
3237 break;
3238 }
3239 result.push(first);
3240 start += consumed;
3241 continue;
3242 }
3243
3244 break;
3246 }
3247
3248 let mut fallback_options = options.clone();
3250 fallback_options.break_on_sentences = false;
3251 fallback_options.preserve_breaks = false;
3252 fallback_options.sentence_per_line = false;
3253 fallback_options.semantic_line_breaks = false;
3254 fallback_options.require_sentence_capital = true;
3255 fallback_options.max_list_continuation_indent = None;
3256 fallback_options.defined_references = None;
3257 let remaining = &text[start..];
3258 let tail_elements = if start == 0 {
3259 elements
3260 } else {
3261 parse_markdown_elements_inner(remaining, attr_lists, myst_roles, defined_references)
3262 };
3263 result.extend(reflow_elements(&tail_elements, &fallback_options));
3264 result
3265}
3266
3267fn reflow_elements_semantic(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
3271 let sentence_lines = reflow_elements_sentence_per_line(elements, options);
3273
3274 if options.line_length == 0 {
3277 return sentence_lines;
3278 }
3279
3280 let mut result = Vec::new();
3281 for line in sentence_lines {
3282 if line_fits(&line, options) {
3283 result.push(line);
3284 } else {
3285 result.extend(cascade_split_line(&line, options));
3286 }
3287 }
3288
3289 let min_line_len = ((options.line_length as f64) * MIN_SPLIT_RATIO) as usize;
3292 let mut merged: Vec<String> = Vec::with_capacity(result.len());
3293 for line in result {
3294 if !merged.is_empty() && line_width(&line, options) < min_line_len && !line.trim().is_empty() {
3295 if is_standalone_parenthetical(&line) {
3298 merged.push(line);
3299 continue;
3300 }
3301
3302 let prev_ends_at_sentence = {
3304 let trimmed = merged.last().unwrap().trim_end();
3305 trimmed
3306 .chars()
3307 .rev()
3308 .find(|c| !matches!(c, '"' | '\'' | '\u{201D}' | '\u{2019}' | ')' | ']'))
3309 .is_some_and(|c| matches!(c, '.' | '!' | '?'))
3310 };
3311
3312 if !prev_ends_at_sentence {
3313 let prev = merged.last_mut().unwrap();
3314 let combined = format!("{prev} {line}");
3315 if line_fits(&combined, options) {
3317 *prev = combined;
3318 continue;
3319 }
3320 }
3321 }
3322 merged.push(line);
3323 }
3324 merged
3325}
3326
3327fn rfind_safe_space(
3337 line: &str,
3338 element_spans: &[ElementSpan],
3339 options: &ReflowOptions,
3340 relax_soft_spans: bool,
3341) -> Option<usize> {
3342 line.char_indices().rev().map(|(pos, _)| pos).find(|&pos| {
3343 line.as_bytes()[pos] == b' '
3344 && !is_inside_element_filtered(pos, element_spans, options, relax_soft_spans)
3345 && !starts_block_construct(&line[pos + 1..])
3346 })
3347}
3348
3349fn is_inside_element_filtered(
3350 pos: usize,
3351 spans: &[ElementSpan],
3352 options: &ReflowOptions,
3353 relax_soft_spans: bool,
3354) -> bool {
3355 spans.iter().any(|span| {
3356 span.contains(pos)
3357 && (!relax_soft_spans
3358 || span.is_hard
3359 || (options.atomic_spans && span.exempt_width().fits(options.line_length)))
3360 })
3361}
3362
3363#[derive(Clone, Copy)]
3368struct Attached<'a> {
3369 text: &'a str,
3370 width: LineWidth,
3371 separator: &'a str,
3372}
3373
3374fn break_before_attached(
3391 lines: &mut Vec<String>,
3392 current_line: &mut String,
3393 current_width: &mut LineWidth,
3394 element_spans: &mut Vec<ElementSpan>,
3395 attach: Attached<'_>,
3396 options: &ReflowOptions,
3397) -> Option<usize> {
3398 let length_mode = options.length_mode;
3399 let last_space = rfind_safe_space(current_line, element_spans, options, false)
3400 .or_else(|| rfind_safe_space(current_line, element_spans, options, true))?;
3401 let before = current_line[..last_space]
3402 .trim_end_matches(is_breakable_whitespace)
3403 .to_string();
3404 let after = current_line[last_space + 1..].to_string();
3405 let after_width = measure(&after, last_space + 1, element_spans, length_mode);
3406 lines.push(before);
3407 let carried = after.len();
3408 let Attached { text, width, separator } = attach;
3409 *current_line = format!("{after}{separator}{text}");
3410 *current_width = after_width + LineWidth::plain(display_len(separator, length_mode)) + width;
3411 rebase_spans_after_break(element_spans, last_space + 1);
3412 Some(carried)
3413}
3414
3415fn rebase_spans_after_break(element_spans: &mut Vec<ElementSpan>, carried_start: usize) {
3424 element_spans.retain(|span| span.end > carried_start);
3425 for span in element_spans.iter_mut() {
3426 span.start = span.start.saturating_sub(carried_start);
3427 span.end -= carried_start;
3428 }
3429}
3430
3431fn reflow_elements(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
3433 let mut lines = Vec::new();
3434 let mut current_line = String::new();
3435 let mut current_width = LineWidth::default();
3438 let mut current_line_element_spans: Vec<ElementSpan> = Vec::new();
3440 let length_mode = options.length_mode;
3441 let exemptions = options.length_exemptions;
3442
3443 for (idx, element) in elements.iter().enumerate() {
3444 let element_len = element.display_len(length_mode);
3445 let element_width = element.exempt_width(length_mode, exemptions);
3446 let is_hard = match element {
3447 Element::Bold { content, .. }
3448 | Element::Italic { content, .. }
3449 | Element::Strikethrough { content, .. } => content.contains(['[', '`', '<', '$', '{']),
3450 _ => true,
3451 };
3452
3453 let is_adjacent_to_prev = if idx > 0 {
3462 match (&elements[idx - 1], element) {
3463 (Element::Text(t), _) => !t.is_empty() && !t.ends_with(is_breakable_whitespace),
3464 (_, Element::Text(t)) => !t.is_empty() && !t.starts_with(is_breakable_whitespace),
3465 _ => true,
3466 }
3467 } else {
3468 false
3469 };
3470
3471 if let Element::Text(text) = element {
3473 let has_leading_space = text.starts_with(is_breakable_whitespace);
3475 let words: Vec<&str> = split_breakable_words(text).collect();
3477
3478 for (i, word) in words.iter().enumerate() {
3479 let word_width = LineWidth::plain(display_len(word, length_mode));
3481 let is_trailing_punct = word.chars().all(|c| {
3487 matches!(c, ',' | '.' | ':' | ';' | '!' | '?' | ')' | ']' | '}') || is_non_breaking_space(c)
3488 });
3489
3490 let is_first_adjacent = i == 0 && is_adjacent_to_prev;
3493
3494 if is_first_adjacent {
3495 if !(current_width + word_width).fits(options.line_length)
3497 && !current_width.is_empty()
3498 && break_before_attached(
3499 &mut lines,
3500 &mut current_line,
3501 &mut current_width,
3502 &mut current_line_element_spans,
3503 Attached {
3504 text: word,
3505 width: word_width,
3506 separator: "",
3507 },
3508 options,
3509 )
3510 .is_some()
3511 {
3512 } else {
3517 current_line.push_str(word);
3518 current_width += word_width;
3519 }
3520 } else if !current_width.is_empty()
3521 && !(current_width + LineWidth::plain(1) + word_width).fits(options.line_length)
3522 {
3523 if is_trailing_punct {
3524 if break_before_attached(
3531 &mut lines,
3532 &mut current_line,
3533 &mut current_width,
3534 &mut current_line_element_spans,
3535 Attached {
3536 text: word,
3537 width: word_width,
3538 separator: " ",
3539 },
3540 options,
3541 )
3542 .is_none()
3543 {
3544 current_line.push(' ');
3545 current_line.push_str(word);
3546 current_width += LineWidth::plain(1) + word_width;
3547 }
3548 } else if !starts_block_construct(word) {
3549 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
3551 current_line = word.to_string();
3552 current_width = word_width;
3553 current_line_element_spans.clear();
3554 } else if break_before_attached(
3555 &mut lines,
3556 &mut current_line,
3557 &mut current_width,
3558 &mut current_line_element_spans,
3559 Attached {
3560 text: word,
3561 width: word_width,
3562 separator: " ",
3563 },
3564 options,
3565 )
3566 .is_some()
3567 {
3568 } else {
3573 if i > 0 || has_leading_space {
3576 current_line.push(' ');
3577 current_width += LineWidth::plain(1);
3578 }
3579 current_line.push_str(word);
3580 current_width += word_width;
3581 }
3582 } else {
3583 let add_space = !current_width.is_empty() && (i > 0 || has_leading_space);
3595 if add_space {
3596 current_line.push(' ');
3597 current_width += LineWidth::plain(1);
3598 }
3599 current_line.push_str(word);
3600 current_width += word_width;
3601 }
3602 }
3603 } else {
3604 let span_info = match element {
3605 Element::Italic { content, underscore } => {
3606 Some((content.as_str(), if *underscore { "_" } else { "*" }, false))
3607 }
3608 Element::Bold { content, underscore } => {
3609 Some((content.as_str(), if *underscore { "__" } else { "**" }, false))
3610 }
3611 Element::Strikethrough { content, double } => {
3612 Some((content.as_str(), if *double { "~~" } else { "~" }, false))
3613 }
3614 Element::Code { content, marker } => Some((content.as_str(), marker.as_str(), true)),
3615 _ => None,
3616 };
3617
3618 let breakable: Option<Vec<&str>> = match span_info {
3622 Some((content, _, is_code)) => {
3623 if is_code {
3624 (!options.atomic_spans && code_span_wraps_losslessly(content))
3625 .then(|| split_breakable_words(content).collect())
3626 } else {
3627 (!options.atomic_spans || element_len > options.line_length)
3628 .then(|| breakable_units(content, options.defined_references.as_ref(), options.attr_lists))
3629 .flatten()
3630 }
3631 }
3632 None => None,
3633 };
3634
3635 if let Some(words) = breakable {
3636 let (_, marker, is_code) = span_info.expect("breakable implies a span");
3637 let n = words.len();
3638 if n == 0 {
3639 let full = format!("{marker}{marker}");
3641 let full_width = LineWidth::plain(display_len(&full, length_mode));
3642 if !is_adjacent_to_prev && !current_width.is_empty() {
3643 current_line.push(' ');
3644 current_width += LineWidth::plain(1);
3645 }
3646 current_line.push_str(&full);
3647 current_width += full_width;
3648 } else {
3649 for (i, word) in words.iter().enumerate() {
3650 let is_first = i == 0;
3651 let is_last = i == n - 1;
3652
3653 let space_start = if is_first && is_code && word.starts_with('`') {
3654 " "
3655 } else {
3656 ""
3657 };
3658 let space_end = if is_last && is_code && word.ends_with('`') {
3659 " "
3660 } else {
3661 ""
3662 };
3663
3664 let word_str: String = match (is_first, is_last) {
3665 (true, true) => format!("{marker}{space_start}{word}{space_end}{marker}"),
3666 (true, false) => format!("{marker}{space_start}{word}"),
3667 (false, true) => format!("{word}{space_end}{marker}"),
3668 (false, false) => word.to_string(),
3669 };
3670 let word_elements = parse_elements(&word_str, options);
3671 let word_spans = compute_element_spans(&word_elements, length_mode, exemptions);
3672 let word_width = measure(&word_str, 0, &word_spans, length_mode);
3673
3674 let needs_space = if is_first {
3675 !is_adjacent_to_prev && !current_width.is_empty()
3676 } else {
3677 !current_width.is_empty()
3678 };
3679
3680 if needs_space
3681 && !(current_width + LineWidth::plain(1) + word_width).fits(options.line_length)
3682 && !starts_block_construct(&word_str)
3683 {
3684 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
3685 current_line = word_str;
3686 current_width = word_width;
3687 current_line_element_spans.clear();
3688 for span in word_spans {
3689 current_line_element_spans.push(span);
3690 }
3691 } else {
3692 let mut start_pos = current_line.len();
3693 if needs_space {
3694 current_line.push(' ');
3695 current_width += LineWidth::plain(1);
3696 start_pos += 1;
3697 }
3698 current_line.push_str(&word_str);
3699 current_width += word_width;
3700 for mut span in word_spans {
3701 span.start += start_pos;
3702 span.end += start_pos;
3703 current_line_element_spans.push(span);
3704 }
3705 }
3706 }
3707 }
3708 } else {
3709 let element_str = format!("{element}");
3712
3713 if is_adjacent_to_prev {
3714 if !(current_width + element_width).fits(options.line_length)
3716 && let Some(carried) = break_before_attached(
3717 &mut lines,
3718 &mut current_line,
3719 &mut current_width,
3720 &mut current_line_element_spans,
3721 Attached {
3722 text: &element_str,
3723 width: element_width,
3724 separator: "",
3725 },
3726 options,
3727 )
3728 {
3729 current_line_element_spans.push(ElementSpan::new(
3733 carried,
3734 element_str.len(),
3735 element_len,
3736 element_width,
3737 is_hard,
3738 ));
3739 } else {
3740 let start = current_line.len();
3741 current_line.push_str(&element_str);
3742 current_width += element_width;
3743 current_line_element_spans.push(ElementSpan::new(
3744 start,
3745 element_str.len(),
3746 element_len,
3747 element_width,
3748 is_hard,
3749 ));
3750 }
3751 } else if !current_width.is_empty()
3752 && !(current_width + LineWidth::plain(1) + element_width).fits(options.line_length)
3753 {
3754 if !starts_block_construct(&element_str) {
3755 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
3757 current_line.clone_from(&element_str);
3758 current_width = element_width;
3759 current_line_element_spans.clear();
3760 current_line_element_spans.push(ElementSpan::new(
3761 0,
3762 element_str.len(),
3763 element_len,
3764 element_width,
3765 is_hard,
3766 ));
3767 } else if let Some(carried) = break_before_attached(
3768 &mut lines,
3769 &mut current_line,
3770 &mut current_width,
3771 &mut current_line_element_spans,
3772 Attached {
3773 text: &element_str,
3774 width: element_width,
3775 separator: " ",
3776 },
3777 options,
3778 ) {
3779 let start = carried + 1;
3783 current_line_element_spans.push(ElementSpan::new(
3784 start,
3785 element_str.len(),
3786 element_len,
3787 element_width,
3788 is_hard,
3789 ));
3790 } else {
3791 let ends_with_opener =
3794 current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
3795 if !ends_with_opener {
3796 current_line.push(' ');
3797 current_width += LineWidth::plain(1);
3798 }
3799 let start = current_line.len();
3800 current_line.push_str(&element_str);
3801 current_width += element_width;
3802 current_line_element_spans.push(ElementSpan::new(
3803 start,
3804 element_str.len(),
3805 element_len,
3806 element_width,
3807 is_hard,
3808 ));
3809 }
3810 } else {
3811 let ends_with_opener =
3813 current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
3814 if !current_width.is_empty() && !ends_with_opener {
3815 current_line.push(' ');
3816 current_width += LineWidth::plain(1);
3817 }
3818 let start = current_line.len();
3819 current_line.push_str(&element_str);
3820 current_width += element_width;
3821 current_line_element_spans.push(ElementSpan::new(
3822 start,
3823 element_str.len(),
3824 element_len,
3825 element_width,
3826 is_hard,
3827 ));
3828 }
3829 }
3830 }
3831 }
3832
3833 if !current_line.is_empty() {
3835 lines.push(current_line.trim_end_matches(is_breakable_whitespace).to_string());
3836 }
3837
3838 lines
3839}
3840
3841pub fn reflow_markdown(content: &str, options: &ReflowOptions) -> String {
3843 let lines: Vec<&str> = content.lines().collect();
3844 let mut result = Vec::new();
3845 let mut i = 0;
3846
3847 while i < lines.len() {
3848 let line = lines[i];
3849 let trimmed = line.trim();
3850
3851 if trimmed.is_empty() {
3853 result.push(String::new());
3854 i += 1;
3855 continue;
3856 }
3857
3858 if trimmed.starts_with('#') {
3860 result.push(line.to_string());
3861 i += 1;
3862 continue;
3863 }
3864
3865 if trimmed.starts_with(":::") {
3867 result.push(line.to_string());
3868 i += 1;
3869 continue;
3870 }
3871
3872 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
3874 result.push(line.to_string());
3875 i += 1;
3876 while i < lines.len() {
3878 result.push(lines[i].to_string());
3879 if lines[i].trim().starts_with("```") || lines[i].trim().starts_with("~~~") {
3880 i += 1;
3881 break;
3882 }
3883 i += 1;
3884 }
3885 continue;
3886 }
3887
3888 if calculate_indentation_width_default(line) >= 4 {
3890 result.push(line.to_string());
3892 i += 1;
3893 while i < lines.len() {
3894 let next_line = lines[i];
3895 if calculate_indentation_width_default(next_line) >= 4 || next_line.trim().is_empty() {
3897 result.push(next_line.to_string());
3898 i += 1;
3899 } else {
3900 break;
3901 }
3902 }
3903 continue;
3904 }
3905
3906 if trimmed.starts_with('>') {
3908 let gt_pos = line.find('>').expect("'>' must exist since trimmed.starts_with('>')");
3911 let quote_prefix = line[0..=gt_pos].to_string();
3912 let quote_content = &line[quote_prefix.len()..].trim_start();
3913
3914 let reflowed = reflow_line(quote_content, options);
3915 for reflowed_line in &reflowed {
3916 result.push(format!("{quote_prefix} {reflowed_line}"));
3917 }
3918 i += 1;
3919 continue;
3920 }
3921
3922 if is_horizontal_rule(trimmed) {
3924 result.push(line.to_string());
3925 i += 1;
3926 continue;
3927 }
3928
3929 if is_unordered_list_marker(trimmed) || is_numbered_list_item(trimmed) {
3931 let indent = line.len() - line.trim_start().len();
3933 let indent_str = " ".repeat(indent);
3934
3935 let mut marker_end = indent;
3938 let mut content_start = indent;
3939
3940 if trimmed.chars().next().is_some_and(char::is_numeric) {
3941 if let Some(period_pos) = line[indent..].find('.') {
3943 marker_end = indent + period_pos + 1; content_start = marker_end;
3945 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
3949 content_start += 1;
3950 }
3951 }
3952 } else {
3953 marker_end = indent + 1; content_start = marker_end;
3956 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
3960 content_start += 1;
3961 }
3962 }
3963
3964 let min_continuation_indent = content_start;
3966
3967 let rest = &line[content_start..];
3970 if rest.starts_with("[ ] ") || rest.starts_with("[x] ") || rest.starts_with("[X] ") {
3971 marker_end = content_start + 3; content_start += 4; }
3974
3975 let marker = &line[indent..marker_end];
3976
3977 let mut list_content = vec![trim_preserving_hard_break(&line[content_start..])];
3980 i += 1;
3981
3982 while i < lines.len() {
3986 let next_line = lines[i];
3987 let next_trimmed = next_line.trim();
3988
3989 if is_block_boundary(next_trimmed) {
3991 break;
3992 }
3993
3994 let next_indent = next_line.len() - next_line.trim_start().len();
3996 if next_indent >= min_continuation_indent {
3997 let trimmed_start = next_line.trim_start();
4000 list_content.push(trim_preserving_hard_break(trimmed_start));
4001 i += 1;
4002 } else {
4003 break;
4005 }
4006 }
4007
4008 let combined_content = if options.preserve_breaks {
4011 list_content[0].clone()
4012 } else {
4013 let has_hard_breaks = list_content.iter().any(|line| has_hard_break(line));
4015 if has_hard_breaks {
4016 list_content.join("\n")
4018 } else {
4019 list_content.join(" ")
4021 }
4022 };
4023
4024 let trimmed_marker = marker;
4026 let continuation_spaces = if let Some(max_indent) = options.max_list_continuation_indent {
4027 indent + (content_start - indent).min(max_indent)
4030 } else {
4031 content_start
4032 };
4033
4034 let prefix_length = indent + trimmed_marker.len() + 1;
4036
4037 let adjusted_options = ReflowOptions {
4039 line_length: options.line_length.saturating_sub(prefix_length),
4040 ..options.clone()
4041 };
4042
4043 let reflowed = reflow_line(&combined_content, &adjusted_options);
4044 for (j, reflowed_line) in reflowed.iter().enumerate() {
4045 if j == 0 {
4046 result.push(format!("{indent_str}{trimmed_marker} {reflowed_line}"));
4047 } else {
4048 let continuation_indent = " ".repeat(continuation_spaces);
4050 result.push(format!("{continuation_indent}{reflowed_line}"));
4051 }
4052 }
4053 continue;
4054 }
4055
4056 if crate::utils::table_utils::TableUtils::is_potential_table_row(line) {
4058 result.push(line.to_string());
4059 i += 1;
4060 continue;
4061 }
4062
4063 if trimmed.starts_with('[') && line.contains("]:") {
4065 result.push(line.to_string());
4066 i += 1;
4067 continue;
4068 }
4069
4070 if is_definition_list_item(trimmed) {
4072 result.push(line.to_string());
4073 i += 1;
4074 continue;
4075 }
4076
4077 let mut is_single_line_paragraph = true;
4079 if i + 1 < lines.len() {
4080 let next_trimmed = lines[i + 1].trim();
4081 if !is_block_boundary(next_trimmed) {
4083 is_single_line_paragraph = false;
4084 }
4085 }
4086
4087 if is_single_line_paragraph && line_fits(line, options) {
4089 result.push(line.to_string());
4090 i += 1;
4091 continue;
4092 }
4093
4094 let mut paragraph_parts = Vec::new();
4096 let mut current_part = vec![line];
4097 i += 1;
4098
4099 if options.preserve_breaks {
4101 let hard_break_type = if line.strip_suffix('\r').unwrap_or(line).ends_with('\\') {
4103 Some("\\")
4104 } else if line.ends_with(" ") {
4105 Some(" ")
4106 } else {
4107 None
4108 };
4109 let reflowed = reflow_line(line, options);
4110
4111 if let Some(break_marker) = hard_break_type {
4113 if !reflowed.is_empty() {
4114 let mut reflowed_with_break = reflowed;
4115 let last_idx = reflowed_with_break.len() - 1;
4116 if !has_hard_break(&reflowed_with_break[last_idx]) {
4117 reflowed_with_break[last_idx].push_str(break_marker);
4118 }
4119 result.extend(reflowed_with_break);
4120 }
4121 } else {
4122 result.extend(reflowed);
4123 }
4124 } else {
4125 while i < lines.len() {
4127 let prev_line = if !current_part.is_empty() {
4128 current_part.last().unwrap()
4129 } else {
4130 ""
4131 };
4132 let next_line = lines[i];
4133 let next_trimmed = next_line.trim();
4134
4135 if is_block_boundary(next_trimmed) {
4137 break;
4138 }
4139
4140 let prev_trimmed = prev_line.trim();
4143 let abbreviations = get_abbreviations(&options.abbreviations);
4144 let ends_with_sentence = (prev_trimmed.ends_with('.')
4145 || prev_trimmed.ends_with('!')
4146 || prev_trimmed.ends_with('?')
4147 || prev_trimmed.ends_with(".*")
4148 || prev_trimmed.ends_with("!*")
4149 || prev_trimmed.ends_with("?*")
4150 || prev_trimmed.ends_with("._")
4151 || prev_trimmed.ends_with("!_")
4152 || prev_trimmed.ends_with("?_")
4153 || prev_trimmed.ends_with(".\"")
4155 || prev_trimmed.ends_with("!\"")
4156 || prev_trimmed.ends_with("?\"")
4157 || prev_trimmed.ends_with(".'")
4158 || prev_trimmed.ends_with("!'")
4159 || prev_trimmed.ends_with("?'")
4160 || prev_trimmed.ends_with(".\u{201D}")
4161 || prev_trimmed.ends_with("!\u{201D}")
4162 || prev_trimmed.ends_with("?\u{201D}")
4163 || prev_trimmed.ends_with(".\u{2019}")
4164 || prev_trimmed.ends_with("!\u{2019}")
4165 || prev_trimmed.ends_with("?\u{2019}"))
4166 && !text_ends_with_abbreviation(
4167 prev_trimmed.trim_end_matches(['*', '_', '"', '\'', '\u{201D}', '\u{2019}']),
4168 &abbreviations,
4169 );
4170
4171 if has_hard_break(prev_line) || (options.sentence_per_line && ends_with_sentence) {
4172 paragraph_parts.push(current_part.join(" "));
4174 current_part = vec![next_line];
4175 } else {
4176 current_part.push(next_line);
4177 }
4178 i += 1;
4179 }
4180
4181 if !current_part.is_empty() {
4183 if current_part.len() == 1 {
4184 paragraph_parts.push(current_part[0].to_string());
4186 } else {
4187 paragraph_parts.push(current_part.join(" "));
4188 }
4189 }
4190
4191 for (j, part) in paragraph_parts.iter().enumerate() {
4193 let reflowed = reflow_line(part, options);
4194 result.extend(reflowed);
4195
4196 if j < paragraph_parts.len() - 1 && !result.is_empty() && !options.sentence_per_line {
4200 let last_idx = result.len() - 1;
4201 if !has_hard_break(&result[last_idx]) {
4202 result[last_idx].push_str(" ");
4203 }
4204 }
4205 }
4206 }
4207 }
4208
4209 let result_text = result.join("\n");
4211 if content.ends_with('\n') && !result_text.ends_with('\n') {
4212 format!("{result_text}\n")
4213 } else {
4214 result_text
4215 }
4216}
4217
4218#[derive(Debug, Clone)]
4220pub struct ParagraphReflow {
4221 pub start_byte: usize,
4223 pub end_byte: usize,
4225 pub reflowed_text: String,
4227}
4228
4229#[derive(Debug, Clone)]
4235pub struct BlockquoteLineData {
4236 pub(crate) content: String,
4238 pub(crate) is_explicit: bool,
4240 pub(crate) prefix: Option<String>,
4242}
4243
4244impl BlockquoteLineData {
4245 pub fn explicit(content: String, prefix: String) -> Self {
4247 Self {
4248 content,
4249 is_explicit: true,
4250 prefix: Some(prefix),
4251 }
4252 }
4253
4254 pub fn lazy(content: String) -> Self {
4256 Self {
4257 content,
4258 is_explicit: false,
4259 prefix: None,
4260 }
4261 }
4262}
4263
4264#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4266pub enum BlockquoteContinuationStyle {
4267 Explicit,
4268 Lazy,
4269}
4270
4271pub fn blockquote_continuation_style(lines: &[BlockquoteLineData]) -> BlockquoteContinuationStyle {
4279 let mut explicit_count = 0usize;
4280 let mut lazy_count = 0usize;
4281
4282 for line in lines.iter().skip(1) {
4283 if line.is_explicit {
4284 explicit_count += 1;
4285 } else {
4286 lazy_count += 1;
4287 }
4288 }
4289
4290 if explicit_count > 0 && lazy_count == 0 {
4291 BlockquoteContinuationStyle::Explicit
4292 } else if lazy_count > 0 && explicit_count == 0 {
4293 BlockquoteContinuationStyle::Lazy
4294 } else if explicit_count >= lazy_count {
4295 BlockquoteContinuationStyle::Explicit
4296 } else {
4297 BlockquoteContinuationStyle::Lazy
4298 }
4299}
4300
4301pub fn dominant_blockquote_prefix(lines: &[BlockquoteLineData], fallback: &str) -> String {
4306 let mut counts: std::collections::HashMap<String, (usize, usize)> = std::collections::HashMap::new();
4307
4308 for (idx, line) in lines.iter().enumerate() {
4309 let Some(prefix) = line.prefix.as_ref() else {
4310 continue;
4311 };
4312 counts
4313 .entry(prefix.clone())
4314 .and_modify(|entry| entry.0 += 1)
4315 .or_insert((1, idx));
4316 }
4317
4318 counts
4319 .into_iter()
4320 .max_by(|(_, (count_a, first_idx_a)), (_, (count_b, first_idx_b))| {
4321 count_a.cmp(count_b).then_with(|| first_idx_b.cmp(first_idx_a))
4322 })
4323 .map_or_else(|| fallback.to_string(), |(prefix, _)| prefix)
4324}
4325
4326pub(crate) fn should_force_explicit_blockquote_line(content_line: &str) -> bool {
4331 let trimmed = content_line.trim_start();
4332 trimmed.starts_with('>')
4333 || trimmed.starts_with('#')
4334 || trimmed.starts_with("```")
4335 || trimmed.starts_with("~~~")
4336 || is_unordered_list_marker(trimmed)
4337 || is_numbered_list_item(trimmed)
4338 || is_horizontal_rule(trimmed)
4339 || is_definition_list_item(trimmed)
4340 || (trimmed.starts_with('[') && trimmed.contains("]:"))
4341 || trimmed.starts_with(":::")
4342 || (trimmed.starts_with('<')
4343 && !trimmed.starts_with("<http")
4344 && !trimmed.starts_with("<https")
4345 && !trimmed.starts_with("<mailto:"))
4346}
4347
4348pub fn reflow_blockquote_content(
4357 lines: &[BlockquoteLineData],
4358 explicit_prefix: &str,
4359 continuation_style: BlockquoteContinuationStyle,
4360 options: &ReflowOptions,
4361) -> Vec<String> {
4362 let content_strs: Vec<&str> = lines.iter().map(|l| l.content.as_str()).collect();
4363 let segments = split_into_segments_strs(&content_strs);
4364 let mut reflowed_content_lines: Vec<String> = Vec::new();
4365
4366 for segment in segments {
4367 let hard_break_type = segment.last().and_then(|&line| {
4368 let line = line.strip_suffix('\r').unwrap_or(line);
4369 if line.ends_with('\\') {
4370 Some("\\")
4371 } else if line.ends_with(" ") {
4372 Some(" ")
4373 } else {
4374 None
4375 }
4376 });
4377
4378 let pieces: Vec<&str> = segment
4379 .iter()
4380 .map(|&line| {
4381 if let Some(l) = line.strip_suffix('\\') {
4382 l.trim_end()
4383 } else if let Some(l) = line.strip_suffix(" ") {
4384 l.trim_end()
4385 } else {
4386 line.trim_end()
4387 }
4388 })
4389 .collect();
4390
4391 let segment_text = pieces.join(" ");
4392 let segment_text = segment_text.trim();
4393 if segment_text.is_empty() {
4394 continue;
4395 }
4396
4397 let mut reflowed = reflow_line(segment_text, options);
4398 if let Some(break_marker) = hard_break_type
4399 && !reflowed.is_empty()
4400 {
4401 let last_idx = reflowed.len() - 1;
4402 if !has_hard_break(&reflowed[last_idx]) {
4403 reflowed[last_idx].push_str(break_marker);
4404 }
4405 }
4406 reflowed_content_lines.extend(reflowed);
4407 }
4408
4409 let mut styled_lines: Vec<String> = Vec::new();
4410 for (idx, line) in reflowed_content_lines.iter().enumerate() {
4411 let force_explicit = idx == 0
4412 || continuation_style == BlockquoteContinuationStyle::Explicit
4413 || should_force_explicit_blockquote_line(line);
4414 if force_explicit {
4415 styled_lines.push(format!("{explicit_prefix}{line}"));
4416 } else {
4417 styled_lines.push(line.clone());
4418 }
4419 }
4420
4421 styled_lines
4422}
4423
4424fn is_blockquote_content_boundary(content: &str) -> bool {
4425 let trimmed = content.trim();
4426 trimmed.is_empty()
4427 || is_block_boundary(trimmed)
4428 || crate::utils::table_utils::TableUtils::is_potential_table_row(content)
4429 || trimmed.starts_with(":::")
4430 || crate::utils::is_template_directive_only(content)
4431 || is_standalone_attr_list(content)
4432 || is_snippet_block_delimiter(content)
4433}
4434
4435fn split_into_segments_strs<'a>(lines: &[&'a str]) -> Vec<Vec<&'a str>> {
4436 let mut segments = Vec::new();
4437 let mut current = Vec::new();
4438
4439 for &line in lines {
4440 current.push(line);
4441 if has_hard_break(line) {
4442 segments.push(current);
4443 current = Vec::new();
4444 }
4445 }
4446
4447 if !current.is_empty() {
4448 segments.push(current);
4449 }
4450
4451 segments
4452}
4453
4454fn reflow_blockquote_paragraph_at_line(
4455 content: &str,
4456 lines: &[&str],
4457 target_idx: usize,
4458 options: &ReflowOptions,
4459) -> Option<ParagraphReflow> {
4460 let mut anchor_idx = target_idx;
4461 let mut target_level = if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[target_idx]) {
4462 parsed.nesting_level
4463 } else {
4464 let mut found = None;
4465 let mut idx = target_idx;
4466 loop {
4467 if lines[idx].trim().is_empty() {
4468 break;
4469 }
4470 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[idx]) {
4471 found = Some((idx, parsed.nesting_level));
4472 break;
4473 }
4474 if idx == 0 {
4475 break;
4476 }
4477 idx -= 1;
4478 }
4479 let (idx, level) = found?;
4480 anchor_idx = idx;
4481 level
4482 };
4483
4484 let mut para_start = anchor_idx;
4486 while para_start > 0 {
4487 let prev_idx = para_start - 1;
4488 let prev_line = lines[prev_idx];
4489
4490 if prev_line.trim().is_empty() {
4491 break;
4492 }
4493
4494 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(prev_line) {
4495 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
4496 break;
4497 }
4498 para_start = prev_idx;
4499 continue;
4500 }
4501
4502 let prev_lazy = prev_line.trim_start();
4503 if is_blockquote_content_boundary(prev_lazy) {
4504 break;
4505 }
4506 para_start = prev_idx;
4507 }
4508
4509 while para_start < lines.len() {
4511 let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[para_start]) else {
4512 para_start += 1;
4513 continue;
4514 };
4515 target_level = parsed.nesting_level;
4516 break;
4517 }
4518
4519 if para_start >= lines.len() || para_start > target_idx {
4520 return None;
4521 }
4522
4523 let mut collected: Vec<(usize, BlockquoteLineData)> = Vec::new();
4526 let mut idx = para_start;
4527 while idx < lines.len() {
4528 if !collected.is_empty() && has_hard_break(&collected[collected.len() - 1].1.content) {
4529 break;
4530 }
4531
4532 let line = lines[idx];
4533 if line.trim().is_empty() {
4534 break;
4535 }
4536
4537 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(line) {
4538 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
4539 break;
4540 }
4541 collected.push((
4542 idx,
4543 BlockquoteLineData::explicit(trim_preserving_hard_break(parsed.content), parsed.prefix.to_string()),
4544 ));
4545 idx += 1;
4546 continue;
4547 }
4548
4549 let lazy_content = line.trim_start();
4550 if is_blockquote_content_boundary(lazy_content) {
4551 break;
4552 }
4553
4554 collected.push((idx, BlockquoteLineData::lazy(trim_preserving_hard_break(lazy_content))));
4555 idx += 1;
4556 }
4557
4558 if collected.is_empty() {
4559 return None;
4560 }
4561
4562 let para_end = collected[collected.len() - 1].0;
4563 if target_idx < para_start || target_idx > para_end {
4564 return None;
4565 }
4566
4567 let line_data: Vec<BlockquoteLineData> = collected.iter().map(|(_, d)| d.clone()).collect();
4568
4569 let fallback_prefix = line_data
4570 .iter()
4571 .find_map(|d| d.prefix.clone())
4572 .unwrap_or_else(|| "> ".to_string());
4573 let explicit_prefix = dominant_blockquote_prefix(&line_data, &fallback_prefix);
4574 let continuation_style = blockquote_continuation_style(&line_data);
4575
4576 let adjusted_line_length = options
4577 .line_length
4578 .saturating_sub(display_len(&explicit_prefix, options.length_mode))
4579 .max(1);
4580
4581 let adjusted_options = ReflowOptions {
4582 line_length: adjusted_line_length,
4583 ..options.clone()
4584 };
4585
4586 let styled_lines = reflow_blockquote_content(&line_data, &explicit_prefix, continuation_style, &adjusted_options);
4587
4588 if styled_lines.is_empty() {
4589 return None;
4590 }
4591
4592 let mut start_byte = 0;
4594 for line in lines.iter().take(para_start) {
4595 start_byte += line.len() + 1;
4596 }
4597
4598 let mut end_byte = start_byte;
4599 for line in lines.iter().take(para_end + 1).skip(para_start) {
4600 end_byte += line.len() + 1;
4601 }
4602
4603 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
4604 if !includes_trailing_newline {
4605 end_byte -= 1;
4606 }
4607
4608 let reflowed_joined = styled_lines.join("\n");
4609 let reflowed_text = if includes_trailing_newline {
4610 if reflowed_joined.ends_with('\n') {
4611 reflowed_joined
4612 } else {
4613 format!("{reflowed_joined}\n")
4614 }
4615 } else if reflowed_joined.ends_with('\n') {
4616 reflowed_joined.trim_end_matches('\n').to_string()
4617 } else {
4618 reflowed_joined
4619 };
4620
4621 Some(ParagraphReflow {
4622 start_byte,
4623 end_byte,
4624 reflowed_text,
4625 })
4626}
4627
4628pub fn reflow_paragraph_at_line(content: &str, line_number: usize, line_length: usize) -> Option<ParagraphReflow> {
4646 reflow_paragraph_at_line_with_mode(content, line_number, line_length, ReflowLengthMode::default())
4647}
4648
4649pub fn reflow_paragraph_at_line_with_mode(
4651 content: &str,
4652 line_number: usize,
4653 line_length: usize,
4654 length_mode: ReflowLengthMode,
4655) -> Option<ParagraphReflow> {
4656 let options = ReflowOptions {
4657 line_length,
4658 length_mode,
4659 ..Default::default()
4660 };
4661 reflow_paragraph_at_line_with_options(content, line_number, &options)
4662}
4663
4664pub fn reflow_paragraph_at_line_with_options(
4675 content: &str,
4676 line_number: usize,
4677 options: &ReflowOptions,
4678) -> Option<ParagraphReflow> {
4679 if line_number == 0 {
4680 return None;
4681 }
4682
4683 let lines: Vec<&str> = content.lines().collect();
4684
4685 if line_number > lines.len() {
4687 return None;
4688 }
4689
4690 let target_idx = line_number - 1; let target_line = lines[target_idx];
4692 let trimmed = target_line.trim();
4693
4694 if let Some(blockquote_reflow) = reflow_blockquote_paragraph_at_line(content, &lines, target_idx, options) {
4697 return Some(blockquote_reflow);
4698 }
4699
4700 if is_paragraph_boundary(trimmed, target_line) {
4702 return None;
4703 }
4704
4705 let mut para_start = target_idx;
4707 while para_start > 0 {
4708 let prev_idx = para_start - 1;
4709 let prev_line = lines[prev_idx];
4710 let prev_trimmed = prev_line.trim();
4711
4712 if is_paragraph_boundary(prev_trimmed, prev_line) {
4714 break;
4715 }
4716
4717 para_start = prev_idx;
4718 }
4719
4720 let mut para_end = target_idx;
4722 while para_end + 1 < lines.len() {
4723 let next_idx = para_end + 1;
4724 let next_line = lines[next_idx];
4725 let next_trimmed = next_line.trim();
4726
4727 if is_paragraph_boundary(next_trimmed, next_line) {
4729 break;
4730 }
4731
4732 para_end = next_idx;
4733 }
4734
4735 let paragraph_lines = &lines[para_start..=para_end];
4737
4738 let mut start_byte = 0;
4740 for line in lines.iter().take(para_start) {
4741 start_byte += line.len() + 1; }
4743
4744 let mut end_byte = start_byte;
4745 for line in paragraph_lines {
4746 end_byte += line.len() + 1; }
4748
4749 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
4752
4753 if !includes_trailing_newline {
4755 end_byte -= 1;
4756 }
4757
4758 let paragraph_text = paragraph_lines.join("\n");
4760
4761 let reflowed = reflow_markdown(¶graph_text, options);
4763
4764 let reflowed_text = if includes_trailing_newline {
4768 if reflowed.ends_with('\n') {
4770 reflowed
4771 } else {
4772 format!("{reflowed}\n")
4773 }
4774 } else {
4775 if reflowed.ends_with('\n') {
4777 reflowed.trim_end_matches('\n').to_string()
4778 } else {
4779 reflowed
4780 }
4781 };
4782
4783 Some(ParagraphReflow {
4784 start_byte,
4785 end_byte,
4786 reflowed_text,
4787 })
4788}
4789fn decompose_code_span(raw: &str) -> Option<(&str, &str)> {
4795 let marker_len = raw.bytes().take_while(|&b| b == b'`').count();
4796 if marker_len == 0 {
4797 return None;
4798 }
4799 let marker = &raw[..marker_len];
4800 if raw.len() < marker_len * 2 {
4801 return None;
4802 }
4803 let content = &raw[marker_len..raw.len() - marker_len];
4804 Some((content, marker))
4805}
4806
4807#[cfg(test)]
4808mod tests {
4809 use super::*;
4810
4811 #[test]
4815 fn preserves_content_accepts_whitespace_changes_and_rejects_the_rest() {
4816 let accepted: &[(&str, &[&str])] = &[
4817 ("one two three", &["one two three"]),
4818 ("one two three", &["one two", "three"]),
4819 ("one two three", &["one", "two", "three"]),
4820 ("one two ", &["one two"]),
4822 ("日本語のテキスト", &["日本語の", "テキスト"]),
4824 ("_First. Second._", &["_First.", "Second._"]),
4826 ];
4827 for (original, reflowed) in accepted {
4828 let reflowed: Vec<String> = reflowed.iter().map(ToString::to_string).collect();
4829 assert!(
4830 preserves_content(original, &reflowed),
4831 "{original:?} -> {reflowed:?} only moves whitespace"
4832 );
4833 }
4834
4835 let rejected: &[(&str, &[&str])] = &[
4836 ("one two three", &["one two"]),
4838 ("one two", &["one two three"]),
4840 ("one two", &["two one"]),
4842 ("_First. Second._", &["_First._", "_Second._"]),
4844 ("alpha and beta", &["alpha", "andbeta"]),
4846 ("mot suivant : autre", &["mot suivant: autre"]),
4848 ];
4849 for (original, reflowed) in rejected {
4850 let reflowed: Vec<String> = reflowed.iter().map(ToString::to_string).collect();
4851 assert!(
4852 !preserves_content(original, &reflowed),
4853 "{original:?} -> {reflowed:?} changes the text, not just its line breaks"
4854 );
4855 }
4856 }
4857
4858 #[test]
4860 fn reflow_line_falls_back_to_the_input_when_content_would_change() {
4861 let options = ReflowOptions {
4862 line_length: 40,
4863 ..Default::default()
4864 };
4865 let line = "one two three four five six seven eight nine ten";
4866
4867 assert!(preserves_content(line, &reflow_line(line, &options)));
4868 assert_eq!(reflow_line(line, &options), reflow_line_unchecked(line, &options));
4869 }
4870
4871 #[test]
4872 fn cascade_split_line_handles_a_very_long_line_without_overflowing() {
4873 let words: Vec<String> = (0..4000).map(|i| format!("word{i}")).collect();
4879 let line = words.join(" ");
4880
4881 let options = ReflowOptions {
4882 line_length: 80,
4883 length_mode: ReflowLengthMode::Chars,
4884 ..Default::default()
4885 };
4886 let out = cascade_split_line(&line, &options);
4887
4888 assert!(out.len() > 1, "a very long line should split into many lines");
4889 for segment in &out {
4890 assert!(
4891 display_len(segment, ReflowLengthMode::Chars) <= 80 || !segment.contains(' '),
4892 "each wrapped line should fit the width (or be a single unbreakable token)"
4893 );
4894 }
4895 let rejoined = out.join(" ");
4897 let original_words: Vec<&str> = line.split(' ').collect();
4898 let result_words: Vec<&str> = rejoined.split_whitespace().collect();
4899 assert_eq!(original_words, result_words, "reflow must preserve all words in order");
4900 }
4901
4902 #[test]
4907 fn test_helper_function_text_ends_with_abbreviation() {
4908 let abbreviations = get_abbreviations(&None);
4910
4911 assert!(text_ends_with_abbreviation("Dr.", &abbreviations));
4913 assert!(text_ends_with_abbreviation("word Dr.", &abbreviations));
4914 assert!(text_ends_with_abbreviation("e.g.", &abbreviations));
4915 assert!(text_ends_with_abbreviation("i.e.", &abbreviations));
4916 assert!(text_ends_with_abbreviation("Mr.", &abbreviations));
4917 assert!(text_ends_with_abbreviation("Mrs.", &abbreviations));
4918 assert!(text_ends_with_abbreviation("Ms.", &abbreviations));
4919 assert!(text_ends_with_abbreviation("Prof.", &abbreviations));
4920
4921 assert!(!text_ends_with_abbreviation("etc.", &abbreviations));
4923 assert!(!text_ends_with_abbreviation("paradigms.", &abbreviations));
4924 assert!(!text_ends_with_abbreviation("programs.", &abbreviations));
4925 assert!(!text_ends_with_abbreviation("items.", &abbreviations));
4926 assert!(!text_ends_with_abbreviation("systems.", &abbreviations));
4927 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)); }
4933
4934 #[test]
4935 fn test_footnote_after_period_splits_sentence() {
4936 let text = "First sentence.[^1] Second sentence.";
4940 let sentences = split_into_sentences(text, None);
4941 assert_eq!(
4942 sentences,
4943 vec!["First sentence.[^1]".to_string(), "Second sentence.".to_string()],
4944 "footnote glued to the period should keep the boundary and stay attached to the first sentence"
4945 );
4946 }
4947
4948 #[test]
4949 fn test_multiple_consecutive_footnotes_after_period_splits_sentence() {
4950 let text = "Notes here.[^1][^2] Second sentence.";
4952 let sentences = split_into_sentences(text, None);
4953 assert_eq!(
4954 sentences,
4955 vec!["Notes here.[^1][^2]".to_string(), "Second sentence.".to_string()]
4956 );
4957 }
4958
4959 #[test]
4960 fn test_footnote_before_period_still_splits_sentence() {
4961 let text = "Annotation here[^1]. Second sentence.";
4965 let sentences = split_into_sentences(text, None);
4966 assert_eq!(
4967 sentences,
4968 vec!["Annotation here[^1].".to_string(), "Second sentence.".to_string()]
4969 );
4970 }
4971
4972 #[test]
4973 fn test_mid_sentence_footnote_does_not_split() {
4974 let text = "The system word[^1] more words. Next sentence.";
4977 let sentences = split_into_sentences(text, None);
4978 assert_eq!(
4979 sentences,
4980 vec![
4981 "The system word[^1] more words.".to_string(),
4982 "Next sentence.".to_string()
4983 ]
4984 );
4985 }
4986
4987 #[test]
4988 fn test_bare_numeric_bracket_after_period_does_not_split() {
4989 let text = "Citation here.[1] Second sentence.";
4992 let sentences = split_into_sentences(text, None);
4993 assert_eq!(
4994 sentences,
4995 vec![text.to_string()],
4996 "a bare numeric bracket must not be treated as a sentence boundary"
4997 );
4998 }
4999
5000 #[test]
5001 fn test_footnote_glued_to_following_word_does_not_split() {
5002 let text = "First sentence.[^1]Continued glued text.";
5005 let sentences = split_into_sentences(text, None);
5006 assert_eq!(sentences, vec![text.to_string()]);
5007 }
5008
5009 #[test]
5010 fn test_footnote_at_end_of_text_is_preserved() {
5011 let text = "Sentence.[^1]";
5014 let sentences = split_into_sentences(text, None);
5015 assert_eq!(sentences, vec![text.to_string()]);
5016 }
5017
5018 #[test]
5019 fn test_abbreviation_before_footnote_does_not_split() {
5020 let text = "See the notes, e.g.[^1] this one.";
5023 let sentences = split_into_sentences(text, None);
5024 assert_eq!(
5025 sentences,
5026 vec![text.to_string()],
5027 "e.g. is an abbreviation, not a sentence boundary"
5028 );
5029 }
5030
5031 #[test]
5032 fn sentence_boundary_never_falls_inside_an_atomic_construct() {
5033 let cases = [
5039 "Prefix [link. Still link](https://example.com) tail. Next sentence.",
5040 "Prefix [target](<https://example.com/First. Second>) tail. Next sentence.",
5041 "Prefix [text](url \"Title. More\") tail. Next sentence.",
5042 "Prefix  tail. Next sentence.",
5043 "Prefix [ref text. More][ref] tail. Next sentence.",
5044 "Prefix [collapsed. More][] tail. Next sentence.",
5045 "Prefix [[Page name. Title]] tail. Next sentence.",
5046 "Prefix $x. Y$ tail. Next sentence.",
5047 "Prefix $$x. Y$$ tail. Next sentence.",
5048 "Prefix <span title=\"A. B\">x</span> tail. Next sentence.",
5049 "Prefix `code. Still code` tail. Next sentence.",
5050 ];
5051 for text in cases {
5052 let sentences = split_into_sentences(text, None);
5053 let (head, tail) = text.rsplit_once(" tail. ").expect("case has a tail");
5054 assert_eq!(
5055 sentences,
5056 vec![format!("{head} tail."), tail.to_string()],
5057 "input {text:?}"
5058 );
5059 }
5060
5061 let text = "Prefix [shortcut. More] tail. Next sentence.";
5065 let whole = vec![
5066 "Prefix [shortcut. More] tail.".to_string(),
5067 "Next sentence.".to_string(),
5068 ];
5069 let defined = HashSet::from(["shortcut. more".to_string()]);
5070 assert_eq!(split_into_sentences(text, Some(&defined)), whole);
5071 assert_eq!(split_into_sentences(text, None), whole);
5072 assert_eq!(
5073 split_into_sentences(text, Some(&HashSet::new())),
5074 vec!["Prefix [shortcut.", "More] tail.", "Next sentence."]
5075 );
5076 }
5077
5078 #[test]
5079 fn a_sentence_may_open_with_a_link_or_image() {
5080 for text in [
5085 "Opening sentence. [First. Second](https://example.com)",
5086 "Opening sentence. ",
5087 "Opening sentence. [[First. Second]]",
5088 "Opening sentence. [[first-note|First. Second]]",
5089 "Opening sentence. [Ref link][ref]",
5090 "Opening sentence. [](url) continues.",
5093 "Opening sentence. [][ref] continues.",
5094 "Opening sentence. [![First image][img]](url) continues.",
5097 "Opening sentence. [![First image][]](url) continues.",
5098 "Opening sentence. [![First image][img]][ref] continues.",
5099 ] {
5100 let (head, tail) = text.split_once(". ").expect("case has a boundary");
5101 assert_eq!(
5102 split_into_sentences(text, None),
5103 vec![format!("{head}."), tail.to_string()],
5104 "input {text:?}"
5105 );
5106 }
5107 let text = "Opening sentence. [![First image]](url) continues.";
5110 let defined = HashSet::from(["first image".to_string()]);
5111 assert_eq!(
5112 split_into_sentences(text, Some(&defined)),
5113 vec!["Opening sentence.", "[![First image]](url) continues."]
5114 );
5115 assert_eq!(
5116 split_into_sentences(text, Some(&HashSet::new())),
5117 vec![text.to_string()],
5118 "an undefined shortcut is bracketed text, and `!` opens no sentence"
5119 );
5120 assert_eq!(
5123 split_into_sentences("Opening sentence. [](url) continues.", None),
5124 vec](url) continues."]
5125 );
5126 let defined = HashSet::from(["smith 2020".to_string()]);
5129 assert_eq!(
5130 split_into_sentences("Claim ends here. [Smith 2020] more text.", Some(&defined)),
5131 vec!["Claim ends here.", "[Smith 2020] more text."]
5132 );
5133 let none_defined = HashSet::new();
5139 for text in [
5140 "Opening sentence. [first link](https://example.com) continues.",
5141 "Opening sentence. [[first note]] continues.",
5142 "Opening sentence. [[First Note|first note]] continues.",
5143 "Opening sentence. [[Page continues.",
5144 "Opening sentence. [[First] stray]] continues.",
5145 "Opening sentence.  continues.",
5146 "Opening sentence. [1] is the citation.",
5147 "Opening sentence. [First](unterminated",
5148 "Opening sentence. [First][unterminated",
5149 "Opening sentence. [First] (aside) continues.",
5150 "Claim ends here. [Smith 2020]",
5151 "Claim ends here. [Smith 2020] more text.",
5152 "See the RFC. [RFC] More text.",
5153 "Claim ends here. [^Note] more text.",
5154 ] {
5155 assert_eq!(
5156 split_into_sentences(text, Some(&none_defined)),
5157 vec![text.to_string()],
5158 "input {text:?}"
5159 );
5160 }
5161 }
5162
5163 #[test]
5164 fn link_opener_is_read_off_the_parse() {
5165 let len = |text: &str, defs: Option<&HashSet<String>>| {
5168 let chars: Vec<char> = text.chars().collect();
5169 let char_offsets = char_byte_offsets(&chars);
5170 let NestedStructure { links, .. } = sentence_structure(text, defs);
5171 let st = SentenceText {
5172 text,
5173 chars: &chars,
5174 char_offsets: &char_offsets,
5175 links: &links,
5176 };
5177 st.link_end_at(0).map_or(0, |end| link_opener_len(&chars, 0, end))
5178 };
5179 let none = HashSet::new();
5180 assert_eq!(len("[text](url)", Some(&none)), 1);
5181 assert_eq!(
5182 len("[text][ref]", Some(&none)),
5183 1,
5184 "a full reference is a link whether or not defined"
5185 );
5186 assert_eq!(len("[text][]", Some(&none)), 1);
5187 assert_eq!(len("", Some(&none)), 2);
5188 assert_eq!(len("[[wiki]]", Some(&none)), 2);
5189 assert_eq!(
5190 len("[[wiki|shown]]", Some(&none)),
5191 7,
5192 "the displayed text starts after the alias pipe"
5193 );
5194 assert_eq!(len("![[img.png|100]]", Some(&none)), 11);
5195 assert_eq!(len("[[wiki|a|b]]", Some(&none)), 7, "the first pipe starts the alias");
5196 assert_eq!(
5197 len("[[wiki|shown]] [[a|b]]", Some(&none)),
5198 7,
5199 "a pipe past the closing `]]` is not this alias"
5200 );
5201 assert_eq!(
5202 len("[a \\] b](url)", Some(&none)),
5203 1,
5204 "an escaped bracket does not close the text"
5205 );
5206 assert_eq!(
5207 len("[](url)", Some(&none)),
5208 1,
5209 "the outer opener is skipped first"
5210 );
5211 for text in [
5215 "[^1]",
5216 "[text](unterminated",
5217 "[text][unterminated",
5218 "[text] (url)",
5219 "[[wiki",
5220 "[[wiki]",
5221 "[[First] stray]]",
5222 "[Smith 2020]",
5223 "[Smith 2020] (see also)",
5224 "[unclosed",
5225 "!bang",
5226 "text",
5227 ] {
5228 assert_eq!(len(text, Some(&none)), 0, "input {text:?}");
5229 }
5230 let smith = HashSet::from(["smith 2020".to_string()]);
5233 assert_eq!(len("[Smith 2020]", Some(&smith)), 1);
5234 assert_eq!(len("[Smith 2020]", None), 1);
5235 }
5236
5237 #[test]
5238 fn sentence_per_line_reflow_breaks_before_a_bracket_only_where_the_check_counts() {
5239 let defined = HashSet::from(["spec".to_string()]);
5247 let options = ReflowOptions {
5248 line_length: 120,
5249 sentence_per_line: true,
5250 defined_references: Some(defined.clone()),
5251 ..Default::default()
5252 };
5253 for (text, expected) in [
5254 (
5255 "Claim ends here. [Smith](https://example.com) more text. Second sentence.",
5256 vec more text.",
5259 "Second sentence.",
5260 ],
5261 ),
5262 (
5263 "Wow! [smith](https://example.com) more text. Second sentence.",
5264 vec more text.", "Second sentence."],
5265 ),
5266 (
5267 "Claim ends here. [smith](https://example.com) more text. Second sentence.",
5268 vec more text.",
5270 "Second sentence.",
5271 ],
5272 ),
5273 (
5274 "Claim ends here. [smith][ref] more text. Second sentence.",
5275 vec!["Claim ends here. [smith][ref] more text.", "Second sentence."],
5276 ),
5277 (
5278 "Claim ends here.  more text. Second sentence.",
5279 vec more text.", "Second sentence."],
5280 ),
5281 (
5282 "Claim ends here.[Link](https://example.com) more text. Second sentence.",
5283 vec more text.",
5285 "Second sentence.",
5286 ],
5287 ),
5288 (
5289 "See the RFC. [RFC] More text. Second sentence.",
5290 vec!["See the RFC. [RFC] More text.", "Second sentence."],
5291 ),
5292 (
5293 "See the spec. [Spec] More text. Second sentence.",
5294 vec!["See the spec.", "[Spec] More text.", "Second sentence."],
5295 ),
5296 (
5297 "See the spec. [spec] more text. Second sentence.",
5298 vec!["See the spec. [spec] more text.", "Second sentence."],
5299 ),
5300 (
5301 "Claim ends here. [[page|Second sentence]] continues. Third sentence.",
5302 vec![
5303 "Claim ends here.",
5304 "[[page|Second sentence]] continues.",
5305 "Third sentence.",
5306 ],
5307 ),
5308 (
5309 "Claim ends here. [[Page|second sentence]] continues. Third sentence.",
5310 vec![
5311 "Claim ends here. [[Page|second sentence]] continues.",
5312 "Third sentence.",
5313 ],
5314 ),
5315 ] {
5316 let lines = reflow_line(text, &options);
5317 assert_eq!(lines, expected, "input {text:?}");
5318 assert_eq!(
5321 split_into_sentences(text, Some(&defined)).len(),
5322 expected.len(),
5323 "check count for {text:?}"
5324 );
5325 for line in &lines {
5326 assert_eq!(
5327 split_into_sentences(line, Some(&defined)).len(),
5328 1,
5329 "line {line:?} of {text:?}"
5330 );
5331 }
5332 }
5333 }
5334
5335 #[test]
5336 fn sentence_per_line_reflow_holds_atomic_constructs_whole() {
5337 let options = ReflowOptions {
5341 line_length: 80,
5342 sentence_per_line: true,
5343 ..Default::default()
5344 };
5345 let lines = reflow_line(
5346 "Prefix `code. Still code` and [link. Still link](https://example.com) tail. Next sentence.",
5347 &options,
5348 );
5349 assert_eq!(
5350 lines,
5351 vec tail.".to_string(),
5353 "Next sentence.".to_string(),
5354 ]
5355 );
5356
5357 let lines = reflow_line(
5358 "Prefix  and [target](<https://example.com/First. Second>) tail. Next sentence.",
5359 &options,
5360 );
5361 assert_eq!(
5362 lines,
5363 vec and [target](<https://example.com/First. Second>) tail.".to_string(),
5365 "Next sentence.".to_string(),
5366 ]
5367 );
5368
5369 let lines = reflow_line("First one. Then [link](url) second. Third one.", &options);
5372 assert_eq!(
5373 lines,
5374 vec second.".to_string(),
5377 "Third one.".to_string(),
5378 ]
5379 );
5380 }
5381
5382 #[test]
5383 fn test_is_unordered_list_marker() {
5384 assert!(is_unordered_list_marker("- item"));
5386 assert!(is_unordered_list_marker("* item"));
5387 assert!(is_unordered_list_marker("+ item"));
5388 assert!(is_unordered_list_marker("-")); assert!(is_unordered_list_marker("*"));
5390 assert!(is_unordered_list_marker("+"));
5391
5392 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")); }
5403
5404 #[test]
5405 fn test_is_block_boundary() {
5406 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"));
5428 assert!(!is_block_boundary("*emphasis*")); assert!(!is_block_boundary("[link](url)")); assert!(!is_block_boundary("some words here"));
5431 }
5432
5433 #[test]
5434 fn test_definition_list_boundary_in_single_line_paragraph() {
5435 let options = ReflowOptions {
5438 line_length: 80,
5439 ..Default::default()
5440 };
5441 let input = "Term\n: Definition of the term";
5442 let result = reflow_markdown(input, &options);
5443 assert!(
5445 result.contains(": Definition"),
5446 "Definition list item should not be merged into previous line. Got: {result:?}"
5447 );
5448 let lines: Vec<&str> = result.lines().collect();
5449 assert_eq!(lines.len(), 2, "Should remain two separate lines. Got: {lines:?}");
5450 assert_eq!(lines[0], "Term");
5451 assert_eq!(lines[1], ": Definition of the term");
5452 }
5453
5454 #[test]
5455 fn test_is_paragraph_boundary() {
5456 assert!(is_paragraph_boundary("# Heading", "# Heading"));
5458 assert!(is_paragraph_boundary("- item", "- item"));
5459 assert!(is_paragraph_boundary(":::", ":::"));
5460 assert!(is_paragraph_boundary(": definition", ": definition"));
5461
5462 assert!(is_paragraph_boundary("code", " code"));
5464 assert!(is_paragraph_boundary("code", "\tcode"));
5465
5466 assert!(is_paragraph_boundary("| a | b |", "| a | b |"));
5468 assert!(is_paragraph_boundary("a | b", "a | b")); assert!(!is_paragraph_boundary("regular text", "regular text"));
5472 assert!(!is_paragraph_boundary("text", " text")); }
5474
5475 #[test]
5476 fn test_div_marker_boundary_in_reflow_paragraph_at_line() {
5477 let content = "Some paragraph text here.\n\n::: {.callout-note}\nThis is a callout.\n:::\n";
5480 let result = reflow_paragraph_at_line(content, 3, 80);
5482 assert!(result.is_none(), "Div marker line should not be reflowed");
5483 }
5484
5485 #[test]
5486 fn starts_block_construct_detects_block_openers() {
5487 for case in ["- item", "-", "* item", "*", "+ item", "+", "-\titem"] {
5489 assert!(starts_block_construct(case), "bullet: {case:?}");
5490 }
5491 for case in ["1. item", "1) item", "01. x", "000000001. x", "1.\titem"] {
5494 assert!(starts_block_construct(case), "ordered: {case:?}");
5495 }
5496 for case in ["> quote", ">quote", ">"] {
5498 assert!(starts_block_construct(case), "blockquote: {case:?}");
5499 }
5500 for case in ["# heading", "###### h6", "#", "##"] {
5502 assert!(starts_block_construct(case), "heading: {case:?}");
5503 }
5504 for case in ["```", "```rust", "````", "~~~", "~~~text"] {
5506 assert!(starts_block_construct(case), "fence: {case:?}");
5507 }
5508 for case in ["---", "--", "===", "=", "***", "___", "_ _ _", "- - -"] {
5510 assert!(starts_block_construct(case), "setext/thematic: {case:?}");
5511 }
5512 for case in [
5515 "[^1]: text",
5516 "[^note]:",
5517 "[ref]: http://example.com",
5518 "[wat]: url follows",
5519 ] {
5520 assert!(starts_block_construct(case), "definition: {case:?}");
5521 }
5522 for case in ["<div>content", "</div>", "<p>text", "<table>", "<pre>code", "<h1>x"] {
5524 assert!(starts_block_construct(case), "html block: {case:?}");
5525 }
5526 }
5527
5528 #[test]
5529 fn starts_block_construct_allows_ordinary_prose() {
5530 for case in [
5531 "",
5532 "word",
5533 "-5 degrees",
5534 "--flag",
5535 "-item",
5536 "#hashtag",
5537 "####### seven hashes is not a heading",
5538 "1.5 million",
5539 "1234567890. ten digits is not a list marker",
5540 "0000000001. ten digits is not a list marker either",
5541 "2. item",
5544 "7. item",
5545 "0. item",
5546 "42) x",
5547 "123456. item",
5548 "1.",
5549 "1)",
5550 "123456.",
5551 "123456)",
5552 "1.item",
5553 "1:30 pm",
5554 "*emphasis*",
5555 "**bold** text",
5556 "__bold__ text",
5557 "_emphasis_ text",
5558 "`code` span",
5559 "`` double backtick span ``",
5560 "~~strikethrough~~",
5561 "=x",
5562 "== ==",
5563 "(parenthetical)",
5564 "[link](url)",
5565 "[text][ref] more",
5566 "[bracketed] aside",
5567 "[a](b) [ref]: first bracket is a link, not a label",
5568 "[esc\\]: not a close] text",
5569 "<span>inline</span>",
5570 "<b>bold</b>",
5571 "<https://example.com> autolink",
5572 "<mailto:a@b.com>",
5573 "<notarealtag>",
5574 ] {
5575 assert!(!starts_block_construct(case), "prose: {case:?}");
5576 }
5577 }
5578
5579 #[test]
5580 fn merge_block_construct_continuations_merges_marker_led_lines() {
5581 let lines = vec![
5582 "First sentence?".to_string(),
5583 "- looks like a list item".to_string(),
5584 "Second sentence.".to_string(),
5585 ];
5586 assert_eq!(
5587 merge_block_construct_continuations(lines),
5588 vec![
5589 "First sentence? - looks like a list item".to_string(),
5590 "Second sentence.".to_string(),
5591 ]
5592 );
5593
5594 let lines = vec!["- real list content".to_string(), "continuation".to_string()];
5597 assert_eq!(
5598 merge_block_construct_continuations(lines.clone()),
5599 lines,
5600 "first line must never be merged"
5601 );
5602
5603 let lines = vec!["prose".to_string(), "1.".to_string(), "[ref]:".to_string()];
5606 assert_eq!(
5607 merge_block_construct_continuations(lines),
5608 vec!["prose 1. [ref]:".to_string()],
5609 "a merge that creates an opener must fold again"
5610 );
5611 }
5612
5613 #[test]
5614 fn wrap_never_starts_a_line_with_a_block_marker() {
5615 let options = ReflowOptions {
5616 line_length: 25,
5617 ..Default::default()
5618 };
5619 let lines = reflow_line(
5622 "Some words here and then - a dash clause that wraps around the limit.",
5623 &options,
5624 );
5625 assert_eq!(
5626 lines,
5627 vec![
5628 "Some words here and",
5629 "then - a dash clause that",
5630 "wraps around the limit."
5631 ]
5632 );
5633
5634 for input in [
5636 "Alpha beta gamma delta epsilon - dash clause here to wrap",
5637 "Alpha beta gamma delta epsilon > quote lookalike here to wrap",
5638 "Alpha beta gamma delta epsilon # heading lookalike here to wrap",
5639 "Alpha beta gamma delta epsilon 1. ordered lookalike here to wrap",
5640 "Alpha beta gamma delta epsilon * star clause here to wrap",
5641 "Alpha beta gamma delta epsilon + plus clause here to wrap",
5642 ] {
5643 for width in 10..40 {
5644 let options = ReflowOptions {
5645 line_length: width,
5646 ..Default::default()
5647 };
5648 for line in reflow_line(input, &options) {
5649 assert!(
5650 !starts_block_construct(&line),
5651 "width {width}: wrapped line opens a block construct: {line:?} (input {input:?})"
5652 );
5653 }
5654 }
5655 }
5656 }
5657
5658 #[test]
5659 fn sentence_per_line_keeps_block_markers_mid_line() {
5660 let options = ReflowOptions {
5661 line_length: 80,
5662 sentence_per_line: true,
5663 ..Default::default()
5664 };
5665 let lines = reflow_line(
5668 "Google Calendar (Can't we get rid of this dependency? - I don't really see the need)",
5669 &options,
5670 );
5671 assert_eq!(
5672 lines,
5673 vec!["Google Calendar (Can't we get rid of this dependency? - I don't really see the need)".to_string()]
5674 );
5675
5676 let lines = reflow_line("See section 4? # is the marker we use. Fine.", &options);
5678 assert_eq!(lines, vec!["See section 4? # is the marker we use.", "Fine."]);
5679
5680 let lines = reflow_line("Is this a problem? > I quote someone here.", &options);
5681 assert_eq!(lines, vec!["Is this a problem? > I quote someone here."]);
5682
5683 let lines = reflow_line("Another case! 1. Not a list. More text follows here.", &options);
5684 for line in &lines {
5685 assert!(
5686 !starts_block_construct(line),
5687 "sentence-per-line output opens a block construct: {line:?}"
5688 );
5689 }
5690 }
5691
5692 fn strict_sentence_lines(input: &str, require_sentence_capital: bool) -> Vec<String> {
5694 let options = ReflowOptions {
5695 line_length: 80,
5696 sentence_per_line: true,
5697 require_sentence_capital,
5698 ..Default::default()
5699 };
5700 reflow_line(input, &options)
5701 }
5702
5703 #[test]
5704 fn strict_mode_lets_a_sentence_open_with_a_number() {
5705 for (input, expected) in [
5709 (
5710 "The number of items was 5. 2 of them failed.",
5711 vec!["The number of items was 5.", "2 of them failed."],
5712 ),
5713 (
5714 "Sometimes we have 2. 3 might be here.",
5715 vec!["Sometimes we have 2.", "3 might be here."],
5716 ),
5717 (
5718 "The number of items was 5. 2nd sentence.",
5719 vec!["The number of items was 5.", "2nd sentence."],
5720 ),
5721 (
5722 "Released in 2020. 3 of them failed.",
5723 vec!["Released in 2020.", "3 of them failed."],
5724 ),
5725 (
5726 "First sentence. 2nd sentence.",
5727 vec!["First sentence.", "2nd sentence."],
5728 ),
5729 (
5730 "We met at 6:00 sharp. 6:00 is early.",
5731 vec!["We met at 6:00 sharp.", "6:00 is early."],
5732 ),
5733 ("Pi is 3.14 roughly. Next.", vec!["Pi is 3.14 roughly.", "Next."]),
5734 (
5737 "A \"Is this a test?\" 2020 was memorable.",
5738 vec!["A \"Is this a test?\"", "2020 was memorable."],
5739 ),
5740 ] {
5741 assert_eq!(strict_sentence_lines(input, true), expected, "input {input:?}");
5742 }
5743
5744 for input in [
5747 "The count was 5. and that was all.",
5748 "See fig. 3 for details.",
5749 "See no. 5 in the list.",
5750 "See ch. 12 and vol. 3 for more.",
5751 "A \"Is this a test?\" guide to it.",
5752 ] {
5753 assert_eq!(
5754 strict_sentence_lines(input, true),
5755 vec![input.to_string()],
5756 "input {input:?}"
5757 );
5758 }
5759 }
5760
5761 #[test]
5762 fn sentence_never_opens_with_an_ordered_list_marker() {
5763 for (input, require_capital, expected) in [
5770 (
5771 "Steps: 1. Do this. 2. Do that.",
5772 true,
5773 vec!["Steps: 1.", "Do this. 2.", "Do that."],
5774 ),
5775 (
5776 "First sentence. 1. Do that.",
5777 true,
5778 vec!["First sentence. 1.", "Do that."],
5779 ),
5780 ("Do this! 2. Do that.", true, vec!["Do this! 2.", "Do that."]),
5781 ("Do this. 12) Do that.", true, vec!["Do this. 12) Do that."]),
5782 ("Do this. 2. do that.", true, vec!["Do this. 2. do that."]),
5783 ("Do this. 2. do that.", false, vec!["Do this. 2.", "do that."]),
5784 (
5785 "Twelve. 1234567890. next one here.",
5786 true,
5787 vec!["Twelve. 1234567890. next one here."],
5788 ),
5789 ("Do this. 2 more times.", true, vec!["Do this.", "2 more times."]),
5792 ("How many? 2.", true, vec!["How many?", "2."]),
5793 ("第一句。2. Do that.", true, vec!["第一句。2.", "Do that."]),
5796 ("第一句。 2) 第二句。", true, vec!["第一句。 2) 第二句。"]),
5797 ("第一句。2 more.", true, vec!["第一句。", "2 more."]),
5798 ("第一句。第二句。", true, vec!["第一句。", "第二句。"]),
5799 ] {
5800 let lines = strict_sentence_lines(input, require_capital);
5801 assert_eq!(lines, expected, "input {input:?}, require capital {require_capital}");
5802 for line in &lines {
5803 let chars: Vec<char> = line.chars().collect();
5804 assert!(
5805 !opens_ordered_list_marker(&chars),
5806 "line opens with an ordered-list marker: {line:?} (input {input:?})"
5807 );
5808 }
5809 }
5810 }
5811
5812 #[test]
5813 fn opens_ordered_list_marker_matches_the_marker_shape() {
5814 let chars = |s: &str| s.chars().collect::<Vec<char>>();
5815 for text in ["2. x", "1) x", "12. x", "1.\tx", "1234567890. x", "0. x"] {
5816 assert!(opens_ordered_list_marker(&chars(text)), "{text:?} is a marker");
5817 }
5818 for text in ["2.x", "2.", "2)", "2 x", "x. y", "", " 2. x", "2.5 x", "-2. x"] {
5819 assert!(!opens_ordered_list_marker(&chars(text)), "{text:?} is not a marker");
5820 }
5821 }
5822
5823 #[test]
5824 fn inline_math_directly_after_display_math_stays_atomic() {
5825 let options = ReflowOptions {
5833 line_length: 8,
5834 ..Default::default()
5835 };
5836 let lines = reflow_line("$$a$$$bb cc dd$ x", &options);
5837 assert_eq!(lines, vec!["$$a$$$bb cc dd$".to_string(), "x".to_string()]);
5838 }
5839
5840 #[test]
5841 fn test_code_span_parsing() {
5842 let elements = parse_markdown_elements_inner("`code`", false, false, None);
5844 assert_eq!(elements.len(), 1);
5845 assert!(matches!(&elements[0], Element::Code { content, marker } if content == "code" && marker == "`"));
5846
5847 let elements = parse_markdown_elements_inner("``code``", false, false, None);
5849 assert_eq!(elements.len(), 1);
5850 assert!(matches!(&elements[0], Element::Code { content, marker } if content == "code" && marker == "``"));
5851
5852 let elements = parse_markdown_elements_inner("``code`inside``", false, false, None);
5854 assert_eq!(elements.len(), 1);
5855 assert!(
5856 matches!(&elements[0], Element::Code { content, marker } if content == "code`inside" && marker == "``")
5857 );
5858
5859 let elements = parse_markdown_elements_inner("`` code ``", false, false, None);
5861 assert_eq!(elements.len(), 1);
5862 assert!(matches!(&elements[0], Element::Code { content, marker } if content == " code " && marker == "``"));
5863
5864 let elements = parse_markdown_elements_inner("`unclosed", false, false, None);
5866 assert_eq!(elements.len(), 1);
5867 assert!(matches!(&elements[0], Element::Text(s) if s == "`unclosed"));
5868
5869 let elements = parse_markdown_elements_inner("`unclosed [link](url)", false, false, None);
5871 assert_eq!(elements.len(), 2);
5873 assert!(matches!(&elements[0], Element::Text(s) if s == "`unclosed "));
5874 assert!(matches!(&elements[1], Element::Link(s) if s == "[link](url)"));
5875 }
5876
5877 #[test]
5878 fn test_reflow_performance_long_input() {
5879 let mut text = String::new();
5882 for i in 1..400 {
5883 let backticks = "`".repeat(i);
5884 text.push_str(&backticks);
5885 text.push(' ');
5886 }
5887
5888 let start = std::time::Instant::now();
5889 let elements = parse_markdown_elements_inner(&text, false, false, None);
5890 let duration = start.elapsed();
5891
5892 assert!(duration.as_millis() < 100, "Parsing took too long: {duration:?}");
5894 assert!(!elements.is_empty());
5895 }
5896
5897 #[test]
5898 fn test_reflow_performance_display_math_heavy() {
5899 let text = "$$a$$".repeat(4000);
5904
5905 let start = std::time::Instant::now();
5906 let elements = parse_markdown_elements_inner(&text, false, false, None);
5907 let duration = start.elapsed();
5908
5909 assert!(duration.as_millis() < 100, "Parsing took too long: {duration:?}");
5910 assert_eq!(elements.len(), 4000);
5911 }
5912
5913 #[test]
5914 fn inline_math_len_at_start_matches_regex_at_slice_start() {
5915 let alphabet = ['$', 'a', ' '];
5920 let mut inputs: Vec<String> = vec![String::new()];
5921 let mut frontier: Vec<String> = vec![String::new()];
5922 for _ in 0..6 {
5923 let mut longer = Vec::new();
5924 for prefix in &frontier {
5925 for ch in alphabet {
5926 let mut s = prefix.clone();
5927 s.push(ch);
5928 longer.push(s);
5929 }
5930 }
5931 inputs.extend(longer.iter().cloned());
5932 frontier = longer;
5933 }
5934 inputs.push("$αβ$x".to_string());
5936 inputs.push("$α$$".to_string());
5937
5938 for s in &inputs {
5939 let expected = INLINE_MATH_REGEX
5940 .find(s)
5941 .ok()
5942 .flatten()
5943 .filter(|m| m.start() == 0)
5944 .map(|m| m.end());
5945 assert_eq!(inline_math_len_at_start(s), expected, "input: {s:?}");
5946 }
5947 }
5948
5949 #[test]
5950 fn inline_math_probe_after_dollar_matches_uncached_parse() {
5951 let cases = [
5957 ("$$a$$$b c$ x", r#"[DisplayMath("a"), InlineMath("b c"), Text(" x")]"#),
5958 (
5959 "$$a$$$b$ $$a$$$b$",
5960 r#"[DisplayMath("a"), InlineMath("b"), Text(" "), DisplayMath("a"), InlineMath("b")]"#,
5961 ),
5962 (
5964 "$$a$$$ x $y z$",
5965 r#"[DisplayMath("a"), InlineMath(" x "), Text("y z$")]"#,
5966 ),
5967 ("$$a$$$$ x", r#"[DisplayMath("a"), Text("$$ x")]"#),
5969 ("$$a$$$$b$$ x", r#"[DisplayMath("a"), DisplayMath("b"), Text(" x")]"#),
5970 (
5972 "$a$$b$$c$$d$ tail",
5973 r#"[Text("$a"), DisplayMath("b"), Text("c$$d$ tail")]"#,
5974 ),
5975 ];
5976 for (input, expected) in cases {
5977 let elements = parse_markdown_elements_inner(input, false, false, None);
5978 assert_eq!(format!("{elements:?}"), expected, "input: {input:?}");
5979 }
5980 }
5981
5982 #[test]
5983 fn test_atomic_spans() {
5984 let text_emphasis = "hello **word1 word2**";
5986
5987 let options_disabled = ReflowOptions {
5988 line_length: 18,
5989 atomic_spans: true,
5990 ..Default::default()
5991 };
5992 let lines_disabled = reflow_line(text_emphasis, &options_disabled);
5993 assert_eq!(lines_disabled, vec!["hello", "**word1 word2**"]);
5994
5995 let options_enabled = ReflowOptions {
5996 line_length: 18,
5997 atomic_spans: false,
5998 ..Default::default()
5999 };
6000 let lines_enabled = reflow_line(text_emphasis, &options_enabled);
6001 assert_eq!(lines_enabled, vec!["hello **word1", "word2**"]);
6002
6003 let text_code = "hello `word1 word2`";
6005
6006 let lines_code_disabled = reflow_line(text_code, &options_disabled);
6007 assert_eq!(lines_code_disabled, vec!["hello", "`word1 word2`"]);
6008
6009 let lines_code_enabled = reflow_line(text_code, &options_enabled);
6010 assert_eq!(lines_code_enabled, vec!["hello `word1", "word2`"]);
6011
6012 let text_code_padding = "hello `` `word1` `word2` ``";
6014 let lines_padding_enabled = reflow_line(text_code_padding, &options_enabled);
6015 assert_eq!(lines_padding_enabled, vec![r#"hello `` `word1`"#, r#"`word2` ``"#]);
6016
6017 let text_attached = "**one two**,"; let options_11 = ReflowOptions {
6022 line_length: 11,
6023 atomic_spans: true,
6024 ..Default::default()
6025 };
6026 assert_eq!(reflow_line(text_attached, &options_11), vec!["**one two**,"]);
6027
6028 let options_10 = ReflowOptions {
6030 line_length: 10,
6031 atomic_spans: true,
6032 ..Default::default()
6033 };
6034 assert_eq!(reflow_line(text_attached, &options_10), vec!["**one", "two**,"]);
6035 }
6036
6037 #[test]
6038 fn test_emphasis_containing_markers_is_not_split() {
6039 let options = ReflowOptions {
6040 line_length: 5,
6041 atomic_spans: false,
6042 ..Default::default()
6043 };
6044 let lines = reflow_line(r#"*foo \*bar*"#, &options);
6046 assert_eq!(lines, vec![r#"*foo \*bar*"#.to_string()]);
6047 }
6048
6049 fn semantic_shape(markdown: &str) -> String {
6054 let mut options = Options::empty();
6055 options.insert(Options::ENABLE_STRIKETHROUGH);
6056 let mut out = String::new();
6057 let push_prose = |out: &mut String, text: &str| {
6058 for c in text.chars() {
6059 if c.is_whitespace() {
6060 if !out.ends_with(char::is_whitespace) {
6061 out.push(' ');
6062 }
6063 } else {
6064 out.push(c);
6065 }
6066 }
6067 };
6068 for event in Parser::new_ext(markdown, options) {
6069 match event {
6070 Event::Text(text) => push_prose(&mut out, &text),
6071 Event::SoftBreak | Event::HardBreak => push_prose(&mut out, " "),
6072 Event::Code(code) => out.push_str(&format!("<code>{code}</code>")),
6074 Event::Start(tag) => out.push_str(&format!("<{tag:?}>")),
6075 Event::End(tag) => out.push_str(&format!("</{tag:?}>")),
6076 other => out.push_str(&format!("{other:?}")),
6077 }
6078 }
6079 out.trim().to_string()
6080 }
6081
6082 #[test]
6083 fn test_wrapping_a_span_never_changes_what_it_parses_to() {
6084 let corpus = [
6088 "_This is a very, very, very, very, very long line with some `code` inside._",
6089 "_alpha beta gamma delta epsilon `a b` zeta eta theta iota kappa lambda_",
6090 "**strong text with `code` and more words than fit on one single line**",
6091 "~~struck text with `code` and more words than fit on one single line~~",
6092 "_emphasis with **nested strong that is quite long** and trailing words_",
6093 "***A doubly nested bold italic span with more words than fit on a line***",
6096 "___Another doubly nested span with more words than fit on a single line___",
6097 "**_mixed strong then emphasis with more words than fit on a single line_**",
6098 "*__mixed emphasis then strong with more words than fit on a single line__*",
6099 "**~~strong strikethrough with more words than fit on a single line here~~**",
6100 "**a * b with a stray marker and plenty more words to pass the budget**",
6103 "_foo `a` bar `b` baz qux quux corge grault garply waldo fred plugh xyzzy_",
6104 "text before _a long emphasis with `code` inside of it here_ and after",
6105 "(_a parenthesized long emphasis with `code` inside of it right here_)",
6106 r#"*foo \*bar baz qux quux corge grault garply waldo fred plugh xyzzy*"#,
6107 "_tab\tseparated `a\tb` words spread out over quite a long emphasis span_",
6108 "_This has [text](<a b c d e f g h i j k>) and trailing words to wrap._",
6111 "_This has [`code`](<a b c d e f g h i j k>) and trailing words to wrap._",
6112 r#"_See [x](https://example.com "a long link title here") and `code` too._"#,
6113 "_A [link with a long label](https://example.com/path) and `code` here._",
6114 "_An image  plus `code` and more text_",
6115 ];
6116 for text in corpus {
6117 let expected = semantic_shape(text);
6118 for line_length in [20, 30, 40, 80] {
6119 for atomic_spans in [true, false] {
6120 let options = ReflowOptions {
6121 line_length,
6122 atomic_spans,
6123 ..Default::default()
6124 };
6125 let wrapped = reflow_line(text, &options).join("\n");
6126 assert_eq!(
6127 semantic_shape(&wrapped),
6128 expected,
6129 "reflow changed the parse of {text:?} at line_length={line_length} \
6130 atomic_spans={atomic_spans}\n wrapped: {wrapped:?}"
6131 );
6132 }
6133 }
6134 }
6135 }
6136
6137 #[test]
6138 fn test_wrapping_a_span_keeps_whole_the_constructs_pulldown_cannot_see() {
6139 let cases = [
6143 (
6144 "_alpha beta gamma [[a wiki link]] delta epsilon zeta eta_",
6145 "[[a wiki link]]",
6146 ),
6147 (
6148 "_alpha beta gamma {{< foo bar >}} delta epsilon zeta eta_",
6149 "{{< foo bar >}}",
6150 ),
6151 ("_alpha beta gamma $a + b$ delta epsilon zeta eta theta_", "$a + b$"),
6152 ("_alpha beta gamma $$a + b$$ delta epsilon zeta eta theta_", "$$a + b$$"),
6153 ];
6154 for (text, construct) in cases {
6155 for line_length in [12, 20, 30] {
6156 for atomic_spans in [true, false] {
6157 let options = ReflowOptions {
6158 line_length,
6159 atomic_spans,
6160 ..Default::default()
6161 };
6162 let wrapped = reflow_line(text, &options).join("\n");
6163 assert!(
6164 wrapped.contains(construct),
6165 "{construct} was broken at line_length={line_length} \
6166 atomic_spans={atomic_spans}: {wrapped:?}"
6167 );
6168 }
6169 }
6170 }
6171 }
6172
6173 #[test]
6174 fn test_overlong_emphasis_with_nested_code_span_wraps() {
6175 let options = ReflowOptions {
6179 line_length: 80,
6180 atomic_spans: true,
6181 ..Default::default()
6182 };
6183 let text = "_This is a very, very, very, very, very, very, very long line that exceeds 80 characters with some `code` inside._";
6184 let lines = reflow_line(text, &options);
6185 assert_eq!(
6186 lines,
6187 vec![
6188 "_This is a very, very, very, very, very, very, very long line that exceeds 80",
6189 "characters with some `code` inside._",
6190 ]
6191 );
6192 }
6193
6194 #[test]
6195 fn test_overlong_emphasis_with_nested_strong_wraps() {
6196 let options = ReflowOptions {
6198 line_length: 80,
6199 atomic_spans: true,
6200 ..Default::default()
6201 };
6202 let text = "_This is a very, very, very, very, very, very, very long line that exceeds 80 characters with some **bold** inside._";
6203 let lines = reflow_line(text, &options);
6204 assert_eq!(
6205 lines,
6206 vec![
6207 "_This is a very, very, very, very, very, very, very long line that exceeds 80",
6208 "characters with some **bold** inside._",
6209 ]
6210 );
6211 }
6212
6213 #[test]
6214 fn test_overlong_doubly_nested_span_wraps() {
6215 let options = ReflowOptions {
6220 line_length: 80,
6221 atomic_spans: true,
6222 ..Default::default()
6223 };
6224 let body = "This is a very, very, very, very, very, very, very, very, very, very long line that is emphasised.";
6225 for (open, close) in [
6226 ("***", "***"),
6227 ("___", "___"),
6228 ("**_", "_**"),
6229 ("*__", "__*"),
6230 ("**~~", "~~**"),
6231 ] {
6232 let text = format!("{open}{body}{close}");
6233 assert!(text.len() > options.line_length, "case must start over budget");
6234 let lines = reflow_line(&text, &options);
6235 assert!(
6236 lines.len() > 1,
6237 "{open}...{close} should wrap but stayed on one line: {lines:?}"
6238 );
6239 assert!(
6240 lines.iter().all(|line| line.len() <= options.line_length),
6241 "{open}...{close} left a line over the budget: {lines:?}"
6242 );
6243 assert_eq!(
6244 lines.join(" "),
6245 text,
6246 "{open}...{close} wrapping must only replace a space with a newline"
6247 );
6248 }
6249 }
6250
6251 #[test]
6252 fn test_overlong_span_with_stray_marker_stays_whole() {
6253 let options = ReflowOptions {
6257 line_length: 40,
6258 atomic_spans: true,
6259 ..Default::default()
6260 };
6261 let text = "**alpha * beta gamma delta epsilon zeta eta theta iota kappa**";
6262 let lines = reflow_line(text, &options);
6263 assert_eq!(lines, vec![text], "stray marker must keep the span whole");
6264 }
6265
6266 #[test]
6267 fn test_overlong_span_never_breaks_inside_a_nested_reference_link() {
6268 let options = ReflowOptions {
6274 line_length: 30,
6275 atomic_spans: true,
6276 defined_references: Some(HashSet::from([
6277 "ref".to_string(),
6278 "one two three four five six seven".to_string(),
6280 ])),
6281 ..Default::default()
6282 };
6283 for (text, link) in [
6284 (
6285 "_**alpha [one two three four five six seven][ref] beta gamma delta**_",
6286 "[one two three four five six seven][ref]",
6287 ),
6288 (
6289 "**alpha [one two three four five six seven][ref] beta gamma delta**",
6290 "[one two three four five six seven][ref]",
6291 ),
6292 (
6293 "_**alpha ![one two three four five six seven][ref] beta gamma delta**_",
6294 "![one two three four five six seven][ref]",
6295 ),
6296 (
6297 "_**alpha [one two three four five six seven][] beta gamma delta**_",
6298 "[one two three four five six seven][]",
6299 ),
6300 (
6301 "_**alpha [one two three four five six seven] beta gamma delta**_",
6302 "[one two three four five six seven]",
6303 ),
6304 ] {
6305 let lines = reflow_line(text, &options);
6306 assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
6307 assert!(
6308 lines.iter().any(|line| line.contains(link)),
6309 "{link} must stay on one line: {lines:?}"
6310 );
6311 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
6312 }
6313 }
6314
6315 #[test]
6316 fn test_overlong_span_breaks_inside_an_undefined_shortcut_reference() {
6317 let options = ReflowOptions {
6321 line_length: 30,
6322 atomic_spans: true,
6323 defined_references: Some(HashSet::new()),
6324 ..Default::default()
6325 };
6326 let text = "_**alpha [one two three four five six seven] beta gamma delta**_";
6327 let lines = reflow_line(text, &options);
6328 assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
6329 assert!(
6330 !lines
6331 .iter()
6332 .any(|line| line.contains("[one two three four five six seven]")),
6333 "an undefined shortcut is prose and should break: {lines:?}"
6334 );
6335 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
6336 }
6337
6338 #[test]
6339 fn test_overlong_span_never_breaks_inside_a_nested_attr_list() {
6340 let attr = "{.highlight key=\"a b c\"}";
6344 let text = format!("_**alpha beta gamma delta epsilon zeta{attr} eta theta iota kappa**_");
6345 let options = ReflowOptions {
6346 line_length: 20,
6347 atomic_spans: true,
6348 attr_lists: true,
6349 ..Default::default()
6350 };
6351 let lines = reflow_line(&text, &options);
6352 assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
6353 assert!(
6354 lines.iter().any(|line| line.contains(attr)),
6355 "attr list must stay on one line: {lines:?}"
6356 );
6357 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
6358
6359 let plain = ReflowOptions {
6362 attr_lists: false,
6363 ..options
6364 };
6365 let lines = reflow_line(&text, &plain);
6366 assert!(
6367 !lines.iter().any(|line| line.contains(attr)),
6368 "without the flavor the braces are prose and should break: {lines:?}"
6369 );
6370 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
6371 }
6372
6373 #[test]
6374 fn test_overlong_emphasis_never_breaks_inside_nested_code_span() {
6375 let options = ReflowOptions {
6379 line_length: 30,
6380 atomic_spans: true,
6381 ..Default::default()
6382 };
6383 let text = "_alpha beta gamma delta epsilon `a b` zeta eta theta iota kappa_";
6384 let lines = reflow_line(text, &options);
6385 assert!(lines.len() > 1, "over-long emphasis should wrap: {lines:?}");
6386 assert!(
6387 lines.iter().any(|line| line.contains("`a b`")),
6388 "nested code span must stay whole with its interior spaces: {lines:?}"
6389 );
6390 for line in &lines {
6391 assert_eq!(
6392 line.matches('`').count() % 2,
6393 0,
6394 "no line may contain half a code span: {line:?}"
6395 );
6396 }
6397 }
6398
6399 #[test]
6400 fn test_definition_list_marker_does_not_start_line() {
6401 let options = ReflowOptions {
6402 line_length: 20,
6403 ..Default::default()
6404 };
6405 let lines = reflow_line("This is a term and : definition here.", &options);
6407 for line in &lines {
6408 assert!(
6409 !line.trim_start().starts_with(": "),
6410 "Wrapped line should not start with definition marker: {line}"
6411 );
6412 }
6413 }
6414
6415 #[test]
6416 fn test_div_marker_does_not_start_line() {
6417 let options = ReflowOptions {
6418 line_length: 20,
6419 ..Default::default()
6420 };
6421 let lines = reflow_line("This is some text with ::: class marker.", &options);
6423 for line in &lines {
6424 assert!(
6425 !line.trim_start().starts_with(":::"),
6426 "Wrapped line should not start with div marker: {line}"
6427 );
6428 }
6429 }
6430}