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 code_spans: Vec<(usize, usize)>,
106}
107
108struct OpenSpan {
110 span: (usize, usize),
112 content: Option<(usize, usize)>,
115}
116
117fn note_span_content(open: &mut [OpenSpan], start: usize, end: usize) {
120 for open_span in open.iter_mut() {
121 if start >= open_span.span.0 && end <= open_span.span.1 {
122 open_span.content = Some(match open_span.content {
123 Some((known_start, known_end)) => (known_start.min(start), known_end.max(end)),
124 None => (start, end),
125 });
126 }
127 }
128}
129
130fn merge_ranges(mut ranges: Vec<(usize, usize)>) -> Vec<(usize, usize)> {
131 ranges.sort_unstable();
134 let mut merged: Vec<(usize, usize)> = Vec::with_capacity(ranges.len());
135 for (start, end) in ranges {
136 match merged.last_mut() {
137 Some(last) if start <= last.1 => last.1 = last.1.max(end),
138 _ => merged.push((start, end)),
139 }
140 }
141 merged
142}
143
144fn nested_structure(content: &str, defined_references: Option<&HashSet<String>>, attr_lists: bool) -> NestedStructure {
146 let mut options = Options::empty();
147 options.insert(Options::ENABLE_STRIKETHROUGH);
148
149 let mut atomic: Vec<(usize, usize)> = Vec::new();
150 let mut markers: Vec<(usize, usize)> = Vec::new();
151 let mut links: Vec<(usize, usize)> = Vec::new();
152 let mut code_spans: Vec<(usize, usize)> = Vec::new();
153 let mut open: Vec<OpenSpan> = Vec::new();
156
157 for (event, range) in Parser::new_ext(content, options).into_offset_iter() {
158 let (start, end) = (range.start, range.end);
159 if !matches!(event, Event::End(_)) {
163 note_span_content(&mut open, start, end);
164 }
165 match event {
166 Event::Start(Tag::Link { .. } | Tag::Image { .. }) => {
167 atomic.push((start, end));
168 links.push((start, end));
169 }
170 Event::Code(_) => {
171 atomic.push((start, end));
172 code_spans.push((start, end));
173 }
174 Event::InlineHtml(_) => {
175 atomic.push((start, end));
176 }
177 Event::Start(Tag::Emphasis | Tag::Strong | Tag::Strikethrough) => {
178 open.push(OpenSpan {
179 span: (start, end),
180 content: None,
181 });
182 }
183 Event::End(TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough) => {
184 if let Some(OpenSpan {
185 span: (span_start, span_end),
186 content,
187 }) = open.pop()
188 {
189 match content {
190 Some((content_start, content_end)) => {
194 markers.push((span_start, content_start));
195 markers.push((content_end, span_end));
196 }
197 None => atomic.push((span_start, span_end)),
200 }
201 }
202 }
203 _ => {}
204 }
205 }
206
207 for span in all_link_spans(content, defined_references) {
214 atomic.push((span.start, span.end));
215 links.push((span.start, span.end));
216 }
217
218 for found in WIKI_LINK_REGEX.find_iter(content) {
222 atomic.push((found.start(), found.end()));
223 links.push((found.start(), found.end()));
224 }
225 for found in HUGO_SHORTCODE_REGEX
226 .find_iter(content)
227 .chain(DISPLAY_MATH_REGEX.find_iter(content))
228 {
229 atomic.push((found.start(), found.end()));
230 }
231 let mut from = 0;
232 while let Ok(Some(found)) = INLINE_MATH_REGEX.find_from_pos(content, from) {
233 atomic.push((found.start(), found.end()));
234 from = found.end();
235 }
236
237 if attr_lists {
243 for found in ATTR_LIST_PATTERN.find_iter(content) {
244 atomic.push((found.start(), found.end()));
245 }
246 }
247
248 links.sort_unstable();
251 links.dedup();
252
253 NestedStructure {
254 atomic: merge_ranges(atomic),
255 markers: merge_ranges(markers),
256 links,
257 code_spans,
258 }
259}
260
261fn breakable_units<'a>(
284 content: &'a str,
285 defined_references: Option<&HashSet<String>>,
286 attr_lists: bool,
287) -> Option<Vec<&'a str>> {
288 if !content.contains(['`', '*', '_', '~', '[', '<', '$', '{']) {
291 return Some(split_breakable_words(content).collect());
292 }
293
294 let NestedStructure { atomic, markers, .. } = nested_structure(content, defined_references, attr_lists);
295
296 let mut units = Vec::new();
297 let mut unit_start = None;
298 let mut next_atomic = 0;
299 let mut next_marker = 0;
300 for (offset, ch) in content.char_indices() {
301 while atomic.get(next_atomic).is_some_and(|&(_, end)| end <= offset) {
302 next_atomic += 1;
303 }
304 if atomic.get(next_atomic).is_some_and(|&(start, _)| offset >= start) {
305 if unit_start.is_none() {
308 unit_start = Some(offset);
309 }
310 continue;
311 }
312 while markers.get(next_marker).is_some_and(|&(_, end)| end <= offset) {
313 next_marker += 1;
314 }
315 if matches!(ch, '`' | '*' | '_' | '~') && markers.get(next_marker).is_none_or(|&(start, _)| offset < start) {
316 return None;
317 }
318 if is_breakable_whitespace(ch) {
319 if let Some(start) = unit_start.take() {
320 units.push(&content[start..offset]);
321 }
322 } else if unit_start.is_none() {
323 unit_start = Some(offset);
324 }
325 }
326 if let Some(start) = unit_start {
327 units.push(&content[start..]);
328 }
329 Some(units)
330}
331
332#[derive(Clone)]
334pub struct ReflowOptions {
335 pub line_length: usize,
337 pub break_on_sentences: bool,
339 pub preserve_breaks: bool,
341 pub sentence_per_line: bool,
343 pub semantic_line_breaks: bool,
345 pub abbreviations: Option<Vec<String>>,
349 pub length_mode: ReflowLengthMode,
351 pub attr_lists: bool,
354 pub myst_roles: bool,
358 pub require_sentence_capital: bool,
363 pub max_list_continuation_indent: Option<usize>,
367 pub defined_references: Option<HashSet<String>>,
381 pub atomic_spans: bool,
385 pub length_exemptions: LengthExemptions,
388}
389
390#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
398pub struct LengthExemptions {
399 pub link_urls: bool,
402 pub code_spans: bool,
404}
405
406impl LengthExemptions {
407 fn any(&self) -> bool {
410 self.link_urls || self.code_spans
411 }
412}
413
414impl Default for ReflowOptions {
415 fn default() -> Self {
416 Self {
417 line_length: 80,
418 break_on_sentences: true,
419 preserve_breaks: false,
420 sentence_per_line: false,
421 semantic_line_breaks: false,
422 abbreviations: None,
423 length_mode: ReflowLengthMode::default(),
424 attr_lists: false,
425 myst_roles: false,
426 require_sentence_capital: true,
427 max_list_continuation_indent: None,
428 defined_references: None,
429 atomic_spans: true,
430 length_exemptions: LengthExemptions::default(),
431 }
432 }
433}
434
435#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
442struct LineWidth {
443 link_exempt: usize,
445 code_exempt: usize,
447}
448
449impl LineWidth {
450 fn plain(width: usize) -> Self {
453 Self {
454 link_exempt: width,
455 code_exempt: width,
456 }
457 }
458
459 fn effective(self) -> usize {
462 self.link_exempt.min(self.code_exempt)
463 }
464
465 fn fits(self, line_length: usize) -> bool {
466 self.effective() <= line_length
467 }
468
469 fn is_empty(self) -> bool {
473 self.link_exempt == 0 && self.code_exempt == 0
474 }
475}
476
477impl std::ops::Add for LineWidth {
478 type Output = Self;
479
480 fn add(self, other: Self) -> Self {
481 Self {
482 link_exempt: self.link_exempt + other.link_exempt,
483 code_exempt: self.code_exempt + other.code_exempt,
484 }
485 }
486}
487
488impl std::ops::AddAssign for LineWidth {
489 fn add_assign(&mut self, other: Self) {
490 *self = *self + other;
491 }
492}
493
494pub fn normalize_reference_label(label: &str) -> String {
501 label.split_whitespace().collect::<Vec<_>>().join(" ").to_lowercase()
502}
503
504fn footnote_refs_end(chars: &[char], start: usize) -> Option<usize> {
510 let mut pos = start;
511 let mut found = false;
512
513 loop {
514 if chars.get(pos) != Some(&'[') || chars.get(pos + 1) != Some(&'^') {
515 break;
516 }
517 let label_start = pos + 2;
518 let mut label_end = label_start;
519 while matches!(chars.get(label_end), Some(c) if c.is_ascii_alphanumeric() || *c == '_' || *c == '-') {
520 label_end += 1;
521 }
522 if label_end == label_start || chars.get(label_end) != Some(&']') {
523 break;
524 }
525 pos = label_end + 1;
526 found = true;
527 }
528
529 found.then_some(pos)
530}
531
532fn char_byte_offsets(chars: &[char]) -> Vec<usize> {
535 let mut offsets = Vec::with_capacity(chars.len() + 1);
536 let mut offset = 0;
537 for c in chars {
538 offsets.push(offset);
539 offset += c.len_utf8();
540 }
541 offsets.push(offset);
542 offsets
543}
544
545struct SentenceText<'a> {
550 text: &'a str,
551 chars: &'a [char],
552 char_offsets: &'a [usize],
553 links: &'a [(usize, usize)],
554 code_spans: &'a [(usize, usize)],
555}
556
557impl SentenceText<'_> {
558 fn opens_code_span(&self, pos: usize) -> bool {
563 self.char_offsets
564 .get(pos)
565 .is_some_and(|&start| self.code_spans.binary_search_by_key(&start, |&(s, _)| s).is_ok())
566 }
567
568 fn link_end_at(&self, pos: usize) -> Option<usize> {
577 let range_start = match self.chars.get(pos) {
578 Some('[') => pos,
579 Some('!') if self.chars.get(pos + 1) == Some(&'[') => match self.link_range_end_at(pos) {
580 Some(end) => return Some(end),
581 None => pos + 1,
582 },
583 _ => return None,
584 };
585 self.link_range_end_at(range_start)
586 }
587
588 fn link_range_end_at(&self, pos: usize) -> Option<usize> {
590 let start = self.char_offsets[pos];
591 let idx = self.links.binary_search_by_key(&start, |&(s, _)| s).ok()?;
592 let end = self.links[idx].1;
593 Some(self.char_offsets.binary_search(&end).unwrap_or_else(|i| i))
594 }
595}
596
597fn is_sentence_boundary(
601 st: &SentenceText<'_>,
602 pos: usize,
603 abbreviations: &HashSet<String>,
604 require_sentence_capital: bool,
605) -> bool {
606 let SentenceText { text, chars, .. } = *st;
607 if pos + 1 >= chars.len() {
608 return false;
609 }
610 let byte_offset_after_punct = st.char_offsets[pos + 1];
611
612 let c = chars[pos];
613 let next_char = chars[pos + 1];
614
615 if is_cjk_sentence_ending(c) {
618 let mut after_punct_pos = pos + 1;
620 while after_punct_pos < chars.len()
621 && (chars[after_punct_pos] == '*' || chars[after_punct_pos] == '_' || chars[after_punct_pos] == '~')
622 {
623 after_punct_pos += 1;
624 }
625
626 while after_punct_pos < chars.len() && chars[after_punct_pos].is_whitespace() {
628 after_punct_pos += 1;
629 }
630
631 if after_punct_pos >= chars.len() {
633 return false;
634 }
635
636 if opens_ordered_list_marker(&chars[after_punct_pos..]) {
639 return false;
640 }
641
642 while after_punct_pos < chars.len()
644 && (chars[after_punct_pos] == '*' || chars[after_punct_pos] == '_' || chars[after_punct_pos] == '~')
645 {
646 after_punct_pos += 1;
647 }
648
649 if after_punct_pos >= chars.len() {
650 return false;
651 }
652
653 return true;
656 }
657
658 if c != '.' && c != '!' && c != '?' {
660 return false;
661 }
662
663 let inside_quotation = is_closing_quote(next_char);
666
667 let (space_pos, after_space_pos) = if next_char == ' ' {
669 (pos + 1, pos + 2)
671 } else if is_closing_quote(next_char) && pos + 2 < chars.len() {
672 if chars[pos + 2] == ' ' {
674 (pos + 2, pos + 3)
676 } else if (chars[pos + 2] == '*' || chars[pos + 2] == '_') && pos + 3 < chars.len() && chars[pos + 3] == ' ' {
677 (pos + 3, pos + 4)
679 } else if (chars[pos + 2] == '*' || chars[pos + 2] == '_')
680 && pos + 4 < chars.len()
681 && chars[pos + 3] == chars[pos + 2]
682 && chars[pos + 4] == ' '
683 {
684 (pos + 4, pos + 5)
686 } else {
687 return false;
688 }
689 } else if (next_char == '*' || next_char == '_') && pos + 2 < chars.len() && chars[pos + 2] == ' ' {
690 (pos + 2, pos + 3)
692 } else if (next_char == '*' || next_char == '_')
693 && pos + 3 < chars.len()
694 && chars[pos + 2] == next_char
695 && chars[pos + 3] == ' '
696 {
697 (pos + 3, pos + 4)
699 } else if next_char == '~' && pos + 3 < chars.len() && chars[pos + 2] == '~' && chars[pos + 3] == ' ' {
700 (pos + 3, pos + 4)
702 } else if next_char == '[' {
703 match footnote_refs_end(chars, pos + 1) {
709 Some(end_pos) if chars.get(end_pos) == Some(&' ') => (end_pos, end_pos + 1),
710 _ => return false,
711 }
712 } else {
713 return false;
714 };
715
716 let mut next_char_pos = after_space_pos;
718 while next_char_pos < chars.len() && chars[next_char_pos].is_whitespace() {
719 next_char_pos += 1;
720 }
721
722 if next_char_pos >= chars.len() {
724 return false;
725 }
726
727 if opens_ordered_list_marker(&chars[next_char_pos..]) {
736 return false;
737 }
738
739 let mut first_letter_pos = next_char_pos;
745 while first_letter_pos < chars.len() {
746 let ch = chars[first_letter_pos];
747 if let Some(end) = st.link_end_at(first_letter_pos) {
748 first_letter_pos += link_opener_len(chars, first_letter_pos, end);
749 } else if matches!(ch, '*' | '_' | '~') || is_opening_quote(ch) {
750 first_letter_pos += 1;
751 } else {
752 break;
753 }
754 }
755
756 if first_letter_pos >= chars.len() {
758 return false;
759 }
760
761 let first_char = chars[first_letter_pos];
762
763 if c == '!' || c == '?' {
769 return !inside_quotation || !require_sentence_capital || opens_sentence_in_strict_mode(first_char);
770 }
771
772 if pos > 0 {
778 if text_ends_with_abbreviation(&text[..byte_offset_after_punct], abbreviations) {
780 return false;
781 }
782
783 if chars[pos - 1].is_ascii_uppercase() && (pos == 1 || (pos >= 2 && chars[pos - 2].is_whitespace())) {
787 return false;
788 }
789 }
790
791 let elision = pos > 0 && chars[pos - 1] == '.';
802 let digit_run = pos > 0 && chars[pos - 1].is_numeric();
803 let bare = space_pos == pos + 1;
804
805 if st.opens_code_span(first_letter_pos) && !elision && !(digit_run && bare) {
811 return true;
812 }
813
814 if require_sentence_capital && !opens_sentence_in_strict_mode(first_char) {
817 return false;
818 }
819
820 true
821}
822
823fn opens_sentence_in_strict_mode(first_char: char) -> bool {
831 first_char.is_uppercase() || first_char.is_numeric() || is_cjk_char(first_char)
832}
833
834fn opens_ordered_list_marker(chars: &[char]) -> bool {
840 let digits = chars.iter().take_while(|c| c.is_ascii_digit()).count();
841 digits > 0 && matches!(chars.get(digits), Some('.' | ')')) && matches!(chars.get(digits + 1), Some(' ' | '\t'))
842}
843
844fn link_opener_len(chars: &[char], pos: usize, end: usize) -> usize {
850 let open = if chars[pos] == '!' { pos + 1 } else { pos };
851 let body = open + 1;
852 if chars.get(body) != Some(&'[') {
853 return body - pos;
854 }
855 let body = body + 1;
856 let alias = chars[body..end.saturating_sub(2).max(body)]
857 .iter()
858 .position(|&c| c == '|')
859 .map_or(body, |p| body + p + 1);
860 alias - pos
861}
862
863pub fn split_into_sentences(text: &str, defined_references: Option<&HashSet<String>>) -> Vec<String> {
871 let abbreviations = get_abbreviations(&None);
872 split_into_sentences_with_set(text, &abbreviations, true, None, defined_references)
873}
874
875fn split_into_sentences_with_set(
885 text: &str,
886 abbreviations: &HashSet<String>,
887 require_sentence_capital: bool,
888 appended_span_start: Option<usize>,
889 defined_references: Option<&HashSet<String>>,
890) -> Vec<String> {
891 let char_vec: Vec<char> = text.chars().collect();
892 let char_offsets = char_byte_offsets(&char_vec);
893
894 let NestedStructure {
897 atomic,
898 links,
899 code_spans,
900 ..
901 } = sentence_structure(text, defined_references);
902 let mut atomic_it = atomic.iter().peekable();
903 let st = SentenceText {
904 text,
905 chars: &char_vec,
906 char_offsets: &char_offsets,
907 links: &links,
908 code_spans: &code_spans,
909 };
910
911 let mut sentences = Vec::new();
912 let mut current_sentence = String::new();
913 let mut pos = 0;
914
915 while pos < char_vec.len() {
916 let c = char_vec[pos];
917 current_sentence.push(c);
918
919 let byte_idx = char_offsets[pos];
920
921 while let Some(&&(_, end)) = atomic_it.peek() {
923 if end <= byte_idx {
924 atomic_it.next();
925 } else {
926 break;
927 }
928 }
929
930 let in_atomic = atomic_it
932 .peek()
933 .is_some_and(|&&(start, end)| byte_idx >= start && byte_idx < end);
934
935 if !in_atomic && is_sentence_boundary(&st, pos, abbreviations, require_sentence_capital) {
936 if let Some(end_pos) = footnote_refs_end(&char_vec, pos + 1) {
938 while pos + 1 < end_pos {
939 pos += 1;
940 current_sentence.push(char_vec[pos]);
941 }
942 }
943
944 while pos + 1 < char_vec.len() {
946 let next = char_vec[pos + 1];
947 if matches!(next, '*' | '_' | '~') && Some(char_offsets[pos + 1]) == appended_span_start {
948 break;
949 }
950 if next == '*' || next == '_' || next == '~' || is_closing_quote(next) {
951 pos += 1;
952 current_sentence.push(char_vec[pos]);
953 } else {
954 break;
955 }
956 }
957
958 if pos + 1 < char_vec.len() && char_vec[pos + 1] == ' ' {
960 pos += 1; }
962
963 sentences.push(current_sentence.trim().to_string());
964 current_sentence.clear();
965 }
966
967 pos += 1;
968 }
969
970 if !current_sentence.trim().is_empty() {
972 sentences.push(current_sentence.trim().to_string());
973 }
974 sentences
975}
976
977fn sentence_structure(text: &str, defined_references: Option<&HashSet<String>>) -> NestedStructure {
993 if !text.contains(['`', '[', '<', '$']) {
996 return NestedStructure {
997 atomic: Vec::new(),
998 markers: Vec::new(),
999 links: Vec::new(),
1000 code_spans: Vec::new(),
1001 };
1002 }
1003 nested_structure(text, defined_references, false)
1004}
1005
1006fn is_horizontal_rule(line: &str) -> bool {
1008 if line.len() < 3 {
1009 return false;
1010 }
1011
1012 let mut chars = line.chars();
1015 let Some(first_char) = chars.next() else {
1016 return false;
1017 };
1018 if first_char != '-' && first_char != '_' && first_char != '*' {
1019 return false;
1020 }
1021
1022 let mut non_space_count = 1usize; for c in chars {
1024 if c == ' ' {
1025 continue;
1026 }
1027 if c != first_char {
1028 return false;
1029 }
1030 non_space_count += 1;
1031 }
1032 non_space_count >= 3
1033}
1034
1035fn is_numbered_list_item(line: &str) -> bool {
1037 let mut chars = line.chars();
1038
1039 if !chars.next().is_some_and(char::is_numeric) {
1041 return false;
1042 }
1043
1044 while let Some(c) = chars.next() {
1046 if c == '.' {
1047 return chars.next() == Some(' ');
1050 }
1051 if !c.is_numeric() {
1052 return false;
1053 }
1054 }
1055
1056 false
1057}
1058
1059fn is_unordered_list_marker(s: &str) -> bool {
1061 matches!(s.as_bytes().first(), Some(b'-' | b'*' | b'+'))
1062 && !is_horizontal_rule(s)
1063 && (s.len() == 1 || s.as_bytes().get(1) == Some(&b' '))
1064}
1065
1066fn is_block_boundary_core(trimmed: &str) -> bool {
1069 trimmed.is_empty()
1070 || trimmed.starts_with('#')
1071 || trimmed.starts_with("```")
1072 || trimmed.starts_with("~~~")
1073 || trimmed.starts_with('>')
1074 || (trimmed.starts_with('[') && trimmed.contains("]:"))
1075 || is_horizontal_rule(trimmed)
1076 || is_unordered_list_marker(trimmed)
1077 || is_numbered_list_item(trimmed)
1078 || is_definition_list_item(trimmed)
1079 || trimmed.starts_with(":::")
1080}
1081
1082fn is_block_boundary(trimmed: &str) -> bool {
1085 is_block_boundary_core(trimmed) || trimmed.starts_with('|')
1086}
1087
1088fn is_paragraph_boundary(trimmed: &str, line: &str) -> bool {
1092 is_block_boundary_core(trimmed)
1093 || calculate_indentation_width_default(line) >= 4
1094 || crate::utils::table_utils::TableUtils::is_potential_table_row(line)
1095}
1096
1097fn has_hard_break(line: &str) -> bool {
1103 let line = line.strip_suffix('\r').unwrap_or(line);
1104 line.ends_with(" ") || line.ends_with('\\')
1105}
1106
1107fn ends_with_sentence_punct(text: &str) -> bool {
1109 text.ends_with('.') || text.ends_with('!') || text.ends_with('?')
1110}
1111
1112fn trim_preserving_hard_break(s: &str) -> String {
1118 let s = s.strip_suffix('\r').unwrap_or(s);
1120
1121 if s.ends_with('\\') {
1123 return s.to_string();
1125 }
1126
1127 if s.ends_with(" ") {
1129 let content_end = s.trim_end().len();
1131 if content_end == 0 {
1132 return String::new();
1134 }
1135 format!("{} ", &s[..content_end])
1137 } else {
1138 s.trim_end().to_string()
1140 }
1141}
1142
1143fn parse_elements(text: &str, options: &ReflowOptions) -> Vec<Element> {
1145 parse_markdown_elements_inner(
1146 text,
1147 options.attr_lists,
1148 options.myst_roles,
1149 options.defined_references.as_ref(),
1150 )
1151}
1152
1153pub fn reflow_line(line: &str, options: &ReflowOptions) -> Vec<String> {
1163 let reflowed = reflow_line_unchecked(line, options);
1164 if preserves_content(line, &reflowed) {
1165 reflowed
1166 } else {
1167 vec![line.to_string()]
1168 }
1169}
1170
1171fn preserves_content(original: &str, reflowed: &[String]) -> bool {
1178 let (original_text, original_breaks) = visible_text_and_breaks(original.chars());
1179 let (reflowed_text, reflowed_breaks) =
1180 visible_text_and_breaks(reflowed.iter().flat_map(|line| line.chars().chain(['\n'])));
1181
1182 original_text == reflowed_text && contains_all(&reflowed_breaks, &original_breaks)
1183}
1184
1185fn visible_text_and_breaks(text: impl Iterator<Item = char>) -> (String, Vec<usize>) {
1188 let mut visible = String::new();
1189 let mut breaks = Vec::new();
1190 let mut count = 0usize;
1191 let mut pending_break = false;
1192
1193 for c in text {
1194 if c.is_whitespace() {
1195 pending_break = count > 0;
1196 } else {
1197 if pending_break {
1198 breaks.push(count);
1199 pending_break = false;
1200 }
1201 visible.push(c);
1202 count += 1;
1203 }
1204 }
1205
1206 (visible, breaks)
1207}
1208
1209fn contains_all(superset: &[usize], subset: &[usize]) -> bool {
1211 let mut candidates = superset.iter();
1212 subset
1213 .iter()
1214 .all(|wanted| candidates.by_ref().any(|found| found == wanted))
1215}
1216
1217fn reflow_line_unchecked(line: &str, options: &ReflowOptions) -> Vec<String> {
1218 if options.sentence_per_line {
1220 let elements = parse_elements(line, options);
1221 return merge_block_construct_continuations(reflow_elements_sentence_per_line(&elements, options));
1222 }
1223
1224 if options.semantic_line_breaks {
1226 let elements = parse_elements(line, options);
1227 return merge_block_construct_continuations(reflow_elements_semantic(&elements, options));
1228 }
1229
1230 if options.line_length == 0 || line_fits(line, options) {
1233 return vec![line.to_string()];
1234 }
1235
1236 let elements = parse_elements(line, options);
1238
1239 merge_block_construct_continuations(reflow_elements(&elements, options))
1241}
1242
1243#[derive(Debug, Clone)]
1245enum Element {
1246 Text(String),
1248 Link(String),
1250 ReferenceLink(String),
1252 EmptyReferenceLink(String),
1254 ShortcutReference(String),
1256 InlineImage(String),
1258 ReferenceImage(String),
1260 EmptyReferenceImage(String),
1262 LinkedImage(String),
1264 FootnoteReference(String),
1266 Strikethrough {
1268 content: String,
1269 double: bool,
1271 },
1272 WikiLink(String),
1274 InlineMath(String),
1276 DisplayMath(String),
1278 EmojiShortcode(String),
1280 Autolink(String),
1282 HtmlTag(String),
1284 HtmlEntity(String),
1286 HugoShortcode(String),
1288 AttrList(String),
1290 MystRole(String),
1294 Code { content: String, marker: String },
1296 Bold {
1298 content: String,
1299 underscore: bool,
1301 },
1302 Italic {
1304 content: String,
1305 underscore: bool,
1307 },
1308}
1309
1310impl Element {
1311 fn opens_with_bracket(&self) -> bool {
1316 matches!(
1317 self,
1318 Element::Link(_)
1319 | Element::ReferenceLink(_)
1320 | Element::EmptyReferenceLink(_)
1321 | Element::ShortcutReference(_)
1322 | Element::FootnoteReference(_)
1323 | Element::InlineImage(_)
1324 | Element::ReferenceImage(_)
1325 | Element::EmptyReferenceImage(_)
1326 | Element::LinkedImage(_)
1327 | Element::WikiLink(_)
1328 )
1329 }
1330}
1331
1332impl std::fmt::Display for Element {
1333 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1334 match self {
1335 Element::Text(s) => write!(f, "{s}"),
1336 Element::Link(s) => write!(f, "{s}"),
1337 Element::ReferenceLink(s) => write!(f, "{s}"),
1338 Element::EmptyReferenceLink(s) => write!(f, "{s}"),
1339 Element::ShortcutReference(s) => write!(f, "{s}"),
1340 Element::InlineImage(s) => write!(f, "{s}"),
1341 Element::ReferenceImage(s) => write!(f, "{s}"),
1342 Element::EmptyReferenceImage(s) => write!(f, "{s}"),
1343 Element::LinkedImage(s) => write!(f, "{s}"),
1344 Element::FootnoteReference(s) => write!(f, "{s}"),
1345 Element::Strikethrough { content, double } => {
1346 let marker = if *double { "~~" } else { "~" };
1347 write!(f, "{marker}{content}{marker}")
1348 }
1349 Element::WikiLink(s) => write!(f, "[[{s}]]"),
1350 Element::InlineMath(s) => write!(f, "${s}$"),
1351 Element::DisplayMath(s) => write!(f, "$${s}$$"),
1352 Element::EmojiShortcode(s) => write!(f, ":{s}:"),
1353 Element::Autolink(s) => write!(f, "{s}"),
1354 Element::HtmlTag(s) => write!(f, "{s}"),
1355 Element::HtmlEntity(s) => write!(f, "{s}"),
1356 Element::HugoShortcode(s) => write!(f, "{s}"),
1357 Element::AttrList(s) => write!(f, "{s}"),
1358 Element::MystRole(s) => write!(f, "{s}"),
1359 Element::Code { content, marker } => write!(f, "{marker}{content}{marker}"),
1360 Element::Bold { content, underscore } => {
1361 if *underscore {
1362 write!(f, "__{content}__")
1363 } else {
1364 write!(f, "**{content}**")
1365 }
1366 }
1367 Element::Italic { content, underscore } => {
1368 if *underscore {
1369 write!(f, "_{content}_")
1370 } else {
1371 write!(f, "*{content}*")
1372 }
1373 }
1374 }
1375 }
1376}
1377
1378impl Element {
1379 fn display_len(&self, mode: ReflowLengthMode) -> usize {
1380 match self {
1381 Element::Text(s)
1382 | Element::Link(s)
1383 | Element::ReferenceLink(s)
1384 | Element::EmptyReferenceLink(s)
1385 | Element::ShortcutReference(s)
1386 | Element::InlineImage(s)
1387 | Element::ReferenceImage(s)
1388 | Element::EmptyReferenceImage(s)
1389 | Element::LinkedImage(s)
1390 | Element::FootnoteReference(s)
1391 | Element::Autolink(s)
1392 | Element::HtmlTag(s)
1393 | Element::HtmlEntity(s)
1394 | Element::HugoShortcode(s)
1395 | Element::AttrList(s)
1396 | Element::MystRole(s) => display_len(s, mode),
1397 Element::WikiLink(s) => display_len(s, mode) + 4,
1398 Element::InlineMath(s) => display_len(s, mode) + 2,
1399 Element::DisplayMath(s) => display_len(s, mode) + 4,
1400 Element::EmojiShortcode(s) => display_len(s, mode) + 2,
1401 Element::Strikethrough { content, double } => display_len(content, mode) + if *double { 4 } else { 2 },
1402 Element::Code { content, marker } => display_len(content, mode) + display_len(marker, mode) * 2,
1403 Element::Bold { content, .. } => display_len(content, mode) + 4,
1404 Element::Italic { content, .. } => display_len(content, mode) + 2,
1405 }
1406 }
1407
1408 fn exempt_width(&self, mode: ReflowLengthMode, exemptions: LengthExemptions) -> LineWidth {
1419 let full = self.display_len(mode);
1420 let mut width = LineWidth::plain(full);
1421 match self {
1422 Element::Link(s) | Element::LinkedImage(s) if exemptions.link_urls => {
1423 if let Some(text) = bracketed_text(s, 0) {
1424 width.link_exempt = (2 + display_len(text, mode)).min(full);
1425 }
1426 }
1427 Element::InlineImage(s) if exemptions.link_urls => {
1428 if let Some(alt) = bracketed_text(s, 1) {
1429 width.link_exempt = (3 + display_len(alt, mode)).min(full);
1430 }
1431 }
1432 Element::Code { .. } if exemptions.code_spans => width.code_exempt = 0,
1433 _ => {}
1434 }
1435 width
1436 }
1437}
1438
1439fn bracketed_text(s: &str, open: usize) -> Option<&str> {
1446 let bytes = s.as_bytes();
1447 if bytes.get(open) != Some(&b'[') {
1448 return None;
1449 }
1450 let mut depth = 0usize;
1451 let mut in_code_span = false;
1452 let mut escaped = false;
1453 for (i, &byte) in bytes.iter().enumerate().skip(open + 1) {
1454 if escaped {
1455 escaped = false;
1456 continue;
1457 }
1458 match byte {
1459 b'\\' => escaped = true,
1460 b'`' => in_code_span = !in_code_span,
1461 b'[' if !in_code_span => depth += 1,
1462 b']' if !in_code_span => match depth.checked_sub(1) {
1463 Some(next) => depth = next,
1464 None => return s.get(open + 1..i),
1465 },
1466 _ => {}
1467 }
1468 }
1469 None
1470}
1471
1472#[derive(Debug, Clone)]
1474struct EmphasisSpan {
1475 start: usize,
1477 end: usize,
1479 content: String,
1481 is_strong: bool,
1483 is_strikethrough: bool,
1485 uses_underscore: bool,
1487 strikethrough_double: bool,
1490}
1491
1492fn extract_emphasis_and_code_spans(text: &str) -> (Vec<EmphasisSpan>, Vec<CodeSpan>) {
1502 let has_emphasis = text.contains(['*', '_', '~']);
1504 let has_code = text.contains('`');
1505 if !has_emphasis && !has_code {
1506 return (Vec::new(), Vec::new());
1507 }
1508
1509 let mut emphasis_spans = Vec::new();
1510 let mut code_spans = Vec::new();
1511
1512 let mut options = Options::empty();
1513 if has_emphasis {
1514 options.insert(Options::ENABLE_STRIKETHROUGH);
1515 }
1516
1517 let mut emphasis_stack: Vec<(usize, bool)> = Vec::new(); let mut strong_stack: Vec<(usize, bool)> = Vec::new();
1520 let mut strikethrough_stack: Vec<usize> = Vec::new();
1521
1522 let parser = Parser::new_ext(text, options).into_offset_iter();
1523
1524 for (event, range) in parser {
1525 match event {
1526 Event::Code(_) => {
1527 code_spans.push(CodeSpan {
1528 start: range.start,
1529 end: range.end,
1530 });
1531 }
1532 Event::Start(Tag::Emphasis) => {
1533 let uses_underscore = text.get(range.start..range.start + 1) == Some("_");
1535 emphasis_stack.push((range.start, uses_underscore));
1536 }
1537 Event::End(TagEnd::Emphasis) => {
1538 if let Some((start_byte, uses_underscore)) = emphasis_stack.pop() {
1539 let content_start = start_byte + 1;
1540 let content_end = range.end - 1;
1541 if content_end > content_start
1542 && let Some(content) = text.get(content_start..content_end)
1543 {
1544 emphasis_spans.push(EmphasisSpan {
1545 start: start_byte,
1546 end: range.end,
1547 content: content.to_string(),
1548 is_strong: false,
1549 is_strikethrough: false,
1550 uses_underscore,
1551 strikethrough_double: false,
1552 });
1553 }
1554 }
1555 }
1556 Event::Start(Tag::Strong) => {
1557 let uses_underscore = text.get(range.start..range.start + 2) == Some("__");
1558 strong_stack.push((range.start, uses_underscore));
1559 }
1560 Event::End(TagEnd::Strong) => {
1561 if let Some((start_byte, uses_underscore)) = strong_stack.pop() {
1562 let content_start = start_byte + 2;
1563 let content_end = range.end - 2;
1564 if content_end > content_start
1565 && let Some(content) = text.get(content_start..content_end)
1566 {
1567 emphasis_spans.push(EmphasisSpan {
1568 start: start_byte,
1569 end: range.end,
1570 content: content.to_string(),
1571 is_strong: true,
1572 is_strikethrough: false,
1573 uses_underscore,
1574 strikethrough_double: false,
1575 });
1576 }
1577 }
1578 }
1579 Event::Start(Tag::Strikethrough) => {
1580 strikethrough_stack.push(range.start);
1581 }
1582 Event::End(TagEnd::Strikethrough) => {
1583 if let Some(start_byte) = strikethrough_stack.pop() {
1584 let double = text.get(start_byte..start_byte + 2) == Some("~~");
1585 let marker_len = if double { 2 } else { 1 };
1586 let content_start = start_byte + marker_len;
1587 let content_end = range.end - marker_len;
1588 if content_end > content_start
1589 && let Some(content) = text.get(content_start..content_end)
1590 {
1591 emphasis_spans.push(EmphasisSpan {
1592 start: start_byte,
1593 end: range.end,
1594 content: content.to_string(),
1595 is_strong: false,
1596 is_strikethrough: true,
1597 uses_underscore: false,
1598 strikethrough_double: double,
1599 });
1600 }
1601 }
1602 }
1603 _ => {}
1604 }
1605 }
1606
1607 emphasis_spans.sort_by_key(|s| s.start);
1608 (emphasis_spans, code_spans)
1609}
1610
1611#[derive(Debug, Clone)]
1612struct CodeSpan {
1613 start: usize,
1614 end: usize,
1615}
1616
1617#[derive(Debug, Clone)]
1618struct LinkSpan {
1619 start: usize,
1620 end: usize,
1621 link_type: Option<LinkType>,
1622 is_image: bool,
1623 is_footnote: bool,
1624 depth: usize,
1627}
1628
1629fn extract_link_spans(text: &str, defined_references: Option<&HashSet<String>>) -> Vec<LinkSpan> {
1633 let mut spans = all_link_spans(text, defined_references);
1634 spans.retain(|span| span.depth == 0);
1635 spans
1636}
1637
1638fn all_link_spans(text: &str, defined_references: Option<&HashSet<String>>) -> Vec<LinkSpan> {
1641 if !text.contains('[') {
1644 return Vec::new();
1645 }
1646
1647 let mut spans = Vec::new();
1648 let mut options = Options::empty();
1649 options.insert(Options::ENABLE_FOOTNOTES);
1650
1651 let resolve = move |link: BrokenLink<'_>| -> Option<(CowStr<'_>, CowStr<'_>)> {
1668 let atomic = match link.link_type {
1673 LinkType::Shortcut | LinkType::ShortcutUnknown => match defined_references {
1674 Some(defs) => defs.contains(&normalize_reference_label(link.reference.as_ref())),
1675 None => true,
1676 },
1677 _ => true,
1678 };
1679 atomic.then_some((CowStr::Borrowed(""), CowStr::Borrowed("")))
1680 };
1681 let parser = Parser::new_with_broken_link_callback(text, options, Some(resolve)).into_offset_iter();
1682 let mut stack = Vec::new();
1683
1684 for (event, range) in parser {
1685 match event {
1686 Event::Start(Tag::Link { link_type, .. }) => {
1687 stack.push((range.start, Some(link_type), false));
1688 }
1689 Event::Start(Tag::Image { link_type, .. }) => {
1690 stack.push((range.start, Some(link_type), true));
1691 }
1692 Event::End(TagEnd::Link | TagEnd::Image) => {
1693 if let Some((start_byte, link_type, is_image)) = stack.pop() {
1694 spans.push(LinkSpan {
1695 start: start_byte,
1696 end: range.end,
1697 link_type,
1698 is_image,
1699 is_footnote: false,
1700 depth: stack.len(),
1701 });
1702 }
1703 }
1704 Event::FootnoteReference(_) => {
1705 spans.push(LinkSpan {
1706 start: range.start,
1707 end: range.end,
1708 link_type: None,
1709 is_image: false,
1710 is_footnote: true,
1711 depth: stack.len(),
1712 });
1713 }
1714 _ => {}
1715 }
1716 }
1717
1718 spans.sort_by_key(|s| s.start);
1719 spans
1720}
1721
1722fn myst_role_len_at(text: &str, absolute_pos: usize, code_spans: &[CodeSpan]) -> Option<usize> {
1730 let bytes = text.as_bytes();
1731 if bytes.first() != Some(&b'{') {
1732 return None;
1733 }
1734
1735 let mut j = 1;
1737 match bytes.get(j) {
1738 Some(&b) if b.is_ascii_alphabetic() || b == b'_' => {}
1739 _ => return None,
1740 }
1741 while let Some(&b) = bytes.get(j) {
1742 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b':' | b'.') {
1743 j += 1;
1744 } else {
1745 break;
1746 }
1747 }
1748 if bytes.get(j) != Some(&b'}') {
1749 return None;
1750 }
1751 j += 1; let code_span_start = absolute_pos + j;
1755 if let Ok(idx) = code_spans.binary_search_by_key(&code_span_start, |span| span.start) {
1756 let span = &code_spans[idx];
1757 let code_span_len = span.end - span.start;
1758 return Some(j + code_span_len);
1759 }
1760
1761 None
1762}
1763
1764fn inline_math_len_at_start(s: &str) -> Option<usize> {
1771 let bytes = s.as_bytes();
1772 if bytes.first() != Some(&b'$') || bytes.get(1) == Some(&b'$') {
1774 return None;
1775 }
1776 let close = 1 + s[1..].find('$')?;
1779 if bytes.get(close + 1) == Some(&b'$') {
1781 return None;
1782 }
1783 Some(close + 1)
1784}
1785
1786#[derive(Clone, Copy, Debug)]
1788struct PatternMatch {
1789 start: usize,
1790 end: usize,
1791}
1792
1793#[derive(Clone, Copy)]
1807enum PatternCache {
1808 Unsearched,
1809 NotFound,
1810 Found(PatternMatch),
1811}
1812
1813impl PatternCache {
1814 fn earliest_in(
1818 &mut self,
1819 remaining: &str,
1820 cursor: usize,
1821 find: impl FnOnce(&str) -> Option<(usize, usize)>,
1822 ) -> Option<(usize, usize)> {
1823 let stale = match self {
1824 PatternCache::Found(pm) => pm.start < cursor,
1825 PatternCache::NotFound => false,
1826 PatternCache::Unsearched => true,
1827 };
1828 if stale {
1829 *self = match find(remaining) {
1830 Some((start, end)) => PatternCache::Found(PatternMatch {
1831 start: cursor + start,
1832 end: cursor + end,
1833 }),
1834 None => PatternCache::NotFound,
1835 };
1836 }
1837 match self {
1838 PatternCache::Found(pm) => Some((pm.start - cursor, pm.end - cursor)),
1839 _ => None,
1840 }
1841 }
1842}
1843
1844fn parse_markdown_elements_inner(
1855 text: &str,
1856 attr_lists: bool,
1857 myst_roles: bool,
1858 defined_references: Option<&HashSet<String>>,
1859) -> Vec<Element> {
1860 let mut elements = Vec::new();
1861 let mut remaining = text;
1862
1863 let (emphasis_spans, code_spans) = extract_emphasis_and_code_spans(text);
1868 let link_spans = extract_link_spans(text, defined_references);
1869
1870 let mut cached_wiki_link = PatternCache::Unsearched;
1873 let mut cached_display_math = PatternCache::Unsearched;
1874 let mut cached_inline_math = PatternCache::Unsearched;
1875 let mut cached_emoji = PatternCache::Unsearched;
1876 let mut cached_html_entity = PatternCache::Unsearched;
1877 let mut cached_hugo_shortcode = PatternCache::Unsearched;
1878 let mut cached_html_tag = PatternCache::Unsearched;
1879 let mut cached_next_curly = PatternCache::Unsearched;
1880
1881 let mut link_span_idx = 0usize;
1885 let mut emphasis_span_idx = 0usize;
1886 let mut code_span_idx = 0usize;
1887
1888 while !remaining.is_empty() {
1889 let current_offset = text.len() - remaining.len();
1891 let mut earliest_match: Option<(usize, usize, &str)> = None;
1894
1895 while link_span_idx < link_spans.len() && link_spans[link_span_idx].start < current_offset {
1897 link_span_idx += 1;
1898 }
1899 let next_link: Option<&LinkSpan> = link_spans.get(link_span_idx);
1900
1901 if let Some(span) = next_link {
1902 let pos_in_remaining = span.start - current_offset;
1903 if earliest_match
1904 .as_ref()
1905 .is_none_or(|(start, _, _)| pos_in_remaining < *start)
1906 {
1907 let match_end = span.end - current_offset;
1908 earliest_match = Some((pos_in_remaining, match_end, "link_span"));
1909 }
1910 }
1911
1912 if let Some((start, end)) = cached_wiki_link.earliest_in(remaining, current_offset, |suffix| {
1914 WIKI_LINK_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1915 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1916 {
1917 earliest_match = Some((start, end, "wiki_link"));
1918 }
1919
1920 if let Some((start, end)) = cached_display_math.earliest_in(remaining, current_offset, |suffix| {
1922 DISPLAY_MATH_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1923 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1924 {
1925 earliest_match = Some((start, end, "display_math"));
1926 }
1927
1928 let inline_math_probe = if current_offset > 0 && text.as_bytes()[current_offset - 1] == b'$' {
1942 inline_math_len_at_start(remaining).map(|len| (0, len))
1943 } else {
1944 None
1945 };
1946 if let Some((start, end)) = inline_math_probe.or_else(|| {
1947 cached_inline_math.earliest_in(remaining, current_offset, |suffix| {
1948 INLINE_MATH_REGEX
1949 .find(suffix)
1950 .ok()
1951 .flatten()
1952 .map(|m| (m.start(), m.end()))
1953 })
1954 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1955 {
1956 earliest_match = Some((start, end, "inline_math"));
1957 }
1958
1959 if let Some((start, end)) = cached_emoji.earliest_in(remaining, current_offset, |suffix| {
1961 EMOJI_SHORTCODE_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1962 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1963 {
1964 earliest_match = Some((start, end, "emoji"));
1965 }
1966
1967 if let Some((start, end)) = cached_html_entity.earliest_in(remaining, current_offset, |suffix| {
1969 HTML_ENTITY_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1970 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1971 {
1972 earliest_match = Some((start, end, "html_entity"));
1973 }
1974
1975 if let Some((start, end)) = cached_hugo_shortcode.earliest_in(remaining, current_offset, |suffix| {
1978 HUGO_SHORTCODE_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1979 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1980 {
1981 earliest_match = Some((start, end, "hugo_shortcode"));
1982 }
1983
1984 if let Some((start, end)) = cached_html_tag.earliest_in(remaining, current_offset, |suffix| {
1991 let mut from = 0;
1992 while let Some(m) = HTML_TAG_PATTERN.find(&suffix[from..]) {
1993 let (tag_start, tag_end) = (from + m.start(), from + m.end());
1994 let tag = &suffix[tag_start..tag_end];
1995 let is_url_autolink = tag.starts_with("<http://")
1997 || tag.starts_with("<https://")
1998 || tag.starts_with("<mailto:")
1999 || tag.starts_with("<ftp://")
2000 || tag.starts_with("<ftps://");
2001 let is_email_autolink = {
2004 let content = tag.trim_start_matches('<').trim_end_matches('>');
2005 EMAIL_PATTERN.is_match(content)
2006 };
2007 if is_url_autolink || is_email_autolink {
2008 from = tag_end;
2009 } else {
2010 return Some((tag_start, tag_end));
2011 }
2012 }
2013 None
2014 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
2015 {
2016 earliest_match = Some((start, end, "html_tag"));
2017 }
2018
2019 let mut next_special = remaining.len();
2021 let mut special_type = "";
2022 let mut pulldown_emphasis: Option<&EmphasisSpan> = None;
2023 let mut attr_list_len: usize = 0;
2024 let mut myst_role_len: usize = 0;
2025
2026 while code_span_idx < code_spans.len() && code_spans[code_span_idx].start < current_offset {
2028 code_span_idx += 1;
2029 }
2030 let next_code_span: Option<&CodeSpan> = code_spans.get(code_span_idx);
2031 if let Some(span) = next_code_span {
2032 let pos_in_remaining = span.start - current_offset;
2033 if pos_in_remaining < next_special {
2034 next_special = pos_in_remaining;
2035 special_type = "pulldown_code";
2036 }
2037 }
2038
2039 let next_curly_pos = cached_next_curly
2042 .earliest_in(remaining, current_offset, |suffix| {
2043 suffix.find('{').map(|pos| (pos, pos + 1))
2044 })
2045 .map(|(start, _)| start);
2046
2047 if myst_roles
2052 && let Some(pos) = next_curly_pos
2053 && pos < next_special
2054 && let Some(role_len) = myst_role_len_at(&remaining[pos..], current_offset + pos, &code_spans)
2055 {
2056 next_special = pos;
2057 special_type = "myst_role";
2058 myst_role_len = role_len;
2059 }
2060
2061 if attr_lists
2063 && let Some(pos) = next_curly_pos
2064 && pos < next_special
2065 && let Some(m) = ATTR_LIST_PATTERN.find(&remaining[pos..])
2066 && m.start() == 0
2067 {
2068 next_special = pos;
2069 special_type = "attr_list";
2070 attr_list_len = m.end();
2071 }
2072
2073 while emphasis_span_idx < emphasis_spans.len() && emphasis_spans[emphasis_span_idx].start < current_offset {
2075 emphasis_span_idx += 1;
2076 }
2077 if let Some(span) = emphasis_spans.get(emphasis_span_idx) {
2078 let pos_in_remaining = span.start - current_offset;
2079 if pos_in_remaining < next_special {
2080 next_special = pos_in_remaining;
2081 special_type = "pulldown_emphasis";
2082 pulldown_emphasis = Some(span);
2083 }
2084 }
2085
2086 let should_process_markdown_link = if let Some((pos, _, _)) = earliest_match {
2088 pos < next_special
2089 } else {
2090 false
2091 };
2092
2093 if should_process_markdown_link {
2094 let (pos, match_end, pattern_type) = earliest_match.unwrap();
2095
2096 if pos > 0 {
2098 elements.push(Element::Text(remaining[..pos].to_string()));
2099 }
2100
2101 match pattern_type {
2103 "link_span" => {
2104 let span = next_link.unwrap();
2105 let raw_text = remaining[pos..match_end].to_string();
2106 if span.is_footnote {
2107 elements.push(Element::FootnoteReference(raw_text));
2108 } else if span.is_image {
2109 match span.link_type {
2110 Some(LinkType::Inline) => elements.push(Element::InlineImage(raw_text)),
2111 Some(LinkType::Reference)
2114 | Some(LinkType::ReferenceUnknown)
2115 | Some(LinkType::Shortcut)
2116 | Some(LinkType::ShortcutUnknown) => elements.push(Element::ReferenceImage(raw_text)),
2117 Some(LinkType::Collapsed) | Some(LinkType::CollapsedUnknown) => {
2118 elements.push(Element::EmptyReferenceImage(raw_text))
2119 }
2120 _ => elements.push(Element::InlineImage(raw_text)),
2121 }
2122 } else {
2123 match span.link_type {
2124 Some(LinkType::Inline) => {
2125 if raw_text.starts_with('[') && raw_text.contains("![") {
2126 elements.push(Element::LinkedImage(raw_text));
2127 } else {
2128 elements.push(Element::Link(raw_text));
2129 }
2130 }
2131 Some(LinkType::Reference) | Some(LinkType::ReferenceUnknown) => {
2134 elements.push(Element::ReferenceLink(raw_text))
2135 }
2136 Some(LinkType::Collapsed) | Some(LinkType::CollapsedUnknown) => {
2137 elements.push(Element::EmptyReferenceLink(raw_text))
2138 }
2139 Some(LinkType::Shortcut) | Some(LinkType::ShortcutUnknown) => {
2140 elements.push(Element::ShortcutReference(raw_text))
2141 }
2142 Some(LinkType::Autolink) | Some(LinkType::Email) => {
2143 elements.push(Element::Autolink(raw_text))
2144 }
2145 _ => elements.push(Element::Link(raw_text)),
2146 }
2147 }
2148 remaining = &remaining[match_end..];
2149 }
2150 "wiki_link" => {
2151 if let Some(caps) = WIKI_LINK_REGEX.captures(remaining) {
2152 let content = caps.get(1).map_or("", |m| m.as_str());
2153 elements.push(Element::WikiLink(content.to_string()));
2154 remaining = &remaining[match_end..];
2155 } else {
2156 elements.push(Element::Text("[[".to_string()));
2157 remaining = &remaining[2..];
2158 }
2159 }
2160 "display_math" => {
2161 if let Some(caps) = DISPLAY_MATH_REGEX.captures(remaining) {
2162 let math = caps.get(1).map_or("", |m| m.as_str());
2163 elements.push(Element::DisplayMath(math.to_string()));
2164 remaining = &remaining[match_end..];
2165 } else {
2166 elements.push(Element::Text("$$".to_string()));
2167 remaining = &remaining[2..];
2168 }
2169 }
2170 "inline_math" => {
2171 if let Ok(Some(caps)) = INLINE_MATH_REGEX.captures(remaining) {
2172 let math = caps.get(1).map_or("", |m| m.as_str());
2173 elements.push(Element::InlineMath(math.to_string()));
2174 remaining = &remaining[match_end..];
2175 } else {
2176 elements.push(Element::Text("$".to_string()));
2177 remaining = &remaining[1..];
2178 }
2179 }
2180 "emoji" => {
2181 if let Some(caps) = EMOJI_SHORTCODE_REGEX.captures(remaining) {
2182 let emoji = caps.get(1).map_or("", |m| m.as_str());
2183 elements.push(Element::EmojiShortcode(emoji.to_string()));
2184 remaining = &remaining[match_end..];
2185 } else {
2186 elements.push(Element::Text(":".to_string()));
2187 remaining = &remaining[1..];
2188 }
2189 }
2190 "html_entity" => {
2191 elements.push(Element::HtmlEntity(remaining[pos..match_end].to_string()));
2193 remaining = &remaining[match_end..];
2194 }
2195 "hugo_shortcode" => {
2196 elements.push(Element::HugoShortcode(remaining[pos..match_end].to_string()));
2198 remaining = &remaining[match_end..];
2199 }
2200 "html_tag" => {
2201 elements.push(Element::HtmlTag(remaining[pos..match_end].to_string()));
2203 remaining = &remaining[match_end..];
2204 }
2205 _ => unreachable!("unknown pattern type: {}", pattern_type),
2206 }
2207 } else {
2208 if next_special > 0 && next_special < remaining.len() {
2212 elements.push(Element::Text(remaining[..next_special].to_string()));
2213 remaining = &remaining[next_special..];
2214 }
2215
2216 match special_type {
2218 "pulldown_code" => {
2219 let span = next_code_span.unwrap();
2220 let span_len = span.end - span.start;
2221 let code_raw = &remaining[..span_len];
2222 if let Some((content, marker)) = decompose_code_span(code_raw) {
2223 elements.push(Element::Code {
2224 content: content.to_string(),
2225 marker: marker.to_string(),
2226 });
2227 } else {
2228 elements.push(Element::Text(code_raw.to_string()));
2229 }
2230 remaining = &remaining[span_len..];
2231 }
2232 "attr_list" => {
2233 elements.push(Element::AttrList(remaining[..attr_list_len].to_string()));
2234 remaining = &remaining[attr_list_len..];
2235 }
2236 "myst_role" => {
2237 elements.push(Element::MystRole(remaining[..myst_role_len].to_string()));
2238 remaining = &remaining[myst_role_len..];
2239 }
2240 "pulldown_emphasis" => {
2241 let span = pulldown_emphasis.expect("pulldown_emphasis must be set");
2243 let span_len = span.end - span.start;
2244 if span.is_strikethrough {
2245 elements.push(Element::Strikethrough {
2246 content: span.content.clone(),
2247 double: span.strikethrough_double,
2248 });
2249 } else if span.is_strong {
2250 elements.push(Element::Bold {
2251 content: span.content.clone(),
2252 underscore: span.uses_underscore,
2253 });
2254 } else {
2255 elements.push(Element::Italic {
2256 content: span.content.clone(),
2257 underscore: span.uses_underscore,
2258 });
2259 }
2260 remaining = &remaining[span_len..];
2261 }
2262 _ => {
2263 elements.push(Element::Text(remaining.to_string()));
2265 break;
2266 }
2267 }
2268 }
2269 }
2270
2271 let mut merged_elements = Vec::new();
2273 for el in elements {
2274 match el {
2275 Element::Text(s) => {
2276 if let Some(Element::Text(last_s)) = merged_elements.last_mut() {
2277 last_s.push_str(&s);
2278 } else {
2279 merged_elements.push(Element::Text(s));
2280 }
2281 }
2282 other => merged_elements.push(other),
2283 }
2284 }
2285 merged_elements
2286}
2287
2288fn source_gap_before(elements: &[Element], idx: usize) -> &str {
2302 let Some(Element::Text(previous)) = idx.checked_sub(1).map(|prev| &elements[prev]) else {
2303 return "";
2304 };
2305
2306 let gap = &previous[previous.trim_end_matches(char::is_whitespace).len()..];
2307 if gap.is_empty() {
2308 ""
2309 } else if gap.contains(is_non_breaking_space) {
2310 gap
2311 } else {
2312 " "
2313 }
2314}
2315
2316fn push_source_gap(current_line: &mut String, gap: &str) {
2319 if !gap.is_empty() && !current_line.is_empty() && !current_line.ends_with(char::is_whitespace) {
2320 current_line.push_str(gap);
2321 }
2322}
2323
2324fn is_setext_or_thematic(text: &str) -> bool {
2330 let mut marker = 0u8;
2331 let mut count = 0usize;
2332 let mut has_space = false;
2333 for &b in text.as_bytes() {
2334 match b {
2335 b' ' | b'\t' => has_space = true,
2336 b'-' | b'=' | b'*' | b'_' => {
2337 if marker == 0 {
2338 marker = b;
2339 } else if b != marker {
2340 return false;
2341 }
2342 count += 1;
2343 }
2344 _ => return false,
2345 }
2346 }
2347 match marker {
2348 b'=' => !has_space,
2349 b'-' => !has_space || count >= 3,
2350 b'*' | b'_' => count >= 3,
2351 _ => false,
2352 }
2353}
2354
2355fn starts_block_construct(text: &str) -> bool {
2367 let text = text.trim_start();
2368 let bytes = text.as_bytes();
2369 let Some(&first) = bytes.first() else {
2370 return false;
2371 };
2372 let marker_then_boundary = |len: usize| bytes.len() == len || bytes[len] == b' ' || bytes[len] == b'\t';
2373 match first {
2374 b'>' => true,
2376 b'-' | b'*' | b'+' => marker_then_boundary(1) || is_setext_or_thematic(text),
2377 b'_' | b'=' => is_setext_or_thematic(text),
2378 b':' => is_definition_list_item(text) || text.starts_with(":::"),
2379 b'|' => true,
2380 b'#' => {
2381 let hashes = bytes.iter().take_while(|&&b| b == b'#').count();
2382 hashes <= 6 && marker_then_boundary(hashes)
2383 }
2384 b'`' => bytes.iter().take_while(|&&b| b == b'`').count() >= 3,
2385 b'~' => bytes.iter().take_while(|&&b| b == b'~').count() >= 3,
2386 b'0'..=b'9' => {
2393 let digits = bytes.iter().take_while(|b| b.is_ascii_digit()).count();
2394 digits <= 9
2395 && text[..digits].trim_start_matches('0') == "1"
2396 && bytes.len() > digits + 1
2397 && (bytes[digits] == b'.' || bytes[digits] == b')')
2398 && (bytes[digits + 1] == b' ' || bytes[digits + 1] == b'\t')
2399 }
2400 b'[' => {
2408 let mut escaped = false;
2409 let mut label_close = None;
2410 for (i, &b) in bytes.iter().enumerate().skip(1) {
2411 if escaped {
2412 escaped = false;
2413 } else if b == b'\\' {
2414 escaped = true;
2415 } else if b == b']' {
2416 label_close = Some(i);
2417 break;
2418 }
2419 }
2420 label_close.is_some_and(|i| bytes.get(i + 1) == Some(&b':'))
2421 }
2422 b'<' => crate::utils::html_block::parse_html_block_start(text).is_some(),
2425 _ => false,
2426 }
2427}
2428
2429fn merge_block_construct_continuations(lines: Vec<String>) -> Vec<String> {
2438 let mut merged: Vec<String> = Vec::with_capacity(lines.len());
2439 for line in lines {
2440 merged.push(line);
2441 while merged.len() > 1 && starts_block_construct(merged.last().expect("non-empty")) {
2445 let last = merged.pop().expect("non-empty");
2446 let prev = merged.last_mut().expect("len > 1");
2447 prev.push(' ');
2448 prev.push_str(last.trim_start());
2449 }
2450 }
2451 merged
2452}
2453
2454fn reflow_elements_sentence_per_line(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
2456 let abbreviations = get_abbreviations(&options.abbreviations);
2457 let require_sentence_capital = options.require_sentence_capital;
2458 let mut lines = Vec::new();
2459 let mut current_line = String::new();
2460
2461 for (idx, element) in elements.iter().enumerate() {
2462 let is_span = matches!(
2468 element,
2469 Element::Italic { .. } | Element::Bold { .. } | Element::Strikethrough { .. }
2470 );
2471 let piece = match element {
2472 Element::Text(text) => Some(text.clone()),
2474 Element::Italic { content, underscore } => Some(wrap_emphasis(
2475 content,
2476 if *underscore { "_" } else { "*" },
2477 &mut current_line,
2478 source_gap_before(elements, idx),
2479 )),
2480 Element::Bold { content, underscore } => Some(wrap_emphasis(
2481 content,
2482 if *underscore { "__" } else { "**" },
2483 &mut current_line,
2484 source_gap_before(elements, idx),
2485 )),
2486 Element::Strikethrough { content, double } => Some(wrap_emphasis(
2487 content,
2488 if *double { "~~" } else { "~" },
2489 &mut current_line,
2490 source_gap_before(elements, idx),
2491 )),
2492 _ => None,
2493 };
2494
2495 if let Some(piece) = piece {
2496 let appended_span_start = is_span.then_some(current_line.len());
2500 let combined = format!("{current_line}{piece}");
2501 let sentences = split_into_sentences_with_set(
2503 &combined,
2504 &abbreviations,
2505 require_sentence_capital,
2506 appended_span_start,
2507 options.defined_references.as_ref(),
2508 );
2509
2510 let next_bracketed = elements
2519 .get(idx + 1)
2520 .filter(|next| next.opens_with_bracket())
2521 .map(|next| (source_gap_before(elements, idx + 1), next.to_string()));
2522 let closes_before_next = |sentence: &str| -> bool {
2523 let Some((gap, next_str)) = &next_bracketed else {
2524 return true;
2525 };
2526 let mut probe = sentence.to_string();
2527 push_source_gap(&mut probe, gap);
2528 probe.push_str(next_str);
2529 let probe_sentences = split_into_sentences_with_set(
2530 &probe,
2531 &abbreviations,
2532 require_sentence_capital,
2533 None,
2534 options.defined_references.as_ref(),
2535 );
2536 probe_sentences.last().is_some_and(|last| last == next_str)
2537 };
2538
2539 if sentences.len() > 1 {
2540 let mut pending = String::new();
2544 let last = sentences.len() - 1;
2545 for (i, sentence) in sentences.iter().enumerate() {
2546 if !pending.is_empty() {
2547 pending.push(' ');
2548 }
2549 pending.push_str(sentence);
2550
2551 let closed = i < last || (ends_with_sentence_punct(&pending) && closes_before_next(&pending));
2556 if closed && !text_ends_with_abbreviation(&pending, &abbreviations) {
2557 lines.push(std::mem::take(&mut pending));
2558 }
2559 }
2560 current_line = pending;
2561 } else {
2562 let trimmed = combined.trim();
2564
2565 if trimmed.is_empty() {
2569 continue;
2570 }
2571
2572 let ends_with_sentence_punct = ends_with_sentence_punct(trimmed);
2573
2574 if ends_with_sentence_punct
2575 && !text_ends_with_abbreviation(trimmed, &abbreviations)
2576 && closes_before_next(trimmed)
2577 {
2578 lines.push(combined.trim_matches(is_breakable_whitespace).to_string());
2581 current_line.clear();
2582 } else {
2583 current_line = combined;
2585 }
2586 }
2587 } else {
2588 let element_str = format!("{element}");
2590 push_source_gap(&mut current_line, source_gap_before(elements, idx));
2591 current_line.push_str(&element_str);
2592 }
2593 }
2594
2595 if !current_line.is_empty() {
2607 let split_tail = (!current_line.contains(is_non_breaking_space))
2608 .then(|| {
2609 split_into_sentences_with_set(
2610 ¤t_line,
2611 &abbreviations,
2612 require_sentence_capital,
2613 None,
2614 options.defined_references.as_ref(),
2615 )
2616 })
2617 .filter(|sentences| sentences.len() > 1);
2618
2619 match split_tail {
2620 Some(sentences) => lines.extend(sentences),
2621 None => lines.push(current_line.trim_matches(is_breakable_whitespace).to_string()),
2622 }
2623 }
2624 lines
2625}
2626
2627fn wrap_emphasis(content: &str, marker: &str, current_line: &mut String, gap: &str) -> String {
2631 push_source_gap(current_line, gap);
2632 format!("{marker}{content}{marker}")
2633}
2634
2635const BREAK_WORDS: &[&str] = &[
2639 "and",
2640 "or",
2641 "but",
2642 "nor",
2643 "yet",
2644 "so",
2645 "for",
2646 "which",
2647 "that",
2648 "because",
2649 "when",
2650 "if",
2651 "while",
2652 "where",
2653 "although",
2654 "though",
2655 "unless",
2656 "since",
2657 "after",
2658 "before",
2659 "until",
2660 "as",
2661 "once",
2662 "whether",
2663 "however",
2664 "therefore",
2665 "moreover",
2666 "furthermore",
2667 "nevertheless",
2668 "whereas",
2669];
2670
2671fn is_clause_punctuation(c: char) -> bool {
2673 matches!(c, ',' | ';' | ':' | '\u{2014}') }
2675
2676fn clause_break_allowed_after(chars: &[char], i: usize) -> bool {
2686 match chars.get(i + 1) {
2687 None => true,
2688 Some(next) => is_breakable_whitespace(*next),
2689 }
2690}
2691
2692fn paren_group_end<'a>(slice: &'a str, element_spans: &[ElementSpan], offset: usize) -> Option<(usize, &'a str)> {
2706 debug_assert!(slice.starts_with('('));
2707 let mut depth: i32 = 0;
2708 for (local_byte, c) in slice.char_indices() {
2709 let global_byte = offset + local_byte;
2710 if depth > 0 && is_inside_element(global_byte, element_spans) {
2715 continue;
2716 }
2717 match c {
2718 '(' => depth += 1,
2719 ')' => {
2720 depth -= 1;
2721 if depth == 0 {
2722 let end = local_byte + 1;
2723 let inner = &slice[1..local_byte];
2724 return Some((end, inner));
2725 }
2726 }
2727 _ => {}
2728 }
2729 }
2730 None
2731}
2732
2733fn split_at_parenthetical(
2750 text: &str,
2751 line_length: usize,
2752 element_spans: &[ElementSpan],
2753 length_mode: ReflowLengthMode,
2754) -> Option<(String, String)> {
2755 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
2756
2757 if text.starts_with('(')
2759 && let Some((end_local, inner)) = paren_group_end(text, element_spans, 0)
2760 && inner.contains(' ')
2761 {
2762 let mut first_end = end_local;
2769 loop {
2770 first_end += text[first_end..]
2771 .char_indices()
2772 .take_while(|(_, c)| !is_breakable_whitespace(*c))
2773 .last()
2774 .map_or(0, |(idx, c)| idx + c.len_utf8());
2775 match element_containing(first_end, element_spans) {
2776 Some(span) => first_end = span.end,
2777 None => break,
2778 }
2779 }
2780 let rest_start = first_end;
2781 let first = &text[..first_end];
2782 if measure(first, 0, element_spans, length_mode).fits(line_length) {
2785 let rest = text[rest_start..].trim_start();
2786 if !rest.is_empty() {
2787 return Some((first.to_string(), rest.to_string()));
2788 }
2789 }
2790 }
2791
2792 let mut best_open_byte: Option<usize> = None;
2794 let mut pos = 0usize;
2795 while pos < text.len() {
2796 if text.as_bytes()[pos] != b'(' {
2798 let c = text[pos..].chars().next().unwrap();
2799 pos += c.len_utf8();
2800 continue;
2801 }
2802 if is_inside_element(pos, element_spans) {
2804 pos += 1;
2805 continue;
2806 }
2807 if let Some((end_local, inner)) = paren_group_end(&text[pos..], element_spans, pos) {
2808 let first = text[..pos].trim_end_matches(is_breakable_whitespace);
2809 let first_len = measure(first, 0, element_spans, length_mode).effective();
2810 if first.len() < pos
2813 && !first.is_empty()
2814 && first_len >= min_first_len
2815 && first_len <= line_length
2816 && inner.contains(' ')
2817 && best_open_byte.is_none_or(|prev| pos > prev)
2818 {
2819 best_open_byte = Some(pos);
2820 }
2821 pos += end_local;
2822 } else {
2823 pos += 1;
2824 }
2825 }
2826
2827 let open_byte = best_open_byte?;
2828 let first = text[..open_byte].trim_end_matches(is_breakable_whitespace).to_string();
2829 let rest = text[open_byte..].to_string();
2830 if first.is_empty() || rest.trim().is_empty() {
2831 return None;
2832 }
2833 Some((first, rest))
2834}
2835
2836#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2844struct ElementSpan {
2845 start: usize,
2846 end: usize,
2847 full: usize,
2848 link_saving: usize,
2851 code_saving: usize,
2853 is_hard: bool,
2855}
2856
2857impl ElementSpan {
2858 fn new(start: usize, len: usize, full: usize, width: LineWidth, is_hard: bool) -> Self {
2861 Self {
2862 start,
2863 end: start + len,
2864 full,
2865 link_saving: full - width.link_exempt,
2866 code_saving: full - width.code_exempt,
2867 is_hard,
2868 }
2869 }
2870
2871 fn contains(&self, pos: usize) -> bool {
2872 pos > self.start && pos < self.end
2873 }
2874
2875 fn within(&self, start: usize, end: usize) -> bool {
2876 self.start >= start && self.end <= end
2877 }
2878
2879 fn exempt_width(&self) -> LineWidth {
2880 LineWidth {
2881 link_exempt: self.full - self.link_saving,
2882 code_exempt: self.full - self.code_saving,
2883 }
2884 }
2885}
2886
2887fn compute_element_spans(
2893 elements: &[Element],
2894 mode: ReflowLengthMode,
2895 exemptions: LengthExemptions,
2896) -> Vec<ElementSpan> {
2897 let mut spans = Vec::new();
2898 let mut offset = 0;
2899 for element in elements {
2900 let len = element.display_len(ReflowLengthMode::Bytes);
2901 if !matches!(element, Element::Text(_)) {
2902 let full = element.display_len(mode);
2903 let width = element.exempt_width(mode, exemptions);
2904 let is_hard = match element {
2905 Element::Bold { content, .. }
2906 | Element::Italic { content, .. }
2907 | Element::Strikethrough { content, .. } => content.contains(['[', '`', '<', '$', '{']),
2908 _ => true,
2909 };
2910 spans.push(ElementSpan::new(offset, len, full, width, is_hard));
2911 }
2912 offset += len;
2913 }
2914 spans
2915}
2916
2917fn measure(text: &str, offset: usize, spans: &[ElementSpan], mode: ReflowLengthMode) -> LineWidth {
2925 let full = display_len(text, mode);
2926 let end = offset + text.len();
2927 let mut width = LineWidth::plain(full);
2928 for span in spans.iter().filter(|span| span.within(offset, end)) {
2929 width.link_exempt -= span.link_saving;
2930 width.code_exempt -= span.code_saving;
2931 }
2932 width
2933}
2934
2935fn line_width_components(line: &str, options: &ReflowOptions) -> LineWidth {
2940 let raw = display_len(line, options.length_mode);
2941 if !options.length_exemptions.any() {
2942 return LineWidth::plain(raw);
2943 }
2944 let elements = parse_markdown_elements_inner(
2945 line,
2946 options.attr_lists,
2947 options.myst_roles,
2948 options.defined_references.as_ref(),
2949 );
2950 let spans = compute_element_spans(&elements, options.length_mode, options.length_exemptions);
2951 measure(line, 0, &spans, options.length_mode)
2952}
2953
2954fn line_width(line: &str, options: &ReflowOptions) -> usize {
2956 line_width_components(line, options).effective()
2957}
2958
2959fn line_fits(line: &str, options: &ReflowOptions) -> bool {
2965 display_len(line, options.length_mode) <= options.line_length || line_width(line, options) <= options.line_length
2966}
2967
2968fn element_containing(pos: usize, spans: &[ElementSpan]) -> Option<ElementSpan> {
2970 spans.iter().copied().find(|span| span.contains(pos))
2971}
2972
2973fn is_inside_element(pos: usize, spans: &[ElementSpan]) -> bool {
2975 element_containing(pos, spans).is_some()
2976}
2977
2978const MIN_SPLIT_RATIO: f64 = 0.3;
2981
2982fn split_at_clause_punctuation(
2986 text: &str,
2987 line_length: usize,
2988 element_spans: &[ElementSpan],
2989 length_mode: ReflowLengthMode,
2990) -> Option<(String, String)> {
2991 let chars: Vec<char> = text.chars().collect();
2992 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
2993
2994 let mut width_acc = LineWidth::default();
3000 let mut search_end_char = 0;
3001 let mut byte = 0usize;
3002 let mut idx = 0usize;
3003 while idx < chars.len() {
3004 let (advance_chars, advance_bytes, width) = match element_spans.iter().find(|s| s.start == byte) {
3005 Some(span) => {
3006 let source = &text[span.start..span.end];
3007 (
3008 source.chars().count(),
3009 source.len(),
3010 measure(source, span.start, element_spans, length_mode),
3011 )
3012 }
3013 None => {
3014 let c = chars[idx];
3015 (
3016 1,
3017 c.len_utf8(),
3018 LineWidth::plain(display_len(&c.to_string(), length_mode)),
3019 )
3020 }
3021 };
3022 if !(width_acc + width).fits(line_length) {
3023 break;
3024 }
3025 width_acc += width;
3026 byte += advance_bytes;
3027 idx += advance_chars;
3028 search_end_char = idx;
3029 }
3030
3031 let mut paren_depth: i32 = 0;
3038 let mut best_pos = None;
3039 for i in (0..search_end_char).rev() {
3040 let byte_start: usize = chars[..i].iter().map(|c| c.len_utf8()).sum();
3042 let byte_after: usize = byte_start + chars[i].len_utf8();
3044
3045 if !is_inside_element(byte_start, element_spans) {
3046 match chars[i] {
3047 ')' => paren_depth += 1,
3048 '(' => paren_depth = paren_depth.saturating_sub(1),
3049 _ => {}
3050 }
3051 }
3052
3053 if paren_depth == 0
3054 && is_clause_punctuation(chars[i])
3055 && clause_break_allowed_after(&chars, i)
3056 && !is_inside_element(byte_after, element_spans)
3057 {
3058 best_pos = Some(i);
3059 break;
3060 }
3061 }
3062
3063 let pos = best_pos?;
3064
3065 let first: String = chars[..=pos].iter().collect();
3067 if measure(&first, 0, element_spans, length_mode).effective() < min_first_len {
3068 return None;
3069 }
3070
3071 let rest: String = chars[pos + 1..].iter().collect();
3073 let rest = rest.trim_start().to_string();
3074
3075 if rest.is_empty() {
3076 return None;
3077 }
3078
3079 Some((first, rest))
3080}
3081
3082fn paren_depth_map(text: &str, element_spans: &[ElementSpan]) -> Vec<i32> {
3089 let mut map = vec![0i32; text.len()];
3090 let mut depth = 0i32;
3091 for (byte, c) in text.char_indices() {
3092 if !is_inside_element(byte, element_spans) {
3093 match c {
3094 '(' => depth += 1,
3095 ')' => depth = depth.saturating_sub(1),
3096 _ => {}
3097 }
3098 }
3099 let end = (byte + c.len_utf8()).min(map.len());
3101 for slot in &mut map[byte..end] {
3102 *slot = depth;
3103 }
3104 }
3105 map
3106}
3107
3108fn is_standalone_parenthetical(line: &str) -> bool {
3117 let trimmed = line.trim();
3118 if !trimmed.starts_with('(') {
3119 return false;
3120 }
3121 let Some(close) = trimmed.rfind(')') else {
3124 return false;
3125 };
3126 if trimmed[close + 1..].contains(char::is_whitespace) {
3127 return false;
3128 }
3129 let core = &trimmed[..=close];
3130 let inner = &core[1..core.len() - 1];
3132 if !inner.contains(' ') {
3133 return false;
3134 }
3135 let mut depth = 0i32;
3137 for c in core.chars() {
3138 match c {
3139 '(' => depth += 1,
3140 ')' => depth -= 1,
3141 _ => {}
3142 }
3143 if depth < 0 {
3144 return false;
3145 }
3146 }
3147 depth == 0
3148}
3149
3150fn split_at_break_word(
3154 text: &str,
3155 line_length: usize,
3156 element_spans: &[ElementSpan],
3157 length_mode: ReflowLengthMode,
3158) -> Option<(String, String)> {
3159 let lower = text.to_lowercase();
3160 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
3161 let mut best_split: Option<(usize, usize)> = None; let depth_map = paren_depth_map(text, element_spans);
3166
3167 for &word in BREAK_WORDS {
3168 let mut search_start = 0;
3169 while let Some(pos) = lower[search_start..].find(word) {
3170 let abs_pos = search_start + pos;
3171
3172 let preceded_by_space = abs_pos == 0 || text.as_bytes().get(abs_pos - 1) == Some(&b' ');
3174 let followed_by_space = text.as_bytes().get(abs_pos + word.len()) == Some(&b' ');
3175
3176 if preceded_by_space && followed_by_space {
3177 let first_part = text[..abs_pos].trim_end();
3179 let first_part_len = measure(first_part, 0, element_spans, length_mode).effective();
3180
3181 let inside_paren = depth_map.get(abs_pos).is_some_and(|&d| d > 0);
3183
3184 if first_part_len >= min_first_len
3185 && first_part_len <= line_length
3186 && !is_inside_element(abs_pos, element_spans)
3187 && !inside_paren
3188 {
3189 if best_split.is_none_or(|(prev_pos, _)| abs_pos > prev_pos) {
3191 best_split = Some((abs_pos, word.len()));
3192 }
3193 }
3194 }
3195
3196 search_start = abs_pos + word.len();
3197 }
3198 }
3199
3200 let (byte_start, _word_len) = best_split?;
3201
3202 let first = text[..byte_start].trim_end().to_string();
3203 let rest = text[byte_start..].to_string();
3204
3205 if first.is_empty() || rest.trim().is_empty() {
3206 return None;
3207 }
3208
3209 Some((first, rest))
3210}
3211
3212fn replaces_whitespace(text: &str, first: &str, rest: &str, element_spans: &[ElementSpan]) -> bool {
3223 if !text.starts_with(first) || !text.ends_with(rest) {
3224 return false;
3225 }
3226 let gap_end = text.len() - rest.len();
3227 gap_end > first.len()
3228 && text[first.len()..gap_end].chars().all(is_breakable_whitespace)
3229 && !element_spans
3230 .iter()
3231 .any(|span| first.len() < span.end && span.start < gap_end)
3232}
3233
3234fn cascade_split_line(text: &str, options: &ReflowOptions) -> Vec<String> {
3245 let line_length = options.line_length;
3246 let length_mode = options.length_mode;
3247 let attr_lists = options.attr_lists;
3248 let myst_roles = options.myst_roles;
3249 let defined_references = options.defined_references.as_ref();
3250 if line_length == 0 || display_len(text, length_mode) <= line_length {
3251 return vec![text.to_string()];
3252 }
3253
3254 let elements = parse_markdown_elements_inner(text, attr_lists, myst_roles, defined_references);
3255 let element_spans = compute_element_spans(&elements, length_mode, options.length_exemptions);
3256
3257 if measure(text, 0, &element_spans, length_mode).fits(line_length) {
3260 return vec![text.to_string()];
3261 }
3262
3263 let rebased_spans = |start: usize| -> Vec<ElementSpan> {
3267 if start == 0 {
3268 return element_spans.clone();
3269 }
3270 element_spans
3271 .iter()
3272 .filter(|span| span.end > start)
3273 .map(|span| ElementSpan {
3274 start: span.start.saturating_sub(start),
3275 end: span.end.saturating_sub(start),
3276 ..*span
3277 })
3278 .collect()
3279 };
3280
3281 let mut result = Vec::new();
3282 let mut start = 0usize;
3283
3284 loop {
3285 let remaining = &text[start..];
3286 let spans = rebased_spans(start);
3287 if measure(remaining, 0, &spans, length_mode).fits(line_length) {
3288 result.push(remaining.to_string());
3289 return result;
3290 }
3291
3292 let at_whitespace = |candidate: Option<(String, String)>| {
3301 candidate.filter(|(first, rest)| replaces_whitespace(remaining, first, rest, &spans))
3302 };
3303 let split = at_whitespace(split_at_parenthetical(remaining, line_length, &spans, length_mode))
3304 .or_else(|| at_whitespace(split_at_clause_punctuation(remaining, line_length, &spans, length_mode)))
3305 .or_else(|| at_whitespace(split_at_break_word(remaining, line_length, &spans, length_mode)));
3306
3307 if let Some((first, rest)) = split {
3308 let consumed = remaining.len().saturating_sub(rest.len());
3309 if consumed == 0 {
3312 break;
3313 }
3314 result.push(first);
3315 start += consumed;
3316 continue;
3317 }
3318
3319 break;
3321 }
3322
3323 let mut fallback_options = options.clone();
3325 fallback_options.break_on_sentences = false;
3326 fallback_options.preserve_breaks = false;
3327 fallback_options.sentence_per_line = false;
3328 fallback_options.semantic_line_breaks = false;
3329 fallback_options.require_sentence_capital = true;
3330 fallback_options.max_list_continuation_indent = None;
3331 fallback_options.defined_references = None;
3332 let remaining = &text[start..];
3333 let tail_elements = if start == 0 {
3334 elements
3335 } else {
3336 parse_markdown_elements_inner(remaining, attr_lists, myst_roles, defined_references)
3337 };
3338 result.extend(reflow_elements(&tail_elements, &fallback_options));
3339 result
3340}
3341
3342fn reflow_elements_semantic(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
3346 let sentence_lines = reflow_elements_sentence_per_line(elements, options);
3348
3349 if options.line_length == 0 {
3352 return sentence_lines;
3353 }
3354
3355 let mut result = Vec::new();
3356 for line in sentence_lines {
3357 if line_fits(&line, options) {
3358 result.push(line);
3359 } else {
3360 result.extend(cascade_split_line(&line, options));
3361 }
3362 }
3363
3364 let min_line_len = ((options.line_length as f64) * MIN_SPLIT_RATIO) as usize;
3367 let mut merged: Vec<String> = Vec::with_capacity(result.len());
3368 for line in result {
3369 if !merged.is_empty() && line_width(&line, options) < min_line_len && !line.trim().is_empty() {
3370 if is_standalone_parenthetical(&line) {
3373 merged.push(line);
3374 continue;
3375 }
3376
3377 let prev_ends_at_sentence = {
3379 let trimmed = merged.last().unwrap().trim_end();
3380 trimmed
3381 .chars()
3382 .rev()
3383 .find(|c| !matches!(c, '"' | '\'' | '\u{201D}' | '\u{2019}' | ')' | ']'))
3384 .is_some_and(|c| matches!(c, '.' | '!' | '?'))
3385 };
3386
3387 if !prev_ends_at_sentence {
3388 let prev = merged.last_mut().unwrap();
3389 let combined = format!("{prev} {line}");
3390 if line_fits(&combined, options) {
3392 *prev = combined;
3393 continue;
3394 }
3395 }
3396 }
3397 merged.push(line);
3398 }
3399 merged
3400}
3401
3402fn rfind_safe_space(
3412 line: &str,
3413 element_spans: &[ElementSpan],
3414 options: &ReflowOptions,
3415 relax_soft_spans: bool,
3416) -> Option<usize> {
3417 line.char_indices().rev().map(|(pos, _)| pos).find(|&pos| {
3418 line.as_bytes()[pos] == b' '
3419 && !is_inside_element_filtered(pos, element_spans, options, relax_soft_spans)
3420 && !starts_block_construct(&line[pos + 1..])
3421 })
3422}
3423
3424fn is_inside_element_filtered(
3425 pos: usize,
3426 spans: &[ElementSpan],
3427 options: &ReflowOptions,
3428 relax_soft_spans: bool,
3429) -> bool {
3430 spans.iter().any(|span| {
3431 span.contains(pos)
3432 && (!relax_soft_spans
3433 || span.is_hard
3434 || (options.atomic_spans && span.exempt_width().fits(options.line_length)))
3435 })
3436}
3437
3438#[derive(Clone, Copy)]
3443struct Attached<'a> {
3444 text: &'a str,
3445 width: LineWidth,
3446 separator: &'a str,
3447}
3448
3449fn break_before_attached(
3466 lines: &mut Vec<String>,
3467 current_line: &mut String,
3468 current_width: &mut LineWidth,
3469 element_spans: &mut Vec<ElementSpan>,
3470 attach: Attached<'_>,
3471 options: &ReflowOptions,
3472) -> Option<usize> {
3473 let length_mode = options.length_mode;
3474 let last_space = rfind_safe_space(current_line, element_spans, options, false)
3475 .or_else(|| rfind_safe_space(current_line, element_spans, options, true))?;
3476 let before = current_line[..last_space]
3477 .trim_end_matches(is_breakable_whitespace)
3478 .to_string();
3479 let after = current_line[last_space + 1..].to_string();
3480 let after_width = measure(&after, last_space + 1, element_spans, length_mode);
3481 lines.push(before);
3482 let carried = after.len();
3483 let Attached { text, width, separator } = attach;
3484 *current_line = format!("{after}{separator}{text}");
3485 *current_width = after_width + LineWidth::plain(display_len(separator, length_mode)) + width;
3486 rebase_spans_after_break(element_spans, last_space + 1);
3487 Some(carried)
3488}
3489
3490fn rebase_spans_after_break(element_spans: &mut Vec<ElementSpan>, carried_start: usize) {
3499 element_spans.retain(|span| span.end > carried_start);
3500 for span in element_spans.iter_mut() {
3501 span.start = span.start.saturating_sub(carried_start);
3502 span.end -= carried_start;
3503 }
3504}
3505
3506fn reflow_elements(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
3508 let mut lines = Vec::new();
3509 let mut current_line = String::new();
3510 let mut current_width = LineWidth::default();
3513 let mut current_line_element_spans: Vec<ElementSpan> = Vec::new();
3515 let length_mode = options.length_mode;
3516 let exemptions = options.length_exemptions;
3517
3518 for (idx, element) in elements.iter().enumerate() {
3519 let element_len = element.display_len(length_mode);
3520 let element_width = element.exempt_width(length_mode, exemptions);
3521 let is_hard = match element {
3522 Element::Bold { content, .. }
3523 | Element::Italic { content, .. }
3524 | Element::Strikethrough { content, .. } => content.contains(['[', '`', '<', '$', '{']),
3525 _ => true,
3526 };
3527
3528 let is_adjacent_to_prev = if idx > 0 {
3537 match (&elements[idx - 1], element) {
3538 (Element::Text(t), _) => !t.is_empty() && !t.ends_with(is_breakable_whitespace),
3539 (_, Element::Text(t)) => !t.is_empty() && !t.starts_with(is_breakable_whitespace),
3540 _ => true,
3541 }
3542 } else {
3543 false
3544 };
3545
3546 if let Element::Text(text) = element {
3548 let has_leading_space = text.starts_with(is_breakable_whitespace);
3550 let words: Vec<&str> = split_breakable_words(text).collect();
3552
3553 for (i, word) in words.iter().enumerate() {
3554 let word_width = LineWidth::plain(display_len(word, length_mode));
3556 let is_trailing_punct = word.chars().all(|c| {
3562 matches!(c, ',' | '.' | ':' | ';' | '!' | '?' | ')' | ']' | '}') || is_non_breaking_space(c)
3563 });
3564
3565 let is_first_adjacent = i == 0 && is_adjacent_to_prev;
3568
3569 if is_first_adjacent {
3570 if !(current_width + word_width).fits(options.line_length)
3572 && !current_width.is_empty()
3573 && break_before_attached(
3574 &mut lines,
3575 &mut current_line,
3576 &mut current_width,
3577 &mut current_line_element_spans,
3578 Attached {
3579 text: word,
3580 width: word_width,
3581 separator: "",
3582 },
3583 options,
3584 )
3585 .is_some()
3586 {
3587 } else {
3592 current_line.push_str(word);
3593 current_width += word_width;
3594 }
3595 } else if !current_width.is_empty()
3596 && !(current_width + LineWidth::plain(1) + word_width).fits(options.line_length)
3597 {
3598 if is_trailing_punct {
3599 if break_before_attached(
3606 &mut lines,
3607 &mut current_line,
3608 &mut current_width,
3609 &mut current_line_element_spans,
3610 Attached {
3611 text: word,
3612 width: word_width,
3613 separator: " ",
3614 },
3615 options,
3616 )
3617 .is_none()
3618 {
3619 current_line.push(' ');
3620 current_line.push_str(word);
3621 current_width += LineWidth::plain(1) + word_width;
3622 }
3623 } else if !starts_block_construct(word) {
3624 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
3626 current_line = word.to_string();
3627 current_width = word_width;
3628 current_line_element_spans.clear();
3629 } else if break_before_attached(
3630 &mut lines,
3631 &mut current_line,
3632 &mut current_width,
3633 &mut current_line_element_spans,
3634 Attached {
3635 text: word,
3636 width: word_width,
3637 separator: " ",
3638 },
3639 options,
3640 )
3641 .is_some()
3642 {
3643 } else {
3648 if i > 0 || has_leading_space {
3651 current_line.push(' ');
3652 current_width += LineWidth::plain(1);
3653 }
3654 current_line.push_str(word);
3655 current_width += word_width;
3656 }
3657 } else {
3658 let add_space = !current_width.is_empty() && (i > 0 || has_leading_space);
3670 if add_space {
3671 current_line.push(' ');
3672 current_width += LineWidth::plain(1);
3673 }
3674 current_line.push_str(word);
3675 current_width += word_width;
3676 }
3677 }
3678 } else {
3679 let span_info = match element {
3680 Element::Italic { content, underscore } => {
3681 Some((content.as_str(), if *underscore { "_" } else { "*" }, false))
3682 }
3683 Element::Bold { content, underscore } => {
3684 Some((content.as_str(), if *underscore { "__" } else { "**" }, false))
3685 }
3686 Element::Strikethrough { content, double } => {
3687 Some((content.as_str(), if *double { "~~" } else { "~" }, false))
3688 }
3689 Element::Code { content, marker } => Some((content.as_str(), marker.as_str(), true)),
3690 _ => None,
3691 };
3692
3693 let breakable: Option<Vec<&str>> = match span_info {
3697 Some((content, _, is_code)) => {
3698 if is_code {
3699 (!options.atomic_spans && code_span_wraps_losslessly(content))
3700 .then(|| split_breakable_words(content).collect())
3701 } else {
3702 (!options.atomic_spans || element_len > options.line_length)
3703 .then(|| breakable_units(content, options.defined_references.as_ref(), options.attr_lists))
3704 .flatten()
3705 }
3706 }
3707 None => None,
3708 };
3709
3710 if let Some(words) = breakable {
3711 let (_, marker, is_code) = span_info.expect("breakable implies a span");
3712 let n = words.len();
3713 if n == 0 {
3714 let full = format!("{marker}{marker}");
3716 let full_width = LineWidth::plain(display_len(&full, length_mode));
3717 if !is_adjacent_to_prev && !current_width.is_empty() {
3718 current_line.push(' ');
3719 current_width += LineWidth::plain(1);
3720 }
3721 current_line.push_str(&full);
3722 current_width += full_width;
3723 } else {
3724 for (i, word) in words.iter().enumerate() {
3725 let is_first = i == 0;
3726 let is_last = i == n - 1;
3727
3728 let space_start = if is_first && is_code && word.starts_with('`') {
3729 " "
3730 } else {
3731 ""
3732 };
3733 let space_end = if is_last && is_code && word.ends_with('`') {
3734 " "
3735 } else {
3736 ""
3737 };
3738
3739 let word_str: String = match (is_first, is_last) {
3740 (true, true) => format!("{marker}{space_start}{word}{space_end}{marker}"),
3741 (true, false) => format!("{marker}{space_start}{word}"),
3742 (false, true) => format!("{word}{space_end}{marker}"),
3743 (false, false) => word.to_string(),
3744 };
3745 let word_elements = parse_elements(&word_str, options);
3746 let word_spans = compute_element_spans(&word_elements, length_mode, exemptions);
3747 let word_width = measure(&word_str, 0, &word_spans, length_mode);
3748
3749 let needs_space = if is_first {
3750 !is_adjacent_to_prev && !current_width.is_empty()
3751 } else {
3752 !current_width.is_empty()
3753 };
3754
3755 if needs_space
3756 && !(current_width + LineWidth::plain(1) + word_width).fits(options.line_length)
3757 && !starts_block_construct(&word_str)
3758 {
3759 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
3760 current_line = word_str;
3761 current_width = word_width;
3762 current_line_element_spans.clear();
3763 for span in word_spans {
3764 current_line_element_spans.push(span);
3765 }
3766 } else {
3767 let mut start_pos = current_line.len();
3768 if needs_space {
3769 current_line.push(' ');
3770 current_width += LineWidth::plain(1);
3771 start_pos += 1;
3772 }
3773 current_line.push_str(&word_str);
3774 current_width += word_width;
3775 for mut span in word_spans {
3776 span.start += start_pos;
3777 span.end += start_pos;
3778 current_line_element_spans.push(span);
3779 }
3780 }
3781 }
3782 }
3783 } else {
3784 let element_str = format!("{element}");
3787
3788 if is_adjacent_to_prev {
3789 if !(current_width + element_width).fits(options.line_length)
3791 && let Some(carried) = break_before_attached(
3792 &mut lines,
3793 &mut current_line,
3794 &mut current_width,
3795 &mut current_line_element_spans,
3796 Attached {
3797 text: &element_str,
3798 width: element_width,
3799 separator: "",
3800 },
3801 options,
3802 )
3803 {
3804 current_line_element_spans.push(ElementSpan::new(
3808 carried,
3809 element_str.len(),
3810 element_len,
3811 element_width,
3812 is_hard,
3813 ));
3814 } else {
3815 let start = current_line.len();
3816 current_line.push_str(&element_str);
3817 current_width += element_width;
3818 current_line_element_spans.push(ElementSpan::new(
3819 start,
3820 element_str.len(),
3821 element_len,
3822 element_width,
3823 is_hard,
3824 ));
3825 }
3826 } else if !current_width.is_empty()
3827 && !(current_width + LineWidth::plain(1) + element_width).fits(options.line_length)
3828 {
3829 if !starts_block_construct(&element_str) {
3830 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
3832 current_line.clone_from(&element_str);
3833 current_width = element_width;
3834 current_line_element_spans.clear();
3835 current_line_element_spans.push(ElementSpan::new(
3836 0,
3837 element_str.len(),
3838 element_len,
3839 element_width,
3840 is_hard,
3841 ));
3842 } else if let Some(carried) = break_before_attached(
3843 &mut lines,
3844 &mut current_line,
3845 &mut current_width,
3846 &mut current_line_element_spans,
3847 Attached {
3848 text: &element_str,
3849 width: element_width,
3850 separator: " ",
3851 },
3852 options,
3853 ) {
3854 let start = carried + 1;
3858 current_line_element_spans.push(ElementSpan::new(
3859 start,
3860 element_str.len(),
3861 element_len,
3862 element_width,
3863 is_hard,
3864 ));
3865 } else {
3866 let ends_with_opener =
3869 current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
3870 if !ends_with_opener {
3871 current_line.push(' ');
3872 current_width += LineWidth::plain(1);
3873 }
3874 let start = current_line.len();
3875 current_line.push_str(&element_str);
3876 current_width += element_width;
3877 current_line_element_spans.push(ElementSpan::new(
3878 start,
3879 element_str.len(),
3880 element_len,
3881 element_width,
3882 is_hard,
3883 ));
3884 }
3885 } else {
3886 let ends_with_opener =
3888 current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
3889 if !current_width.is_empty() && !ends_with_opener {
3890 current_line.push(' ');
3891 current_width += LineWidth::plain(1);
3892 }
3893 let start = current_line.len();
3894 current_line.push_str(&element_str);
3895 current_width += element_width;
3896 current_line_element_spans.push(ElementSpan::new(
3897 start,
3898 element_str.len(),
3899 element_len,
3900 element_width,
3901 is_hard,
3902 ));
3903 }
3904 }
3905 }
3906 }
3907
3908 if !current_line.is_empty() {
3910 lines.push(current_line.trim_end_matches(is_breakable_whitespace).to_string());
3911 }
3912
3913 lines
3914}
3915
3916pub fn reflow_markdown(content: &str, options: &ReflowOptions) -> String {
3918 let lines: Vec<&str> = content.lines().collect();
3919 let mut result = Vec::new();
3920 let mut i = 0;
3921
3922 while i < lines.len() {
3923 let line = lines[i];
3924 let trimmed = line.trim();
3925
3926 if trimmed.is_empty() {
3928 result.push(String::new());
3929 i += 1;
3930 continue;
3931 }
3932
3933 if trimmed.starts_with('#') {
3935 result.push(line.to_string());
3936 i += 1;
3937 continue;
3938 }
3939
3940 if trimmed.starts_with(":::") {
3942 result.push(line.to_string());
3943 i += 1;
3944 continue;
3945 }
3946
3947 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
3949 result.push(line.to_string());
3950 i += 1;
3951 while i < lines.len() {
3953 result.push(lines[i].to_string());
3954 if lines[i].trim().starts_with("```") || lines[i].trim().starts_with("~~~") {
3955 i += 1;
3956 break;
3957 }
3958 i += 1;
3959 }
3960 continue;
3961 }
3962
3963 if calculate_indentation_width_default(line) >= 4 {
3965 result.push(line.to_string());
3967 i += 1;
3968 while i < lines.len() {
3969 let next_line = lines[i];
3970 if calculate_indentation_width_default(next_line) >= 4 || next_line.trim().is_empty() {
3972 result.push(next_line.to_string());
3973 i += 1;
3974 } else {
3975 break;
3976 }
3977 }
3978 continue;
3979 }
3980
3981 if trimmed.starts_with('>') {
3983 let gt_pos = line.find('>').expect("'>' must exist since trimmed.starts_with('>')");
3986 let quote_prefix = line[0..=gt_pos].to_string();
3987 let quote_content = &line[quote_prefix.len()..].trim_start();
3988
3989 let reflowed = reflow_line(quote_content, options);
3990 for reflowed_line in &reflowed {
3991 result.push(format!("{quote_prefix} {reflowed_line}"));
3992 }
3993 i += 1;
3994 continue;
3995 }
3996
3997 if is_horizontal_rule(trimmed) {
3999 result.push(line.to_string());
4000 i += 1;
4001 continue;
4002 }
4003
4004 if is_unordered_list_marker(trimmed) || is_numbered_list_item(trimmed) {
4006 let indent = line.len() - line.trim_start().len();
4008 let indent_str = " ".repeat(indent);
4009
4010 let mut marker_end = indent;
4013 let mut content_start = indent;
4014
4015 if trimmed.chars().next().is_some_and(char::is_numeric) {
4016 if let Some(period_pos) = line[indent..].find('.') {
4018 marker_end = indent + period_pos + 1; content_start = marker_end;
4020 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
4024 content_start += 1;
4025 }
4026 }
4027 } else {
4028 marker_end = indent + 1; content_start = marker_end;
4031 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
4035 content_start += 1;
4036 }
4037 }
4038
4039 let min_continuation_indent = content_start;
4041
4042 let rest = &line[content_start..];
4045 if rest.starts_with("[ ] ") || rest.starts_with("[x] ") || rest.starts_with("[X] ") {
4046 marker_end = content_start + 3; content_start += 4; }
4049
4050 let marker = &line[indent..marker_end];
4051
4052 let mut list_content = vec![trim_preserving_hard_break(&line[content_start..])];
4055 i += 1;
4056
4057 while i < lines.len() {
4061 let next_line = lines[i];
4062 let next_trimmed = next_line.trim();
4063
4064 if is_block_boundary(next_trimmed) {
4066 break;
4067 }
4068
4069 let next_indent = next_line.len() - next_line.trim_start().len();
4071 if next_indent >= min_continuation_indent {
4072 let trimmed_start = next_line.trim_start();
4075 list_content.push(trim_preserving_hard_break(trimmed_start));
4076 i += 1;
4077 } else {
4078 break;
4080 }
4081 }
4082
4083 let combined_content = if options.preserve_breaks {
4086 list_content[0].clone()
4087 } else {
4088 let has_hard_breaks = list_content.iter().any(|line| has_hard_break(line));
4090 if has_hard_breaks {
4091 list_content.join("\n")
4093 } else {
4094 list_content.join(" ")
4096 }
4097 };
4098
4099 let trimmed_marker = marker;
4101 let continuation_spaces = if let Some(max_indent) = options.max_list_continuation_indent {
4102 indent + (content_start - indent).min(max_indent)
4105 } else {
4106 content_start
4107 };
4108
4109 let prefix_length = indent + trimmed_marker.len() + 1;
4111
4112 let adjusted_options = ReflowOptions {
4114 line_length: options.line_length.saturating_sub(prefix_length),
4115 ..options.clone()
4116 };
4117
4118 let reflowed = reflow_line(&combined_content, &adjusted_options);
4119 for (j, reflowed_line) in reflowed.iter().enumerate() {
4120 if j == 0 {
4121 result.push(format!("{indent_str}{trimmed_marker} {reflowed_line}"));
4122 } else {
4123 let continuation_indent = " ".repeat(continuation_spaces);
4125 result.push(format!("{continuation_indent}{reflowed_line}"));
4126 }
4127 }
4128 continue;
4129 }
4130
4131 if crate::utils::table_utils::TableUtils::is_potential_table_row(line) {
4133 result.push(line.to_string());
4134 i += 1;
4135 continue;
4136 }
4137
4138 if trimmed.starts_with('[') && line.contains("]:") {
4140 result.push(line.to_string());
4141 i += 1;
4142 continue;
4143 }
4144
4145 if is_definition_list_item(trimmed) {
4147 result.push(line.to_string());
4148 i += 1;
4149 continue;
4150 }
4151
4152 let mut is_single_line_paragraph = true;
4154 if i + 1 < lines.len() {
4155 let next_trimmed = lines[i + 1].trim();
4156 if !is_block_boundary(next_trimmed) {
4158 is_single_line_paragraph = false;
4159 }
4160 }
4161
4162 if is_single_line_paragraph && line_fits(line, options) {
4164 result.push(line.to_string());
4165 i += 1;
4166 continue;
4167 }
4168
4169 let mut paragraph_parts = Vec::new();
4171 let mut current_part = vec![line];
4172 i += 1;
4173
4174 if options.preserve_breaks {
4176 let hard_break_type = if line.strip_suffix('\r').unwrap_or(line).ends_with('\\') {
4178 Some("\\")
4179 } else if line.ends_with(" ") {
4180 Some(" ")
4181 } else {
4182 None
4183 };
4184 let reflowed = reflow_line(line, options);
4185
4186 if let Some(break_marker) = hard_break_type {
4188 if !reflowed.is_empty() {
4189 let mut reflowed_with_break = reflowed;
4190 let last_idx = reflowed_with_break.len() - 1;
4191 if !has_hard_break(&reflowed_with_break[last_idx]) {
4192 reflowed_with_break[last_idx].push_str(break_marker);
4193 }
4194 result.extend(reflowed_with_break);
4195 }
4196 } else {
4197 result.extend(reflowed);
4198 }
4199 } else {
4200 while i < lines.len() {
4202 let prev_line = if !current_part.is_empty() {
4203 current_part.last().unwrap()
4204 } else {
4205 ""
4206 };
4207 let next_line = lines[i];
4208 let next_trimmed = next_line.trim();
4209
4210 if is_block_boundary(next_trimmed) {
4212 break;
4213 }
4214
4215 let prev_trimmed = prev_line.trim();
4218 let abbreviations = get_abbreviations(&options.abbreviations);
4219 let ends_with_sentence = (prev_trimmed.ends_with('.')
4220 || prev_trimmed.ends_with('!')
4221 || prev_trimmed.ends_with('?')
4222 || prev_trimmed.ends_with(".*")
4223 || prev_trimmed.ends_with("!*")
4224 || prev_trimmed.ends_with("?*")
4225 || prev_trimmed.ends_with("._")
4226 || prev_trimmed.ends_with("!_")
4227 || prev_trimmed.ends_with("?_")
4228 || prev_trimmed.ends_with(".\"")
4230 || prev_trimmed.ends_with("!\"")
4231 || prev_trimmed.ends_with("?\"")
4232 || prev_trimmed.ends_with(".'")
4233 || prev_trimmed.ends_with("!'")
4234 || prev_trimmed.ends_with("?'")
4235 || prev_trimmed.ends_with(".\u{201D}")
4236 || prev_trimmed.ends_with("!\u{201D}")
4237 || prev_trimmed.ends_with("?\u{201D}")
4238 || prev_trimmed.ends_with(".\u{2019}")
4239 || prev_trimmed.ends_with("!\u{2019}")
4240 || prev_trimmed.ends_with("?\u{2019}"))
4241 && !text_ends_with_abbreviation(
4242 prev_trimmed.trim_end_matches(['*', '_', '"', '\'', '\u{201D}', '\u{2019}']),
4243 &abbreviations,
4244 );
4245
4246 if has_hard_break(prev_line) || (options.sentence_per_line && ends_with_sentence) {
4247 paragraph_parts.push(current_part.join(" "));
4249 current_part = vec![next_line];
4250 } else {
4251 current_part.push(next_line);
4252 }
4253 i += 1;
4254 }
4255
4256 if !current_part.is_empty() {
4258 if current_part.len() == 1 {
4259 paragraph_parts.push(current_part[0].to_string());
4261 } else {
4262 paragraph_parts.push(current_part.join(" "));
4263 }
4264 }
4265
4266 for (j, part) in paragraph_parts.iter().enumerate() {
4268 let reflowed = reflow_line(part, options);
4269 result.extend(reflowed);
4270
4271 if j < paragraph_parts.len() - 1 && !result.is_empty() && !options.sentence_per_line {
4275 let last_idx = result.len() - 1;
4276 if !has_hard_break(&result[last_idx]) {
4277 result[last_idx].push_str(" ");
4278 }
4279 }
4280 }
4281 }
4282 }
4283
4284 let result_text = result.join("\n");
4286 if content.ends_with('\n') && !result_text.ends_with('\n') {
4287 format!("{result_text}\n")
4288 } else {
4289 result_text
4290 }
4291}
4292
4293#[derive(Debug, Clone)]
4295pub struct ParagraphReflow {
4296 pub start_byte: usize,
4298 pub end_byte: usize,
4300 pub reflowed_text: String,
4302}
4303
4304#[derive(Debug, Clone)]
4310pub struct BlockquoteLineData {
4311 pub(crate) content: String,
4313 pub(crate) is_explicit: bool,
4315 pub(crate) prefix: Option<String>,
4317}
4318
4319impl BlockquoteLineData {
4320 pub fn explicit(content: String, prefix: String) -> Self {
4322 Self {
4323 content,
4324 is_explicit: true,
4325 prefix: Some(prefix),
4326 }
4327 }
4328
4329 pub fn lazy(content: String) -> Self {
4331 Self {
4332 content,
4333 is_explicit: false,
4334 prefix: None,
4335 }
4336 }
4337}
4338
4339#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4341pub enum BlockquoteContinuationStyle {
4342 Explicit,
4343 Lazy,
4344}
4345
4346pub fn blockquote_continuation_style(lines: &[BlockquoteLineData]) -> BlockquoteContinuationStyle {
4354 let mut explicit_count = 0usize;
4355 let mut lazy_count = 0usize;
4356
4357 for line in lines.iter().skip(1) {
4358 if line.is_explicit {
4359 explicit_count += 1;
4360 } else {
4361 lazy_count += 1;
4362 }
4363 }
4364
4365 if explicit_count > 0 && lazy_count == 0 {
4366 BlockquoteContinuationStyle::Explicit
4367 } else if lazy_count > 0 && explicit_count == 0 {
4368 BlockquoteContinuationStyle::Lazy
4369 } else if explicit_count >= lazy_count {
4370 BlockquoteContinuationStyle::Explicit
4371 } else {
4372 BlockquoteContinuationStyle::Lazy
4373 }
4374}
4375
4376pub fn dominant_blockquote_prefix(lines: &[BlockquoteLineData], fallback: &str) -> String {
4381 let mut counts: std::collections::HashMap<String, (usize, usize)> = std::collections::HashMap::new();
4382
4383 for (idx, line) in lines.iter().enumerate() {
4384 let Some(prefix) = line.prefix.as_ref() else {
4385 continue;
4386 };
4387 counts
4388 .entry(prefix.clone())
4389 .and_modify(|entry| entry.0 += 1)
4390 .or_insert((1, idx));
4391 }
4392
4393 counts
4394 .into_iter()
4395 .max_by(|(_, (count_a, first_idx_a)), (_, (count_b, first_idx_b))| {
4396 count_a.cmp(count_b).then_with(|| first_idx_b.cmp(first_idx_a))
4397 })
4398 .map_or_else(|| fallback.to_string(), |(prefix, _)| prefix)
4399}
4400
4401pub(crate) fn should_force_explicit_blockquote_line(content_line: &str) -> bool {
4406 let trimmed = content_line.trim_start();
4407 trimmed.starts_with('>')
4408 || trimmed.starts_with('#')
4409 || trimmed.starts_with("```")
4410 || trimmed.starts_with("~~~")
4411 || is_unordered_list_marker(trimmed)
4412 || is_numbered_list_item(trimmed)
4413 || is_horizontal_rule(trimmed)
4414 || is_definition_list_item(trimmed)
4415 || (trimmed.starts_with('[') && trimmed.contains("]:"))
4416 || trimmed.starts_with(":::")
4417 || (trimmed.starts_with('<')
4418 && !trimmed.starts_with("<http")
4419 && !trimmed.starts_with("<https")
4420 && !trimmed.starts_with("<mailto:"))
4421}
4422
4423pub fn reflow_blockquote_content(
4432 lines: &[BlockquoteLineData],
4433 explicit_prefix: &str,
4434 continuation_style: BlockquoteContinuationStyle,
4435 options: &ReflowOptions,
4436) -> Vec<String> {
4437 let content_strs: Vec<&str> = lines.iter().map(|l| l.content.as_str()).collect();
4438 let segments = split_into_segments_strs(&content_strs);
4439 let mut reflowed_content_lines: Vec<String> = Vec::new();
4440
4441 for segment in segments {
4442 let hard_break_type = segment.last().and_then(|&line| {
4443 let line = line.strip_suffix('\r').unwrap_or(line);
4444 if line.ends_with('\\') {
4445 Some("\\")
4446 } else if line.ends_with(" ") {
4447 Some(" ")
4448 } else {
4449 None
4450 }
4451 });
4452
4453 let pieces: Vec<&str> = segment
4454 .iter()
4455 .map(|&line| {
4456 if let Some(l) = line.strip_suffix('\\') {
4457 l.trim_end()
4458 } else if let Some(l) = line.strip_suffix(" ") {
4459 l.trim_end()
4460 } else {
4461 line.trim_end()
4462 }
4463 })
4464 .collect();
4465
4466 let segment_text = pieces.join(" ");
4467 let segment_text = segment_text.trim();
4468 if segment_text.is_empty() {
4469 continue;
4470 }
4471
4472 let mut reflowed = reflow_line(segment_text, options);
4473 if let Some(break_marker) = hard_break_type
4474 && !reflowed.is_empty()
4475 {
4476 let last_idx = reflowed.len() - 1;
4477 if !has_hard_break(&reflowed[last_idx]) {
4478 reflowed[last_idx].push_str(break_marker);
4479 }
4480 }
4481 reflowed_content_lines.extend(reflowed);
4482 }
4483
4484 let mut styled_lines: Vec<String> = Vec::new();
4485 for (idx, line) in reflowed_content_lines.iter().enumerate() {
4486 let force_explicit = idx == 0
4487 || continuation_style == BlockquoteContinuationStyle::Explicit
4488 || should_force_explicit_blockquote_line(line);
4489 if force_explicit {
4490 styled_lines.push(format!("{explicit_prefix}{line}"));
4491 } else {
4492 styled_lines.push(line.clone());
4493 }
4494 }
4495
4496 styled_lines
4497}
4498
4499fn is_blockquote_content_boundary(content: &str) -> bool {
4500 let trimmed = content.trim();
4501 trimmed.is_empty()
4502 || is_block_boundary(trimmed)
4503 || crate::utils::table_utils::TableUtils::is_potential_table_row(content)
4504 || trimmed.starts_with(":::")
4505 || crate::utils::is_template_directive_only(content)
4506 || is_standalone_attr_list(content)
4507 || is_snippet_block_delimiter(content)
4508}
4509
4510fn split_into_segments_strs<'a>(lines: &[&'a str]) -> Vec<Vec<&'a str>> {
4511 let mut segments = Vec::new();
4512 let mut current = Vec::new();
4513
4514 for &line in lines {
4515 current.push(line);
4516 if has_hard_break(line) {
4517 segments.push(current);
4518 current = Vec::new();
4519 }
4520 }
4521
4522 if !current.is_empty() {
4523 segments.push(current);
4524 }
4525
4526 segments
4527}
4528
4529fn reflow_blockquote_paragraph_at_line(
4530 content: &str,
4531 lines: &[&str],
4532 target_idx: usize,
4533 options: &ReflowOptions,
4534) -> Option<ParagraphReflow> {
4535 let mut anchor_idx = target_idx;
4536 let mut target_level = if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[target_idx]) {
4537 parsed.nesting_level
4538 } else {
4539 let mut found = None;
4540 let mut idx = target_idx;
4541 loop {
4542 if lines[idx].trim().is_empty() {
4543 break;
4544 }
4545 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[idx]) {
4546 found = Some((idx, parsed.nesting_level));
4547 break;
4548 }
4549 if idx == 0 {
4550 break;
4551 }
4552 idx -= 1;
4553 }
4554 let (idx, level) = found?;
4555 anchor_idx = idx;
4556 level
4557 };
4558
4559 let mut para_start = anchor_idx;
4561 while para_start > 0 {
4562 let prev_idx = para_start - 1;
4563 let prev_line = lines[prev_idx];
4564
4565 if prev_line.trim().is_empty() {
4566 break;
4567 }
4568
4569 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(prev_line) {
4570 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
4571 break;
4572 }
4573 para_start = prev_idx;
4574 continue;
4575 }
4576
4577 let prev_lazy = prev_line.trim_start();
4578 if is_blockquote_content_boundary(prev_lazy) {
4579 break;
4580 }
4581 para_start = prev_idx;
4582 }
4583
4584 while para_start < lines.len() {
4586 let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[para_start]) else {
4587 para_start += 1;
4588 continue;
4589 };
4590 target_level = parsed.nesting_level;
4591 break;
4592 }
4593
4594 if para_start >= lines.len() || para_start > target_idx {
4595 return None;
4596 }
4597
4598 let mut collected: Vec<(usize, BlockquoteLineData)> = Vec::new();
4601 let mut idx = para_start;
4602 while idx < lines.len() {
4603 if !collected.is_empty() && has_hard_break(&collected[collected.len() - 1].1.content) {
4604 break;
4605 }
4606
4607 let line = lines[idx];
4608 if line.trim().is_empty() {
4609 break;
4610 }
4611
4612 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(line) {
4613 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
4614 break;
4615 }
4616 collected.push((
4617 idx,
4618 BlockquoteLineData::explicit(trim_preserving_hard_break(parsed.content), parsed.prefix.to_string()),
4619 ));
4620 idx += 1;
4621 continue;
4622 }
4623
4624 let lazy_content = line.trim_start();
4625 if is_blockquote_content_boundary(lazy_content) {
4626 break;
4627 }
4628
4629 collected.push((idx, BlockquoteLineData::lazy(trim_preserving_hard_break(lazy_content))));
4630 idx += 1;
4631 }
4632
4633 if collected.is_empty() {
4634 return None;
4635 }
4636
4637 let para_end = collected[collected.len() - 1].0;
4638 if target_idx < para_start || target_idx > para_end {
4639 return None;
4640 }
4641
4642 let line_data: Vec<BlockquoteLineData> = collected.iter().map(|(_, d)| d.clone()).collect();
4643
4644 let fallback_prefix = line_data
4645 .iter()
4646 .find_map(|d| d.prefix.clone())
4647 .unwrap_or_else(|| "> ".to_string());
4648 let explicit_prefix = dominant_blockquote_prefix(&line_data, &fallback_prefix);
4649 let continuation_style = blockquote_continuation_style(&line_data);
4650
4651 let adjusted_line_length = options
4652 .line_length
4653 .saturating_sub(display_len(&explicit_prefix, options.length_mode))
4654 .max(1);
4655
4656 let adjusted_options = ReflowOptions {
4657 line_length: adjusted_line_length,
4658 ..options.clone()
4659 };
4660
4661 let styled_lines = reflow_blockquote_content(&line_data, &explicit_prefix, continuation_style, &adjusted_options);
4662
4663 if styled_lines.is_empty() {
4664 return None;
4665 }
4666
4667 let mut start_byte = 0;
4669 for line in lines.iter().take(para_start) {
4670 start_byte += line.len() + 1;
4671 }
4672
4673 let mut end_byte = start_byte;
4674 for line in lines.iter().take(para_end + 1).skip(para_start) {
4675 end_byte += line.len() + 1;
4676 }
4677
4678 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
4679 if !includes_trailing_newline {
4680 end_byte -= 1;
4681 }
4682
4683 let reflowed_joined = styled_lines.join("\n");
4684 let reflowed_text = if includes_trailing_newline {
4685 if reflowed_joined.ends_with('\n') {
4686 reflowed_joined
4687 } else {
4688 format!("{reflowed_joined}\n")
4689 }
4690 } else if reflowed_joined.ends_with('\n') {
4691 reflowed_joined.trim_end_matches('\n').to_string()
4692 } else {
4693 reflowed_joined
4694 };
4695
4696 Some(ParagraphReflow {
4697 start_byte,
4698 end_byte,
4699 reflowed_text,
4700 })
4701}
4702
4703pub fn reflow_paragraph_at_line(content: &str, line_number: usize, line_length: usize) -> Option<ParagraphReflow> {
4721 reflow_paragraph_at_line_with_mode(content, line_number, line_length, ReflowLengthMode::default())
4722}
4723
4724pub fn reflow_paragraph_at_line_with_mode(
4726 content: &str,
4727 line_number: usize,
4728 line_length: usize,
4729 length_mode: ReflowLengthMode,
4730) -> Option<ParagraphReflow> {
4731 let options = ReflowOptions {
4732 line_length,
4733 length_mode,
4734 ..Default::default()
4735 };
4736 reflow_paragraph_at_line_with_options(content, line_number, &options)
4737}
4738
4739pub fn reflow_paragraph_at_line_with_options(
4750 content: &str,
4751 line_number: usize,
4752 options: &ReflowOptions,
4753) -> Option<ParagraphReflow> {
4754 if line_number == 0 {
4755 return None;
4756 }
4757
4758 let lines: Vec<&str> = content.lines().collect();
4759
4760 if line_number > lines.len() {
4762 return None;
4763 }
4764
4765 let target_idx = line_number - 1; let target_line = lines[target_idx];
4767 let trimmed = target_line.trim();
4768
4769 if let Some(blockquote_reflow) = reflow_blockquote_paragraph_at_line(content, &lines, target_idx, options) {
4772 return Some(blockquote_reflow);
4773 }
4774
4775 if is_paragraph_boundary(trimmed, target_line) {
4777 return None;
4778 }
4779
4780 let mut para_start = target_idx;
4782 while para_start > 0 {
4783 let prev_idx = para_start - 1;
4784 let prev_line = lines[prev_idx];
4785 let prev_trimmed = prev_line.trim();
4786
4787 if is_paragraph_boundary(prev_trimmed, prev_line) {
4789 break;
4790 }
4791
4792 para_start = prev_idx;
4793 }
4794
4795 let mut para_end = target_idx;
4797 while para_end + 1 < lines.len() {
4798 let next_idx = para_end + 1;
4799 let next_line = lines[next_idx];
4800 let next_trimmed = next_line.trim();
4801
4802 if is_paragraph_boundary(next_trimmed, next_line) {
4804 break;
4805 }
4806
4807 para_end = next_idx;
4808 }
4809
4810 let paragraph_lines = &lines[para_start..=para_end];
4812
4813 let mut start_byte = 0;
4815 for line in lines.iter().take(para_start) {
4816 start_byte += line.len() + 1; }
4818
4819 let mut end_byte = start_byte;
4820 for line in paragraph_lines {
4821 end_byte += line.len() + 1; }
4823
4824 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
4827
4828 if !includes_trailing_newline {
4830 end_byte -= 1;
4831 }
4832
4833 let paragraph_text = paragraph_lines.join("\n");
4835
4836 let reflowed = reflow_markdown(¶graph_text, options);
4838
4839 let reflowed_text = if includes_trailing_newline {
4843 if reflowed.ends_with('\n') {
4845 reflowed
4846 } else {
4847 format!("{reflowed}\n")
4848 }
4849 } else {
4850 if reflowed.ends_with('\n') {
4852 reflowed.trim_end_matches('\n').to_string()
4853 } else {
4854 reflowed
4855 }
4856 };
4857
4858 Some(ParagraphReflow {
4859 start_byte,
4860 end_byte,
4861 reflowed_text,
4862 })
4863}
4864fn decompose_code_span(raw: &str) -> Option<(&str, &str)> {
4870 let marker_len = raw.bytes().take_while(|&b| b == b'`').count();
4871 if marker_len == 0 {
4872 return None;
4873 }
4874 let marker = &raw[..marker_len];
4875 if raw.len() < marker_len * 2 {
4876 return None;
4877 }
4878 let content = &raw[marker_len..raw.len() - marker_len];
4879 Some((content, marker))
4880}
4881
4882#[cfg(test)]
4883mod tests {
4884 use super::*;
4885
4886 #[test]
4890 fn preserves_content_accepts_whitespace_changes_and_rejects_the_rest() {
4891 let accepted: &[(&str, &[&str])] = &[
4892 ("one two three", &["one two three"]),
4893 ("one two three", &["one two", "three"]),
4894 ("one two three", &["one", "two", "three"]),
4895 ("one two ", &["one two"]),
4897 ("日本語のテキスト", &["日本語の", "テキスト"]),
4899 ("_First. Second._", &["_First.", "Second._"]),
4901 ];
4902 for (original, reflowed) in accepted {
4903 let reflowed: Vec<String> = reflowed.iter().map(ToString::to_string).collect();
4904 assert!(
4905 preserves_content(original, &reflowed),
4906 "{original:?} -> {reflowed:?} only moves whitespace"
4907 );
4908 }
4909
4910 let rejected: &[(&str, &[&str])] = &[
4911 ("one two three", &["one two"]),
4913 ("one two", &["one two three"]),
4915 ("one two", &["two one"]),
4917 ("_First. Second._", &["_First._", "_Second._"]),
4919 ("alpha and beta", &["alpha", "andbeta"]),
4921 ("mot suivant : autre", &["mot suivant: autre"]),
4923 ];
4924 for (original, reflowed) in rejected {
4925 let reflowed: Vec<String> = reflowed.iter().map(ToString::to_string).collect();
4926 assert!(
4927 !preserves_content(original, &reflowed),
4928 "{original:?} -> {reflowed:?} changes the text, not just its line breaks"
4929 );
4930 }
4931 }
4932
4933 #[test]
4935 fn reflow_line_falls_back_to_the_input_when_content_would_change() {
4936 let options = ReflowOptions {
4937 line_length: 40,
4938 ..Default::default()
4939 };
4940 let line = "one two three four five six seven eight nine ten";
4941
4942 assert!(preserves_content(line, &reflow_line(line, &options)));
4943 assert_eq!(reflow_line(line, &options), reflow_line_unchecked(line, &options));
4944 }
4945
4946 #[test]
4947 fn cascade_split_line_handles_a_very_long_line_without_overflowing() {
4948 let words: Vec<String> = (0..4000).map(|i| format!("word{i}")).collect();
4954 let line = words.join(" ");
4955
4956 let options = ReflowOptions {
4957 line_length: 80,
4958 length_mode: ReflowLengthMode::Chars,
4959 ..Default::default()
4960 };
4961 let out = cascade_split_line(&line, &options);
4962
4963 assert!(out.len() > 1, "a very long line should split into many lines");
4964 for segment in &out {
4965 assert!(
4966 display_len(segment, ReflowLengthMode::Chars) <= 80 || !segment.contains(' '),
4967 "each wrapped line should fit the width (or be a single unbreakable token)"
4968 );
4969 }
4970 let rejoined = out.join(" ");
4972 let original_words: Vec<&str> = line.split(' ').collect();
4973 let result_words: Vec<&str> = rejoined.split_whitespace().collect();
4974 assert_eq!(original_words, result_words, "reflow must preserve all words in order");
4975 }
4976
4977 #[test]
4982 fn test_helper_function_text_ends_with_abbreviation() {
4983 let abbreviations = get_abbreviations(&None);
4985
4986 assert!(text_ends_with_abbreviation("Dr.", &abbreviations));
4988 assert!(text_ends_with_abbreviation("word Dr.", &abbreviations));
4989 assert!(text_ends_with_abbreviation("e.g.", &abbreviations));
4990 assert!(text_ends_with_abbreviation("i.e.", &abbreviations));
4991 assert!(text_ends_with_abbreviation("Mr.", &abbreviations));
4992 assert!(text_ends_with_abbreviation("Mrs.", &abbreviations));
4993 assert!(text_ends_with_abbreviation("Ms.", &abbreviations));
4994 assert!(text_ends_with_abbreviation("Prof.", &abbreviations));
4995
4996 assert!(!text_ends_with_abbreviation("etc.", &abbreviations));
4998 assert!(!text_ends_with_abbreviation("paradigms.", &abbreviations));
4999 assert!(!text_ends_with_abbreviation("programs.", &abbreviations));
5000 assert!(!text_ends_with_abbreviation("items.", &abbreviations));
5001 assert!(!text_ends_with_abbreviation("systems.", &abbreviations));
5002 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)); }
5008
5009 #[test]
5010 fn test_footnote_after_period_splits_sentence() {
5011 let text = "First sentence.[^1] Second sentence.";
5015 let sentences = split_into_sentences(text, None);
5016 assert_eq!(
5017 sentences,
5018 vec!["First sentence.[^1]".to_string(), "Second sentence.".to_string()],
5019 "footnote glued to the period should keep the boundary and stay attached to the first sentence"
5020 );
5021 }
5022
5023 #[test]
5024 fn test_multiple_consecutive_footnotes_after_period_splits_sentence() {
5025 let text = "Notes here.[^1][^2] Second sentence.";
5027 let sentences = split_into_sentences(text, None);
5028 assert_eq!(
5029 sentences,
5030 vec!["Notes here.[^1][^2]".to_string(), "Second sentence.".to_string()]
5031 );
5032 }
5033
5034 #[test]
5035 fn test_footnote_before_period_still_splits_sentence() {
5036 let text = "Annotation here[^1]. Second sentence.";
5040 let sentences = split_into_sentences(text, None);
5041 assert_eq!(
5042 sentences,
5043 vec!["Annotation here[^1].".to_string(), "Second sentence.".to_string()]
5044 );
5045 }
5046
5047 #[test]
5048 fn test_mid_sentence_footnote_does_not_split() {
5049 let text = "The system word[^1] more words. Next sentence.";
5052 let sentences = split_into_sentences(text, None);
5053 assert_eq!(
5054 sentences,
5055 vec![
5056 "The system word[^1] more words.".to_string(),
5057 "Next sentence.".to_string()
5058 ]
5059 );
5060 }
5061
5062 #[test]
5063 fn test_bare_numeric_bracket_after_period_does_not_split() {
5064 let text = "Citation here.[1] Second sentence.";
5067 let sentences = split_into_sentences(text, None);
5068 assert_eq!(
5069 sentences,
5070 vec![text.to_string()],
5071 "a bare numeric bracket must not be treated as a sentence boundary"
5072 );
5073 }
5074
5075 #[test]
5076 fn test_footnote_glued_to_following_word_does_not_split() {
5077 let text = "First sentence.[^1]Continued glued text.";
5080 let sentences = split_into_sentences(text, None);
5081 assert_eq!(sentences, vec![text.to_string()]);
5082 }
5083
5084 #[test]
5085 fn test_footnote_at_end_of_text_is_preserved() {
5086 let text = "Sentence.[^1]";
5089 let sentences = split_into_sentences(text, None);
5090 assert_eq!(sentences, vec![text.to_string()]);
5091 }
5092
5093 #[test]
5094 fn test_abbreviation_before_footnote_does_not_split() {
5095 let text = "See the notes, e.g.[^1] this one.";
5098 let sentences = split_into_sentences(text, None);
5099 assert_eq!(
5100 sentences,
5101 vec![text.to_string()],
5102 "e.g. is an abbreviation, not a sentence boundary"
5103 );
5104 }
5105
5106 #[test]
5107 fn sentence_boundary_never_falls_inside_an_atomic_construct() {
5108 let cases = [
5114 "Prefix [link. Still link](https://example.com) tail. Next sentence.",
5115 "Prefix [target](<https://example.com/First. Second>) tail. Next sentence.",
5116 "Prefix [text](url \"Title. More\") tail. Next sentence.",
5117 "Prefix  tail. Next sentence.",
5118 "Prefix [ref text. More][ref] tail. Next sentence.",
5119 "Prefix [collapsed. More][] tail. Next sentence.",
5120 "Prefix [[Page name. Title]] tail. Next sentence.",
5121 "Prefix $x. Y$ tail. Next sentence.",
5122 "Prefix $$x. Y$$ tail. Next sentence.",
5123 "Prefix <span title=\"A. B\">x</span> tail. Next sentence.",
5124 "Prefix `code. Still code` tail. Next sentence.",
5125 ];
5126 for text in cases {
5127 let sentences = split_into_sentences(text, None);
5128 let (head, tail) = text.rsplit_once(" tail. ").expect("case has a tail");
5129 assert_eq!(
5130 sentences,
5131 vec![format!("{head} tail."), tail.to_string()],
5132 "input {text:?}"
5133 );
5134 }
5135
5136 let text = "Prefix [shortcut. More] tail. Next sentence.";
5140 let whole = vec![
5141 "Prefix [shortcut. More] tail.".to_string(),
5142 "Next sentence.".to_string(),
5143 ];
5144 let defined = HashSet::from(["shortcut. more".to_string()]);
5145 assert_eq!(split_into_sentences(text, Some(&defined)), whole);
5146 assert_eq!(split_into_sentences(text, None), whole);
5147 assert_eq!(
5148 split_into_sentences(text, Some(&HashSet::new())),
5149 vec!["Prefix [shortcut.", "More] tail.", "Next sentence."]
5150 );
5151 }
5152
5153 #[test]
5154 fn a_sentence_may_open_with_a_link_or_image() {
5155 for text in [
5160 "Opening sentence. [First. Second](https://example.com)",
5161 "Opening sentence. ",
5162 "Opening sentence. [[First. Second]]",
5163 "Opening sentence. [[first-note|First. Second]]",
5164 "Opening sentence. [Ref link][ref]",
5165 "Opening sentence. [](url) continues.",
5168 "Opening sentence. [][ref] continues.",
5169 "Opening sentence. [![First image][img]](url) continues.",
5172 "Opening sentence. [![First image][]](url) continues.",
5173 "Opening sentence. [![First image][img]][ref] continues.",
5174 ] {
5175 let (head, tail) = text.split_once(". ").expect("case has a boundary");
5176 assert_eq!(
5177 split_into_sentences(text, None),
5178 vec![format!("{head}."), tail.to_string()],
5179 "input {text:?}"
5180 );
5181 }
5182 let text = "Opening sentence. [![First image]](url) continues.";
5185 let defined = HashSet::from(["first image".to_string()]);
5186 assert_eq!(
5187 split_into_sentences(text, Some(&defined)),
5188 vec!["Opening sentence.", "[![First image]](url) continues."]
5189 );
5190 assert_eq!(
5191 split_into_sentences(text, Some(&HashSet::new())),
5192 vec![text.to_string()],
5193 "an undefined shortcut is bracketed text, and `!` opens no sentence"
5194 );
5195 assert_eq!(
5198 split_into_sentences("Opening sentence. [](url) continues.", None),
5199 vec](url) continues."]
5200 );
5201 let defined = HashSet::from(["smith 2020".to_string()]);
5204 assert_eq!(
5205 split_into_sentences("Claim ends here. [Smith 2020] more text.", Some(&defined)),
5206 vec!["Claim ends here.", "[Smith 2020] more text."]
5207 );
5208 let none_defined = HashSet::new();
5214 for text in [
5215 "Opening sentence. [first link](https://example.com) continues.",
5216 "Opening sentence. [[first note]] continues.",
5217 "Opening sentence. [[First Note|first note]] continues.",
5218 "Opening sentence. [[Page continues.",
5219 "Opening sentence. [[First] stray]] continues.",
5220 "Opening sentence.  continues.",
5221 "Opening sentence. [1] is the citation.",
5222 "Opening sentence. [First](unterminated",
5223 "Opening sentence. [First][unterminated",
5224 "Opening sentence. [First] (aside) continues.",
5225 "Claim ends here. [Smith 2020]",
5226 "Claim ends here. [Smith 2020] more text.",
5227 "See the RFC. [RFC] More text.",
5228 "Claim ends here. [^Note] more text.",
5229 ] {
5230 assert_eq!(
5231 split_into_sentences(text, Some(&none_defined)),
5232 vec![text.to_string()],
5233 "input {text:?}"
5234 );
5235 }
5236 }
5237
5238 #[test]
5239 fn link_opener_is_read_off_the_parse() {
5240 let len = |text: &str, defs: Option<&HashSet<String>>| {
5243 let chars: Vec<char> = text.chars().collect();
5244 let char_offsets = char_byte_offsets(&chars);
5245 let NestedStructure { links, .. } = sentence_structure(text, defs);
5246 let st = SentenceText {
5247 text,
5248 chars: &chars,
5249 char_offsets: &char_offsets,
5250 links: &links,
5251 code_spans: &[],
5252 };
5253 st.link_end_at(0).map_or(0, |end| link_opener_len(&chars, 0, end))
5254 };
5255 let none = HashSet::new();
5256 assert_eq!(len("[text](url)", Some(&none)), 1);
5257 assert_eq!(
5258 len("[text][ref]", Some(&none)),
5259 1,
5260 "a full reference is a link whether or not defined"
5261 );
5262 assert_eq!(len("[text][]", Some(&none)), 1);
5263 assert_eq!(len("", Some(&none)), 2);
5264 assert_eq!(len("[[wiki]]", Some(&none)), 2);
5265 assert_eq!(
5266 len("[[wiki|shown]]", Some(&none)),
5267 7,
5268 "the displayed text starts after the alias pipe"
5269 );
5270 assert_eq!(len("![[img.png|100]]", Some(&none)), 11);
5271 assert_eq!(len("[[wiki|a|b]]", Some(&none)), 7, "the first pipe starts the alias");
5272 assert_eq!(
5273 len("[[wiki|shown]] [[a|b]]", Some(&none)),
5274 7,
5275 "a pipe past the closing `]]` is not this alias"
5276 );
5277 assert_eq!(
5278 len("[a \\] b](url)", Some(&none)),
5279 1,
5280 "an escaped bracket does not close the text"
5281 );
5282 assert_eq!(
5283 len("[](url)", Some(&none)),
5284 1,
5285 "the outer opener is skipped first"
5286 );
5287 for text in [
5291 "[^1]",
5292 "[text](unterminated",
5293 "[text][unterminated",
5294 "[text] (url)",
5295 "[[wiki",
5296 "[[wiki]",
5297 "[[First] stray]]",
5298 "[Smith 2020]",
5299 "[Smith 2020] (see also)",
5300 "[unclosed",
5301 "!bang",
5302 "text",
5303 ] {
5304 assert_eq!(len(text, Some(&none)), 0, "input {text:?}");
5305 }
5306 let smith = HashSet::from(["smith 2020".to_string()]);
5309 assert_eq!(len("[Smith 2020]", Some(&smith)), 1);
5310 assert_eq!(len("[Smith 2020]", None), 1);
5311 }
5312
5313 #[test]
5314 fn sentence_per_line_reflow_breaks_before_a_bracket_only_where_the_check_counts() {
5315 let defined = HashSet::from(["spec".to_string()]);
5323 let options = ReflowOptions {
5324 line_length: 120,
5325 sentence_per_line: true,
5326 defined_references: Some(defined.clone()),
5327 ..Default::default()
5328 };
5329 for (text, expected) in [
5330 (
5331 "Claim ends here. [Smith](https://example.com) more text. Second sentence.",
5332 vec more text.",
5335 "Second sentence.",
5336 ],
5337 ),
5338 (
5339 "Wow! [smith](https://example.com) more text. Second sentence.",
5340 vec more text.", "Second sentence."],
5341 ),
5342 (
5343 "Claim ends here. [smith](https://example.com) more text. Second sentence.",
5344 vec more text.",
5346 "Second sentence.",
5347 ],
5348 ),
5349 (
5350 "Claim ends here. [smith][ref] more text. Second sentence.",
5351 vec!["Claim ends here. [smith][ref] more text.", "Second sentence."],
5352 ),
5353 (
5354 "Claim ends here.  more text. Second sentence.",
5355 vec more text.", "Second sentence."],
5356 ),
5357 (
5358 "Claim ends here.[Link](https://example.com) more text. Second sentence.",
5359 vec more text.",
5361 "Second sentence.",
5362 ],
5363 ),
5364 (
5365 "See the RFC. [RFC] More text. Second sentence.",
5366 vec!["See the RFC. [RFC] More text.", "Second sentence."],
5367 ),
5368 (
5369 "See the spec. [Spec] More text. Second sentence.",
5370 vec!["See the spec.", "[Spec] More text.", "Second sentence."],
5371 ),
5372 (
5373 "See the spec. [spec] more text. Second sentence.",
5374 vec!["See the spec. [spec] more text.", "Second sentence."],
5375 ),
5376 (
5377 "Claim ends here. [[page|Second sentence]] continues. Third sentence.",
5378 vec![
5379 "Claim ends here.",
5380 "[[page|Second sentence]] continues.",
5381 "Third sentence.",
5382 ],
5383 ),
5384 (
5385 "Claim ends here. [[Page|second sentence]] continues. Third sentence.",
5386 vec![
5387 "Claim ends here. [[Page|second sentence]] continues.",
5388 "Third sentence.",
5389 ],
5390 ),
5391 ] {
5392 let lines = reflow_line(text, &options);
5393 assert_eq!(lines, expected, "input {text:?}");
5394 assert_eq!(
5397 split_into_sentences(text, Some(&defined)).len(),
5398 expected.len(),
5399 "check count for {text:?}"
5400 );
5401 for line in &lines {
5402 assert_eq!(
5403 split_into_sentences(line, Some(&defined)).len(),
5404 1,
5405 "line {line:?} of {text:?}"
5406 );
5407 }
5408 }
5409 }
5410
5411 #[test]
5412 fn sentence_per_line_reflow_holds_atomic_constructs_whole() {
5413 let options = ReflowOptions {
5417 line_length: 80,
5418 sentence_per_line: true,
5419 ..Default::default()
5420 };
5421 let lines = reflow_line(
5422 "Prefix `code. Still code` and [link. Still link](https://example.com) tail. Next sentence.",
5423 &options,
5424 );
5425 assert_eq!(
5426 lines,
5427 vec tail.".to_string(),
5429 "Next sentence.".to_string(),
5430 ]
5431 );
5432
5433 let lines = reflow_line(
5434 "Prefix  and [target](<https://example.com/First. Second>) tail. Next sentence.",
5435 &options,
5436 );
5437 assert_eq!(
5438 lines,
5439 vec and [target](<https://example.com/First. Second>) tail.".to_string(),
5441 "Next sentence.".to_string(),
5442 ]
5443 );
5444
5445 let lines = reflow_line("First one. Then [link](url) second. Third one.", &options);
5448 assert_eq!(
5449 lines,
5450 vec second.".to_string(),
5453 "Third one.".to_string(),
5454 ]
5455 );
5456 }
5457
5458 #[test]
5459 fn test_is_unordered_list_marker() {
5460 assert!(is_unordered_list_marker("- item"));
5462 assert!(is_unordered_list_marker("* item"));
5463 assert!(is_unordered_list_marker("+ item"));
5464 assert!(is_unordered_list_marker("-")); assert!(is_unordered_list_marker("*"));
5466 assert!(is_unordered_list_marker("+"));
5467
5468 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")); }
5479
5480 #[test]
5481 fn test_is_block_boundary() {
5482 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"));
5504 assert!(!is_block_boundary("*emphasis*")); assert!(!is_block_boundary("[link](url)")); assert!(!is_block_boundary("some words here"));
5507 }
5508
5509 #[test]
5510 fn test_definition_list_boundary_in_single_line_paragraph() {
5511 let options = ReflowOptions {
5514 line_length: 80,
5515 ..Default::default()
5516 };
5517 let input = "Term\n: Definition of the term";
5518 let result = reflow_markdown(input, &options);
5519 assert!(
5521 result.contains(": Definition"),
5522 "Definition list item should not be merged into previous line. Got: {result:?}"
5523 );
5524 let lines: Vec<&str> = result.lines().collect();
5525 assert_eq!(lines.len(), 2, "Should remain two separate lines. Got: {lines:?}");
5526 assert_eq!(lines[0], "Term");
5527 assert_eq!(lines[1], ": Definition of the term");
5528 }
5529
5530 #[test]
5531 fn test_is_paragraph_boundary() {
5532 assert!(is_paragraph_boundary("# Heading", "# Heading"));
5534 assert!(is_paragraph_boundary("- item", "- item"));
5535 assert!(is_paragraph_boundary(":::", ":::"));
5536 assert!(is_paragraph_boundary(": definition", ": definition"));
5537
5538 assert!(is_paragraph_boundary("code", " code"));
5540 assert!(is_paragraph_boundary("code", "\tcode"));
5541
5542 assert!(is_paragraph_boundary("| a | b |", "| a | b |"));
5544 assert!(is_paragraph_boundary("a | b", "a | b")); assert!(!is_paragraph_boundary("regular text", "regular text"));
5548 assert!(!is_paragraph_boundary("text", " text")); }
5550
5551 #[test]
5552 fn test_div_marker_boundary_in_reflow_paragraph_at_line() {
5553 let content = "Some paragraph text here.\n\n::: {.callout-note}\nThis is a callout.\n:::\n";
5556 let result = reflow_paragraph_at_line(content, 3, 80);
5558 assert!(result.is_none(), "Div marker line should not be reflowed");
5559 }
5560
5561 #[test]
5562 fn starts_block_construct_detects_block_openers() {
5563 for case in ["- item", "-", "* item", "*", "+ item", "+", "-\titem"] {
5565 assert!(starts_block_construct(case), "bullet: {case:?}");
5566 }
5567 for case in ["1. item", "1) item", "01. x", "000000001. x", "1.\titem"] {
5570 assert!(starts_block_construct(case), "ordered: {case:?}");
5571 }
5572 for case in ["> quote", ">quote", ">"] {
5574 assert!(starts_block_construct(case), "blockquote: {case:?}");
5575 }
5576 for case in ["# heading", "###### h6", "#", "##"] {
5578 assert!(starts_block_construct(case), "heading: {case:?}");
5579 }
5580 for case in ["```", "```rust", "````", "~~~", "~~~text"] {
5582 assert!(starts_block_construct(case), "fence: {case:?}");
5583 }
5584 for case in ["---", "--", "===", "=", "***", "___", "_ _ _", "- - -"] {
5586 assert!(starts_block_construct(case), "setext/thematic: {case:?}");
5587 }
5588 for case in [
5591 "[^1]: text",
5592 "[^note]:",
5593 "[ref]: http://example.com",
5594 "[wat]: url follows",
5595 ] {
5596 assert!(starts_block_construct(case), "definition: {case:?}");
5597 }
5598 for case in ["<div>content", "</div>", "<p>text", "<table>", "<pre>code", "<h1>x"] {
5600 assert!(starts_block_construct(case), "html block: {case:?}");
5601 }
5602 }
5603
5604 #[test]
5605 fn starts_block_construct_allows_ordinary_prose() {
5606 for case in [
5607 "",
5608 "word",
5609 "-5 degrees",
5610 "--flag",
5611 "-item",
5612 "#hashtag",
5613 "####### seven hashes is not a heading",
5614 "1.5 million",
5615 "1234567890. ten digits is not a list marker",
5616 "0000000001. ten digits is not a list marker either",
5617 "2. item",
5620 "7. item",
5621 "0. item",
5622 "42) x",
5623 "123456. item",
5624 "1.",
5625 "1)",
5626 "123456.",
5627 "123456)",
5628 "1.item",
5629 "1:30 pm",
5630 "*emphasis*",
5631 "**bold** text",
5632 "__bold__ text",
5633 "_emphasis_ text",
5634 "`code` span",
5635 "`` double backtick span ``",
5636 "~~strikethrough~~",
5637 "=x",
5638 "== ==",
5639 "(parenthetical)",
5640 "[link](url)",
5641 "[text][ref] more",
5642 "[bracketed] aside",
5643 "[a](b) [ref]: first bracket is a link, not a label",
5644 "[esc\\]: not a close] text",
5645 "<span>inline</span>",
5646 "<b>bold</b>",
5647 "<https://example.com> autolink",
5648 "<mailto:a@b.com>",
5649 "<notarealtag>",
5650 ] {
5651 assert!(!starts_block_construct(case), "prose: {case:?}");
5652 }
5653 }
5654
5655 #[test]
5656 fn merge_block_construct_continuations_merges_marker_led_lines() {
5657 let lines = vec![
5658 "First sentence?".to_string(),
5659 "- looks like a list item".to_string(),
5660 "Second sentence.".to_string(),
5661 ];
5662 assert_eq!(
5663 merge_block_construct_continuations(lines),
5664 vec![
5665 "First sentence? - looks like a list item".to_string(),
5666 "Second sentence.".to_string(),
5667 ]
5668 );
5669
5670 let lines = vec!["- real list content".to_string(), "continuation".to_string()];
5673 assert_eq!(
5674 merge_block_construct_continuations(lines.clone()),
5675 lines,
5676 "first line must never be merged"
5677 );
5678
5679 let lines = vec!["prose".to_string(), "1.".to_string(), "[ref]:".to_string()];
5682 assert_eq!(
5683 merge_block_construct_continuations(lines),
5684 vec!["prose 1. [ref]:".to_string()],
5685 "a merge that creates an opener must fold again"
5686 );
5687 }
5688
5689 #[test]
5690 fn wrap_never_starts_a_line_with_a_block_marker() {
5691 let options = ReflowOptions {
5692 line_length: 25,
5693 ..Default::default()
5694 };
5695 let lines = reflow_line(
5698 "Some words here and then - a dash clause that wraps around the limit.",
5699 &options,
5700 );
5701 assert_eq!(
5702 lines,
5703 vec![
5704 "Some words here and",
5705 "then - a dash clause that",
5706 "wraps around the limit."
5707 ]
5708 );
5709
5710 for input in [
5712 "Alpha beta gamma delta epsilon - dash clause here to wrap",
5713 "Alpha beta gamma delta epsilon > quote lookalike here to wrap",
5714 "Alpha beta gamma delta epsilon # heading lookalike here to wrap",
5715 "Alpha beta gamma delta epsilon 1. ordered lookalike here to wrap",
5716 "Alpha beta gamma delta epsilon * star clause here to wrap",
5717 "Alpha beta gamma delta epsilon + plus clause here to wrap",
5718 ] {
5719 for width in 10..40 {
5720 let options = ReflowOptions {
5721 line_length: width,
5722 ..Default::default()
5723 };
5724 for line in reflow_line(input, &options) {
5725 assert!(
5726 !starts_block_construct(&line),
5727 "width {width}: wrapped line opens a block construct: {line:?} (input {input:?})"
5728 );
5729 }
5730 }
5731 }
5732 }
5733
5734 #[test]
5735 fn sentence_per_line_keeps_block_markers_mid_line() {
5736 let options = ReflowOptions {
5737 line_length: 80,
5738 sentence_per_line: true,
5739 ..Default::default()
5740 };
5741 let lines = reflow_line(
5744 "Google Calendar (Can't we get rid of this dependency? - I don't really see the need)",
5745 &options,
5746 );
5747 assert_eq!(
5748 lines,
5749 vec!["Google Calendar (Can't we get rid of this dependency? - I don't really see the need)".to_string()]
5750 );
5751
5752 let lines = reflow_line("See section 4? # is the marker we use. Fine.", &options);
5754 assert_eq!(lines, vec!["See section 4? # is the marker we use.", "Fine."]);
5755
5756 let lines = reflow_line("Is this a problem? > I quote someone here.", &options);
5757 assert_eq!(lines, vec!["Is this a problem? > I quote someone here."]);
5758
5759 let lines = reflow_line("Another case! 1. Not a list. More text follows here.", &options);
5760 for line in &lines {
5761 assert!(
5762 !starts_block_construct(line),
5763 "sentence-per-line output opens a block construct: {line:?}"
5764 );
5765 }
5766 }
5767
5768 fn strict_sentence_lines(input: &str, require_sentence_capital: bool) -> Vec<String> {
5770 let options = ReflowOptions {
5771 line_length: 80,
5772 sentence_per_line: true,
5773 require_sentence_capital,
5774 ..Default::default()
5775 };
5776 reflow_line(input, &options)
5777 }
5778
5779 #[test]
5780 fn strict_mode_lets_a_sentence_open_with_a_number() {
5781 for (input, expected) in [
5785 (
5786 "The number of items was 5. 2 of them failed.",
5787 vec!["The number of items was 5.", "2 of them failed."],
5788 ),
5789 (
5790 "Sometimes we have 2. 3 might be here.",
5791 vec!["Sometimes we have 2.", "3 might be here."],
5792 ),
5793 (
5794 "The number of items was 5. 2nd sentence.",
5795 vec!["The number of items was 5.", "2nd sentence."],
5796 ),
5797 (
5798 "Released in 2020. 3 of them failed.",
5799 vec!["Released in 2020.", "3 of them failed."],
5800 ),
5801 (
5802 "First sentence. 2nd sentence.",
5803 vec!["First sentence.", "2nd sentence."],
5804 ),
5805 (
5806 "We met at 6:00 sharp. 6:00 is early.",
5807 vec!["We met at 6:00 sharp.", "6:00 is early."],
5808 ),
5809 ("Pi is 3.14 roughly. Next.", vec!["Pi is 3.14 roughly.", "Next."]),
5810 (
5813 "A \"Is this a test?\" 2020 was memorable.",
5814 vec!["A \"Is this a test?\"", "2020 was memorable."],
5815 ),
5816 ] {
5817 assert_eq!(strict_sentence_lines(input, true), expected, "input {input:?}");
5818 }
5819
5820 for input in [
5823 "The count was 5. and that was all.",
5824 "See fig. 3 for details.",
5825 "See no. 5 in the list.",
5826 "See ch. 12 and vol. 3 for more.",
5827 "A \"Is this a test?\" guide to it.",
5828 ] {
5829 assert_eq!(
5830 strict_sentence_lines(input, true),
5831 vec![input.to_string()],
5832 "input {input:?}"
5833 );
5834 }
5835 }
5836
5837 #[test]
5838 fn sentence_never_opens_with_an_ordered_list_marker() {
5839 for (input, require_capital, expected) in [
5846 (
5847 "Steps: 1. Do this. 2. Do that.",
5848 true,
5849 vec!["Steps: 1.", "Do this. 2.", "Do that."],
5850 ),
5851 (
5852 "First sentence. 1. Do that.",
5853 true,
5854 vec!["First sentence. 1.", "Do that."],
5855 ),
5856 ("Do this! 2. Do that.", true, vec!["Do this! 2.", "Do that."]),
5857 ("Do this. 12) Do that.", true, vec!["Do this. 12) Do that."]),
5858 ("Do this. 2. do that.", true, vec!["Do this. 2. do that."]),
5859 ("Do this. 2. do that.", false, vec!["Do this. 2.", "do that."]),
5860 (
5861 "Twelve. 1234567890. next one here.",
5862 true,
5863 vec!["Twelve. 1234567890. next one here."],
5864 ),
5865 ("Do this. 2 more times.", true, vec!["Do this.", "2 more times."]),
5868 ("How many? 2.", true, vec!["How many?", "2."]),
5869 ("第一句。2. Do that.", true, vec!["第一句。2.", "Do that."]),
5872 ("第一句。 2) 第二句。", true, vec!["第一句。 2) 第二句。"]),
5873 ("第一句。2 more.", true, vec!["第一句。", "2 more."]),
5874 ("第一句。第二句。", true, vec!["第一句。", "第二句。"]),
5875 ] {
5876 let lines = strict_sentence_lines(input, require_capital);
5877 assert_eq!(lines, expected, "input {input:?}, require capital {require_capital}");
5878 for line in &lines {
5879 let chars: Vec<char> = line.chars().collect();
5880 assert!(
5881 !opens_ordered_list_marker(&chars),
5882 "line opens with an ordered-list marker: {line:?} (input {input:?})"
5883 );
5884 }
5885 }
5886 }
5887
5888 #[test]
5889 fn opens_ordered_list_marker_matches_the_marker_shape() {
5890 let chars = |s: &str| s.chars().collect::<Vec<char>>();
5891 for text in ["2. x", "1) x", "12. x", "1.\tx", "1234567890. x", "0. x"] {
5892 assert!(opens_ordered_list_marker(&chars(text)), "{text:?} is a marker");
5893 }
5894 for text in ["2.x", "2.", "2)", "2 x", "x. y", "", " 2. x", "2.5 x", "-2. x"] {
5895 assert!(!opens_ordered_list_marker(&chars(text)), "{text:?} is not a marker");
5896 }
5897 }
5898
5899 #[test]
5900 fn inline_math_directly_after_display_math_stays_atomic() {
5901 let options = ReflowOptions {
5909 line_length: 8,
5910 ..Default::default()
5911 };
5912 let lines = reflow_line("$$a$$$bb cc dd$ x", &options);
5913 assert_eq!(lines, vec!["$$a$$$bb cc dd$".to_string(), "x".to_string()]);
5914 }
5915
5916 #[test]
5917 fn test_code_span_parsing() {
5918 let elements = parse_markdown_elements_inner("`code`", false, false, None);
5920 assert_eq!(elements.len(), 1);
5921 assert!(matches!(&elements[0], Element::Code { content, marker } if content == "code" && marker == "`"));
5922
5923 let elements = parse_markdown_elements_inner("``code``", false, false, None);
5925 assert_eq!(elements.len(), 1);
5926 assert!(matches!(&elements[0], Element::Code { content, marker } if content == "code" && marker == "``"));
5927
5928 let elements = parse_markdown_elements_inner("``code`inside``", false, false, None);
5930 assert_eq!(elements.len(), 1);
5931 assert!(
5932 matches!(&elements[0], Element::Code { content, marker } if content == "code`inside" && marker == "``")
5933 );
5934
5935 let elements = parse_markdown_elements_inner("`` code ``", false, false, None);
5937 assert_eq!(elements.len(), 1);
5938 assert!(matches!(&elements[0], Element::Code { content, marker } if content == " code " && marker == "``"));
5939
5940 let elements = parse_markdown_elements_inner("`unclosed", false, false, None);
5942 assert_eq!(elements.len(), 1);
5943 assert!(matches!(&elements[0], Element::Text(s) if s == "`unclosed"));
5944
5945 let elements = parse_markdown_elements_inner("`unclosed [link](url)", false, false, None);
5947 assert_eq!(elements.len(), 2);
5949 assert!(matches!(&elements[0], Element::Text(s) if s == "`unclosed "));
5950 assert!(matches!(&elements[1], Element::Link(s) if s == "[link](url)"));
5951 }
5952
5953 #[test]
5954 fn test_reflow_performance_long_input() {
5955 let mut text = String::new();
5958 for i in 1..400 {
5959 let backticks = "`".repeat(i);
5960 text.push_str(&backticks);
5961 text.push(' ');
5962 }
5963
5964 let start = std::time::Instant::now();
5965 let elements = parse_markdown_elements_inner(&text, false, false, None);
5966 let duration = start.elapsed();
5967
5968 assert!(duration.as_millis() < 100, "Parsing took too long: {duration:?}");
5970 assert!(!elements.is_empty());
5971 }
5972
5973 #[test]
5974 fn test_reflow_performance_display_math_heavy() {
5975 let text = "$$a$$".repeat(4000);
5980
5981 let start = std::time::Instant::now();
5982 let elements = parse_markdown_elements_inner(&text, false, false, None);
5983 let duration = start.elapsed();
5984
5985 assert!(duration.as_millis() < 100, "Parsing took too long: {duration:?}");
5986 assert_eq!(elements.len(), 4000);
5987 }
5988
5989 #[test]
5990 fn inline_math_len_at_start_matches_regex_at_slice_start() {
5991 let alphabet = ['$', 'a', ' '];
5996 let mut inputs: Vec<String> = vec![String::new()];
5997 let mut frontier: Vec<String> = vec![String::new()];
5998 for _ in 0..6 {
5999 let mut longer = Vec::new();
6000 for prefix in &frontier {
6001 for ch in alphabet {
6002 let mut s = prefix.clone();
6003 s.push(ch);
6004 longer.push(s);
6005 }
6006 }
6007 inputs.extend(longer.iter().cloned());
6008 frontier = longer;
6009 }
6010 inputs.push("$αβ$x".to_string());
6012 inputs.push("$α$$".to_string());
6013
6014 for s in &inputs {
6015 let expected = INLINE_MATH_REGEX
6016 .find(s)
6017 .ok()
6018 .flatten()
6019 .filter(|m| m.start() == 0)
6020 .map(|m| m.end());
6021 assert_eq!(inline_math_len_at_start(s), expected, "input: {s:?}");
6022 }
6023 }
6024
6025 #[test]
6026 fn inline_math_probe_after_dollar_matches_uncached_parse() {
6027 let cases = [
6033 ("$$a$$$b c$ x", r#"[DisplayMath("a"), InlineMath("b c"), Text(" x")]"#),
6034 (
6035 "$$a$$$b$ $$a$$$b$",
6036 r#"[DisplayMath("a"), InlineMath("b"), Text(" "), DisplayMath("a"), InlineMath("b")]"#,
6037 ),
6038 (
6040 "$$a$$$ x $y z$",
6041 r#"[DisplayMath("a"), InlineMath(" x "), Text("y z$")]"#,
6042 ),
6043 ("$$a$$$$ x", r#"[DisplayMath("a"), Text("$$ x")]"#),
6045 ("$$a$$$$b$$ x", r#"[DisplayMath("a"), DisplayMath("b"), Text(" x")]"#),
6046 (
6048 "$a$$b$$c$$d$ tail",
6049 r#"[Text("$a"), DisplayMath("b"), Text("c$$d$ tail")]"#,
6050 ),
6051 ];
6052 for (input, expected) in cases {
6053 let elements = parse_markdown_elements_inner(input, false, false, None);
6054 assert_eq!(format!("{elements:?}"), expected, "input: {input:?}");
6055 }
6056 }
6057
6058 #[test]
6059 fn test_atomic_spans() {
6060 let text_emphasis = "hello **word1 word2**";
6062
6063 let options_disabled = ReflowOptions {
6064 line_length: 18,
6065 atomic_spans: true,
6066 ..Default::default()
6067 };
6068 let lines_disabled = reflow_line(text_emphasis, &options_disabled);
6069 assert_eq!(lines_disabled, vec!["hello", "**word1 word2**"]);
6070
6071 let options_enabled = ReflowOptions {
6072 line_length: 18,
6073 atomic_spans: false,
6074 ..Default::default()
6075 };
6076 let lines_enabled = reflow_line(text_emphasis, &options_enabled);
6077 assert_eq!(lines_enabled, vec!["hello **word1", "word2**"]);
6078
6079 let text_code = "hello `word1 word2`";
6081
6082 let lines_code_disabled = reflow_line(text_code, &options_disabled);
6083 assert_eq!(lines_code_disabled, vec!["hello", "`word1 word2`"]);
6084
6085 let lines_code_enabled = reflow_line(text_code, &options_enabled);
6086 assert_eq!(lines_code_enabled, vec!["hello `word1", "word2`"]);
6087
6088 let text_code_padding = "hello `` `word1` `word2` ``";
6090 let lines_padding_enabled = reflow_line(text_code_padding, &options_enabled);
6091 assert_eq!(lines_padding_enabled, vec![r#"hello `` `word1`"#, r#"`word2` ``"#]);
6092
6093 let text_attached = "**one two**,"; let options_11 = ReflowOptions {
6098 line_length: 11,
6099 atomic_spans: true,
6100 ..Default::default()
6101 };
6102 assert_eq!(reflow_line(text_attached, &options_11), vec!["**one two**,"]);
6103
6104 let options_10 = ReflowOptions {
6106 line_length: 10,
6107 atomic_spans: true,
6108 ..Default::default()
6109 };
6110 assert_eq!(reflow_line(text_attached, &options_10), vec!["**one", "two**,"]);
6111 }
6112
6113 #[test]
6114 fn test_emphasis_containing_markers_is_not_split() {
6115 let options = ReflowOptions {
6116 line_length: 5,
6117 atomic_spans: false,
6118 ..Default::default()
6119 };
6120 let lines = reflow_line(r#"*foo \*bar*"#, &options);
6122 assert_eq!(lines, vec![r#"*foo \*bar*"#.to_string()]);
6123 }
6124
6125 fn semantic_shape(markdown: &str) -> String {
6130 let mut options = Options::empty();
6131 options.insert(Options::ENABLE_STRIKETHROUGH);
6132 let mut out = String::new();
6133 let push_prose = |out: &mut String, text: &str| {
6134 for c in text.chars() {
6135 if c.is_whitespace() {
6136 if !out.ends_with(char::is_whitespace) {
6137 out.push(' ');
6138 }
6139 } else {
6140 out.push(c);
6141 }
6142 }
6143 };
6144 for event in Parser::new_ext(markdown, options) {
6145 match event {
6146 Event::Text(text) => push_prose(&mut out, &text),
6147 Event::SoftBreak | Event::HardBreak => push_prose(&mut out, " "),
6148 Event::Code(code) => out.push_str(&format!("<code>{code}</code>")),
6150 Event::Start(tag) => out.push_str(&format!("<{tag:?}>")),
6151 Event::End(tag) => out.push_str(&format!("</{tag:?}>")),
6152 other => out.push_str(&format!("{other:?}")),
6153 }
6154 }
6155 out.trim().to_string()
6156 }
6157
6158 #[test]
6159 fn test_wrapping_a_span_never_changes_what_it_parses_to() {
6160 let corpus = [
6164 "_This is a very, very, very, very, very long line with some `code` inside._",
6165 "_alpha beta gamma delta epsilon `a b` zeta eta theta iota kappa lambda_",
6166 "**strong text with `code` and more words than fit on one single line**",
6167 "~~struck text with `code` and more words than fit on one single line~~",
6168 "_emphasis with **nested strong that is quite long** and trailing words_",
6169 "***A doubly nested bold italic span with more words than fit on a line***",
6172 "___Another doubly nested span with more words than fit on a single line___",
6173 "**_mixed strong then emphasis with more words than fit on a single line_**",
6174 "*__mixed emphasis then strong with more words than fit on a single line__*",
6175 "**~~strong strikethrough with more words than fit on a single line here~~**",
6176 "**a * b with a stray marker and plenty more words to pass the budget**",
6179 "_foo `a` bar `b` baz qux quux corge grault garply waldo fred plugh xyzzy_",
6180 "text before _a long emphasis with `code` inside of it here_ and after",
6181 "(_a parenthesized long emphasis with `code` inside of it right here_)",
6182 r#"*foo \*bar baz qux quux corge grault garply waldo fred plugh xyzzy*"#,
6183 "_tab\tseparated `a\tb` words spread out over quite a long emphasis span_",
6184 "_This has [text](<a b c d e f g h i j k>) and trailing words to wrap._",
6187 "_This has [`code`](<a b c d e f g h i j k>) and trailing words to wrap._",
6188 r#"_See [x](https://example.com "a long link title here") and `code` too._"#,
6189 "_A [link with a long label](https://example.com/path) and `code` here._",
6190 "_An image  plus `code` and more text_",
6191 ];
6192 for text in corpus {
6193 let expected = semantic_shape(text);
6194 for line_length in [20, 30, 40, 80] {
6195 for atomic_spans in [true, false] {
6196 let options = ReflowOptions {
6197 line_length,
6198 atomic_spans,
6199 ..Default::default()
6200 };
6201 let wrapped = reflow_line(text, &options).join("\n");
6202 assert_eq!(
6203 semantic_shape(&wrapped),
6204 expected,
6205 "reflow changed the parse of {text:?} at line_length={line_length} \
6206 atomic_spans={atomic_spans}\n wrapped: {wrapped:?}"
6207 );
6208 }
6209 }
6210 }
6211 }
6212
6213 #[test]
6214 fn test_wrapping_a_span_keeps_whole_the_constructs_pulldown_cannot_see() {
6215 let cases = [
6219 (
6220 "_alpha beta gamma [[a wiki link]] delta epsilon zeta eta_",
6221 "[[a wiki link]]",
6222 ),
6223 (
6224 "_alpha beta gamma {{< foo bar >}} delta epsilon zeta eta_",
6225 "{{< foo bar >}}",
6226 ),
6227 ("_alpha beta gamma $a + b$ delta epsilon zeta eta theta_", "$a + b$"),
6228 ("_alpha beta gamma $$a + b$$ delta epsilon zeta eta theta_", "$$a + b$$"),
6229 ];
6230 for (text, construct) in cases {
6231 for line_length in [12, 20, 30] {
6232 for atomic_spans in [true, false] {
6233 let options = ReflowOptions {
6234 line_length,
6235 atomic_spans,
6236 ..Default::default()
6237 };
6238 let wrapped = reflow_line(text, &options).join("\n");
6239 assert!(
6240 wrapped.contains(construct),
6241 "{construct} was broken at line_length={line_length} \
6242 atomic_spans={atomic_spans}: {wrapped:?}"
6243 );
6244 }
6245 }
6246 }
6247 }
6248
6249 #[test]
6250 fn test_overlong_emphasis_with_nested_code_span_wraps() {
6251 let options = ReflowOptions {
6255 line_length: 80,
6256 atomic_spans: true,
6257 ..Default::default()
6258 };
6259 let text = "_This is a very, very, very, very, very, very, very long line that exceeds 80 characters with some `code` inside._";
6260 let lines = reflow_line(text, &options);
6261 assert_eq!(
6262 lines,
6263 vec![
6264 "_This is a very, very, very, very, very, very, very long line that exceeds 80",
6265 "characters with some `code` inside._",
6266 ]
6267 );
6268 }
6269
6270 #[test]
6271 fn test_overlong_emphasis_with_nested_strong_wraps() {
6272 let options = ReflowOptions {
6274 line_length: 80,
6275 atomic_spans: true,
6276 ..Default::default()
6277 };
6278 let text = "_This is a very, very, very, very, very, very, very long line that exceeds 80 characters with some **bold** inside._";
6279 let lines = reflow_line(text, &options);
6280 assert_eq!(
6281 lines,
6282 vec![
6283 "_This is a very, very, very, very, very, very, very long line that exceeds 80",
6284 "characters with some **bold** inside._",
6285 ]
6286 );
6287 }
6288
6289 #[test]
6290 fn test_overlong_doubly_nested_span_wraps() {
6291 let options = ReflowOptions {
6296 line_length: 80,
6297 atomic_spans: true,
6298 ..Default::default()
6299 };
6300 let body = "This is a very, very, very, very, very, very, very, very, very, very long line that is emphasised.";
6301 for (open, close) in [
6302 ("***", "***"),
6303 ("___", "___"),
6304 ("**_", "_**"),
6305 ("*__", "__*"),
6306 ("**~~", "~~**"),
6307 ] {
6308 let text = format!("{open}{body}{close}");
6309 assert!(text.len() > options.line_length, "case must start over budget");
6310 let lines = reflow_line(&text, &options);
6311 assert!(
6312 lines.len() > 1,
6313 "{open}...{close} should wrap but stayed on one line: {lines:?}"
6314 );
6315 assert!(
6316 lines.iter().all(|line| line.len() <= options.line_length),
6317 "{open}...{close} left a line over the budget: {lines:?}"
6318 );
6319 assert_eq!(
6320 lines.join(" "),
6321 text,
6322 "{open}...{close} wrapping must only replace a space with a newline"
6323 );
6324 }
6325 }
6326
6327 #[test]
6328 fn test_overlong_span_with_stray_marker_stays_whole() {
6329 let options = ReflowOptions {
6333 line_length: 40,
6334 atomic_spans: true,
6335 ..Default::default()
6336 };
6337 let text = "**alpha * beta gamma delta epsilon zeta eta theta iota kappa**";
6338 let lines = reflow_line(text, &options);
6339 assert_eq!(lines, vec![text], "stray marker must keep the span whole");
6340 }
6341
6342 #[test]
6343 fn test_overlong_span_never_breaks_inside_a_nested_reference_link() {
6344 let options = ReflowOptions {
6350 line_length: 30,
6351 atomic_spans: true,
6352 defined_references: Some(HashSet::from([
6353 "ref".to_string(),
6354 "one two three four five six seven".to_string(),
6356 ])),
6357 ..Default::default()
6358 };
6359 for (text, link) in [
6360 (
6361 "_**alpha [one two three four five six seven][ref] beta gamma delta**_",
6362 "[one two three four five six seven][ref]",
6363 ),
6364 (
6365 "**alpha [one two three four five six seven][ref] beta gamma delta**",
6366 "[one two three four five six seven][ref]",
6367 ),
6368 (
6369 "_**alpha ![one two three four five six seven][ref] beta gamma delta**_",
6370 "![one two three four five six seven][ref]",
6371 ),
6372 (
6373 "_**alpha [one two three four five six seven][] beta gamma delta**_",
6374 "[one two three four five six seven][]",
6375 ),
6376 (
6377 "_**alpha [one two three four five six seven] beta gamma delta**_",
6378 "[one two three four five six seven]",
6379 ),
6380 ] {
6381 let lines = reflow_line(text, &options);
6382 assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
6383 assert!(
6384 lines.iter().any(|line| line.contains(link)),
6385 "{link} must stay on one line: {lines:?}"
6386 );
6387 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
6388 }
6389 }
6390
6391 #[test]
6392 fn test_overlong_span_breaks_inside_an_undefined_shortcut_reference() {
6393 let options = ReflowOptions {
6397 line_length: 30,
6398 atomic_spans: true,
6399 defined_references: Some(HashSet::new()),
6400 ..Default::default()
6401 };
6402 let text = "_**alpha [one two three four five six seven] beta gamma delta**_";
6403 let lines = reflow_line(text, &options);
6404 assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
6405 assert!(
6406 !lines
6407 .iter()
6408 .any(|line| line.contains("[one two three four five six seven]")),
6409 "an undefined shortcut is prose and should break: {lines:?}"
6410 );
6411 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
6412 }
6413
6414 #[test]
6415 fn test_overlong_span_never_breaks_inside_a_nested_attr_list() {
6416 let attr = "{.highlight key=\"a b c\"}";
6420 let text = format!("_**alpha beta gamma delta epsilon zeta{attr} eta theta iota kappa**_");
6421 let options = ReflowOptions {
6422 line_length: 20,
6423 atomic_spans: true,
6424 attr_lists: true,
6425 ..Default::default()
6426 };
6427 let lines = reflow_line(&text, &options);
6428 assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
6429 assert!(
6430 lines.iter().any(|line| line.contains(attr)),
6431 "attr list must stay on one line: {lines:?}"
6432 );
6433 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
6434
6435 let plain = ReflowOptions {
6438 attr_lists: false,
6439 ..options
6440 };
6441 let lines = reflow_line(&text, &plain);
6442 assert!(
6443 !lines.iter().any(|line| line.contains(attr)),
6444 "without the flavor the braces are prose and should break: {lines:?}"
6445 );
6446 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
6447 }
6448
6449 #[test]
6450 fn test_overlong_emphasis_never_breaks_inside_nested_code_span() {
6451 let options = ReflowOptions {
6455 line_length: 30,
6456 atomic_spans: true,
6457 ..Default::default()
6458 };
6459 let text = "_alpha beta gamma delta epsilon `a b` zeta eta theta iota kappa_";
6460 let lines = reflow_line(text, &options);
6461 assert!(lines.len() > 1, "over-long emphasis should wrap: {lines:?}");
6462 assert!(
6463 lines.iter().any(|line| line.contains("`a b`")),
6464 "nested code span must stay whole with its interior spaces: {lines:?}"
6465 );
6466 for line in &lines {
6467 assert_eq!(
6468 line.matches('`').count() % 2,
6469 0,
6470 "no line may contain half a code span: {line:?}"
6471 );
6472 }
6473 }
6474
6475 #[test]
6476 fn test_definition_list_marker_does_not_start_line() {
6477 let options = ReflowOptions {
6478 line_length: 20,
6479 ..Default::default()
6480 };
6481 let lines = reflow_line("This is a term and : definition here.", &options);
6483 for line in &lines {
6484 assert!(
6485 !line.trim_start().starts_with(": "),
6486 "Wrapped line should not start with definition marker: {line}"
6487 );
6488 }
6489 }
6490
6491 #[test]
6492 fn test_div_marker_does_not_start_line() {
6493 let options = ReflowOptions {
6494 line_length: 20,
6495 ..Default::default()
6496 };
6497 let lines = reflow_line("This is some text with ::: class marker.", &options);
6499 for line in &lines {
6500 assert!(
6501 !line.trim_start().starts_with(":::"),
6502 "Wrapped line should not start with div marker: {line}"
6503 );
6504 }
6505 }
6506}