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(
876 text: &str,
877 defined_references: Option<&HashSet<String>>,
878 require_sentence_capital: bool,
879) -> Vec<String> {
880 let abbreviations = get_abbreviations(&None);
881 split_into_sentences_with_set(text, &abbreviations, require_sentence_capital, None, defined_references)
882}
883
884fn split_into_sentences_with_set(
894 text: &str,
895 abbreviations: &HashSet<String>,
896 require_sentence_capital: bool,
897 appended_span_start: Option<usize>,
898 defined_references: Option<&HashSet<String>>,
899) -> Vec<String> {
900 let char_vec: Vec<char> = text.chars().collect();
901 let char_offsets = char_byte_offsets(&char_vec);
902
903 let NestedStructure {
906 atomic,
907 links,
908 code_spans,
909 ..
910 } = sentence_structure(text, defined_references);
911 let mut atomic_it = atomic.iter().peekable();
912 let st = SentenceText {
913 text,
914 chars: &char_vec,
915 char_offsets: &char_offsets,
916 links: &links,
917 code_spans: &code_spans,
918 };
919
920 let mut sentences = Vec::new();
921 let mut current_sentence = String::new();
922 let mut pos = 0;
923
924 while pos < char_vec.len() {
925 let c = char_vec[pos];
926 current_sentence.push(c);
927
928 let byte_idx = char_offsets[pos];
929
930 while let Some(&&(_, end)) = atomic_it.peek() {
932 if end <= byte_idx {
933 atomic_it.next();
934 } else {
935 break;
936 }
937 }
938
939 let in_atomic = atomic_it
941 .peek()
942 .is_some_and(|&&(start, end)| byte_idx >= start && byte_idx < end);
943
944 if !in_atomic && is_sentence_boundary(&st, pos, abbreviations, require_sentence_capital) {
945 if let Some(end_pos) = footnote_refs_end(&char_vec, pos + 1) {
947 while pos + 1 < end_pos {
948 pos += 1;
949 current_sentence.push(char_vec[pos]);
950 }
951 }
952
953 while pos + 1 < char_vec.len() {
955 let next = char_vec[pos + 1];
956 if matches!(next, '*' | '_' | '~') && Some(char_offsets[pos + 1]) == appended_span_start {
957 break;
958 }
959 if next == '*' || next == '_' || next == '~' || is_closing_quote(next) {
960 pos += 1;
961 current_sentence.push(char_vec[pos]);
962 } else {
963 break;
964 }
965 }
966
967 if pos + 1 < char_vec.len() && char_vec[pos + 1] == ' ' {
969 pos += 1; }
971
972 sentences.push(current_sentence.trim().to_string());
973 current_sentence.clear();
974 }
975
976 pos += 1;
977 }
978
979 if !current_sentence.trim().is_empty() {
981 sentences.push(current_sentence.trim().to_string());
982 }
983 sentences
984}
985
986fn sentence_structure(text: &str, defined_references: Option<&HashSet<String>>) -> NestedStructure {
1002 if !text.contains(['`', '[', '<', '$']) {
1005 return NestedStructure {
1006 atomic: Vec::new(),
1007 markers: Vec::new(),
1008 links: Vec::new(),
1009 code_spans: Vec::new(),
1010 };
1011 }
1012 nested_structure(text, defined_references, false)
1013}
1014
1015fn is_horizontal_rule(line: &str) -> bool {
1017 if line.len() < 3 {
1018 return false;
1019 }
1020
1021 let mut chars = line.chars();
1024 let Some(first_char) = chars.next() else {
1025 return false;
1026 };
1027 if first_char != '-' && first_char != '_' && first_char != '*' {
1028 return false;
1029 }
1030
1031 let mut non_space_count = 1usize; for c in chars {
1033 if c == ' ' {
1034 continue;
1035 }
1036 if c != first_char {
1037 return false;
1038 }
1039 non_space_count += 1;
1040 }
1041 non_space_count >= 3
1042}
1043
1044fn is_numbered_list_item(line: &str) -> bool {
1046 let mut chars = line.chars();
1047
1048 if !chars.next().is_some_and(char::is_numeric) {
1050 return false;
1051 }
1052
1053 while let Some(c) = chars.next() {
1055 if c == '.' {
1056 return chars.next() == Some(' ');
1059 }
1060 if !c.is_numeric() {
1061 return false;
1062 }
1063 }
1064
1065 false
1066}
1067
1068fn is_unordered_list_marker(s: &str) -> bool {
1070 matches!(s.as_bytes().first(), Some(b'-' | b'*' | b'+'))
1071 && !is_horizontal_rule(s)
1072 && (s.len() == 1 || s.as_bytes().get(1) == Some(&b' '))
1073}
1074
1075fn is_block_boundary_core(trimmed: &str) -> bool {
1078 trimmed.is_empty()
1079 || trimmed.starts_with('#')
1080 || trimmed.starts_with("```")
1081 || trimmed.starts_with("~~~")
1082 || trimmed.starts_with('>')
1083 || (trimmed.starts_with('[') && trimmed.contains("]:"))
1084 || is_horizontal_rule(trimmed)
1085 || is_unordered_list_marker(trimmed)
1086 || is_numbered_list_item(trimmed)
1087 || is_definition_list_item(trimmed)
1088 || trimmed.starts_with(":::")
1089}
1090
1091fn is_block_boundary(trimmed: &str) -> bool {
1094 is_block_boundary_core(trimmed) || trimmed.starts_with('|')
1095}
1096
1097fn is_paragraph_boundary(trimmed: &str, line: &str) -> bool {
1101 is_block_boundary_core(trimmed)
1102 || calculate_indentation_width_default(line) >= 4
1103 || crate::utils::table_utils::TableUtils::is_potential_table_row(line)
1104}
1105
1106fn has_hard_break(line: &str) -> bool {
1112 let line = line.strip_suffix('\r').unwrap_or(line);
1113 line.ends_with(" ") || line.ends_with('\\')
1114}
1115
1116fn ends_with_sentence_punct(text: &str) -> bool {
1118 text.ends_with('.') || text.ends_with('!') || text.ends_with('?')
1119}
1120
1121fn trim_preserving_hard_break(s: &str) -> String {
1127 let s = s.strip_suffix('\r').unwrap_or(s);
1129
1130 if s.ends_with('\\') {
1132 return s.to_string();
1134 }
1135
1136 if s.ends_with(" ") {
1138 let content_end = s.trim_end().len();
1140 if content_end == 0 {
1141 return String::new();
1143 }
1144 format!("{} ", &s[..content_end])
1146 } else {
1147 s.trim_end().to_string()
1149 }
1150}
1151
1152fn parse_elements(text: &str, options: &ReflowOptions) -> Vec<Element> {
1154 parse_markdown_elements_inner(
1155 text,
1156 options.attr_lists,
1157 options.myst_roles,
1158 options.defined_references.as_ref(),
1159 )
1160}
1161
1162pub fn reflow_line(line: &str, options: &ReflowOptions) -> Vec<String> {
1172 let reflowed = reflow_line_unchecked(line, options);
1173 if preserves_content(line, &reflowed) {
1174 reflowed
1175 } else {
1176 vec![line.to_string()]
1177 }
1178}
1179
1180fn preserves_content(original: &str, reflowed: &[String]) -> bool {
1187 let (original_text, original_breaks) = visible_text_and_breaks(original.chars());
1188 let (reflowed_text, reflowed_breaks) =
1189 visible_text_and_breaks(reflowed.iter().flat_map(|line| line.chars().chain(['\n'])));
1190
1191 original_text == reflowed_text && contains_all(&reflowed_breaks, &original_breaks)
1192}
1193
1194fn visible_text_and_breaks(text: impl Iterator<Item = char>) -> (String, Vec<usize>) {
1197 let mut visible = String::new();
1198 let mut breaks = Vec::new();
1199 let mut count = 0usize;
1200 let mut pending_break = false;
1201
1202 for c in text {
1203 if c.is_whitespace() {
1204 pending_break = count > 0;
1205 } else {
1206 if pending_break {
1207 breaks.push(count);
1208 pending_break = false;
1209 }
1210 visible.push(c);
1211 count += 1;
1212 }
1213 }
1214
1215 (visible, breaks)
1216}
1217
1218fn contains_all(superset: &[usize], subset: &[usize]) -> bool {
1220 let mut candidates = superset.iter();
1221 subset
1222 .iter()
1223 .all(|wanted| candidates.by_ref().any(|found| found == wanted))
1224}
1225
1226fn reflow_line_unchecked(line: &str, options: &ReflowOptions) -> Vec<String> {
1227 if options.sentence_per_line {
1229 let elements = parse_elements(line, options);
1230 return merge_block_construct_continuations(reflow_elements_sentence_per_line(&elements, options));
1231 }
1232
1233 if options.semantic_line_breaks {
1235 let elements = parse_elements(line, options);
1236 return merge_block_construct_continuations(reflow_elements_semantic(&elements, options));
1237 }
1238
1239 if options.line_length == 0 || line_fits(line, options) {
1242 return vec![line.to_string()];
1243 }
1244
1245 let elements = parse_elements(line, options);
1247
1248 merge_block_construct_continuations(reflow_elements(&elements, options))
1250}
1251
1252#[derive(Debug, Clone)]
1254enum Element {
1255 Text(String),
1257 Link(String),
1259 ReferenceLink(String),
1261 EmptyReferenceLink(String),
1263 ShortcutReference(String),
1265 InlineImage(String),
1267 ReferenceImage(String),
1269 EmptyReferenceImage(String),
1271 LinkedImage(String),
1273 FootnoteReference(String),
1275 Strikethrough {
1277 content: String,
1278 double: bool,
1280 },
1281 WikiLink(String),
1283 InlineMath(String),
1285 DisplayMath(String),
1287 EmojiShortcode(String),
1289 Autolink(String),
1291 HtmlTag(String),
1293 HtmlEntity(String),
1295 HugoShortcode(String),
1297 AttrList(String),
1299 MystRole(String),
1303 Code { content: String, marker: String },
1305 Bold {
1307 content: String,
1308 underscore: bool,
1310 },
1311 Italic {
1313 content: String,
1314 underscore: bool,
1316 },
1317}
1318
1319impl Element {
1320 fn opens_with_bracket(&self) -> bool {
1325 matches!(
1326 self,
1327 Element::Link(_)
1328 | Element::ReferenceLink(_)
1329 | Element::EmptyReferenceLink(_)
1330 | Element::ShortcutReference(_)
1331 | Element::FootnoteReference(_)
1332 | Element::InlineImage(_)
1333 | Element::ReferenceImage(_)
1334 | Element::EmptyReferenceImage(_)
1335 | Element::LinkedImage(_)
1336 | Element::WikiLink(_)
1337 )
1338 }
1339}
1340
1341impl std::fmt::Display for Element {
1342 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1343 match self {
1344 Element::Text(s) => write!(f, "{s}"),
1345 Element::Link(s) => write!(f, "{s}"),
1346 Element::ReferenceLink(s) => write!(f, "{s}"),
1347 Element::EmptyReferenceLink(s) => write!(f, "{s}"),
1348 Element::ShortcutReference(s) => write!(f, "{s}"),
1349 Element::InlineImage(s) => write!(f, "{s}"),
1350 Element::ReferenceImage(s) => write!(f, "{s}"),
1351 Element::EmptyReferenceImage(s) => write!(f, "{s}"),
1352 Element::LinkedImage(s) => write!(f, "{s}"),
1353 Element::FootnoteReference(s) => write!(f, "{s}"),
1354 Element::Strikethrough { content, double } => {
1355 let marker = if *double { "~~" } else { "~" };
1356 write!(f, "{marker}{content}{marker}")
1357 }
1358 Element::WikiLink(s) => write!(f, "[[{s}]]"),
1359 Element::InlineMath(s) => write!(f, "${s}$"),
1360 Element::DisplayMath(s) => write!(f, "$${s}$$"),
1361 Element::EmojiShortcode(s) => write!(f, ":{s}:"),
1362 Element::Autolink(s) => write!(f, "{s}"),
1363 Element::HtmlTag(s) => write!(f, "{s}"),
1364 Element::HtmlEntity(s) => write!(f, "{s}"),
1365 Element::HugoShortcode(s) => write!(f, "{s}"),
1366 Element::AttrList(s) => write!(f, "{s}"),
1367 Element::MystRole(s) => write!(f, "{s}"),
1368 Element::Code { content, marker } => write!(f, "{marker}{content}{marker}"),
1369 Element::Bold { content, underscore } => {
1370 if *underscore {
1371 write!(f, "__{content}__")
1372 } else {
1373 write!(f, "**{content}**")
1374 }
1375 }
1376 Element::Italic { content, underscore } => {
1377 if *underscore {
1378 write!(f, "_{content}_")
1379 } else {
1380 write!(f, "*{content}*")
1381 }
1382 }
1383 }
1384 }
1385}
1386
1387impl Element {
1388 fn display_len(&self, mode: ReflowLengthMode) -> usize {
1389 match self {
1390 Element::Text(s)
1391 | Element::Link(s)
1392 | Element::ReferenceLink(s)
1393 | Element::EmptyReferenceLink(s)
1394 | Element::ShortcutReference(s)
1395 | Element::InlineImage(s)
1396 | Element::ReferenceImage(s)
1397 | Element::EmptyReferenceImage(s)
1398 | Element::LinkedImage(s)
1399 | Element::FootnoteReference(s)
1400 | Element::Autolink(s)
1401 | Element::HtmlTag(s)
1402 | Element::HtmlEntity(s)
1403 | Element::HugoShortcode(s)
1404 | Element::AttrList(s)
1405 | Element::MystRole(s) => display_len(s, mode),
1406 Element::WikiLink(s) => display_len(s, mode) + 4,
1407 Element::InlineMath(s) => display_len(s, mode) + 2,
1408 Element::DisplayMath(s) => display_len(s, mode) + 4,
1409 Element::EmojiShortcode(s) => display_len(s, mode) + 2,
1410 Element::Strikethrough { content, double } => display_len(content, mode) + if *double { 4 } else { 2 },
1411 Element::Code { content, marker } => display_len(content, mode) + display_len(marker, mode) * 2,
1412 Element::Bold { content, .. } => display_len(content, mode) + 4,
1413 Element::Italic { content, .. } => display_len(content, mode) + 2,
1414 }
1415 }
1416
1417 fn exempt_width(&self, mode: ReflowLengthMode, exemptions: LengthExemptions) -> LineWidth {
1428 let full = self.display_len(mode);
1429 let mut width = LineWidth::plain(full);
1430 match self {
1431 Element::Link(s) | Element::LinkedImage(s) if exemptions.link_urls => {
1432 if let Some(text) = bracketed_text(s, 0) {
1433 width.link_exempt = (2 + display_len(text, mode)).min(full);
1434 }
1435 }
1436 Element::InlineImage(s) if exemptions.link_urls => {
1437 if let Some(alt) = bracketed_text(s, 1) {
1438 width.link_exempt = (3 + display_len(alt, mode)).min(full);
1439 }
1440 }
1441 Element::Code { .. } if exemptions.code_spans => width.code_exempt = 0,
1442 _ => {}
1443 }
1444 width
1445 }
1446}
1447
1448fn bracketed_text(s: &str, open: usize) -> Option<&str> {
1455 let bytes = s.as_bytes();
1456 if bytes.get(open) != Some(&b'[') {
1457 return None;
1458 }
1459 let mut depth = 0usize;
1460 let mut in_code_span = false;
1461 let mut escaped = false;
1462 for (i, &byte) in bytes.iter().enumerate().skip(open + 1) {
1463 if escaped {
1464 escaped = false;
1465 continue;
1466 }
1467 match byte {
1468 b'\\' => escaped = true,
1469 b'`' => in_code_span = !in_code_span,
1470 b'[' if !in_code_span => depth += 1,
1471 b']' if !in_code_span => match depth.checked_sub(1) {
1472 Some(next) => depth = next,
1473 None => return s.get(open + 1..i),
1474 },
1475 _ => {}
1476 }
1477 }
1478 None
1479}
1480
1481#[derive(Debug, Clone)]
1483struct EmphasisSpan {
1484 start: usize,
1486 end: usize,
1488 content: String,
1490 is_strong: bool,
1492 is_strikethrough: bool,
1494 uses_underscore: bool,
1496 strikethrough_double: bool,
1499}
1500
1501fn extract_emphasis_and_code_spans(text: &str) -> (Vec<EmphasisSpan>, Vec<CodeSpan>) {
1511 let has_emphasis = text.contains(['*', '_', '~']);
1513 let has_code = text.contains('`');
1514 if !has_emphasis && !has_code {
1515 return (Vec::new(), Vec::new());
1516 }
1517
1518 let mut emphasis_spans = Vec::new();
1519 let mut code_spans = Vec::new();
1520
1521 let mut options = Options::empty();
1522 if has_emphasis {
1523 options.insert(Options::ENABLE_STRIKETHROUGH);
1524 }
1525
1526 let mut emphasis_stack: Vec<(usize, bool)> = Vec::new(); let mut strong_stack: Vec<(usize, bool)> = Vec::new();
1529 let mut strikethrough_stack: Vec<usize> = Vec::new();
1530
1531 let parser = Parser::new_ext(text, options).into_offset_iter();
1532
1533 for (event, range) in parser {
1534 match event {
1535 Event::Code(_) => {
1536 code_spans.push(CodeSpan {
1537 start: range.start,
1538 end: range.end,
1539 });
1540 }
1541 Event::Start(Tag::Emphasis) => {
1542 let uses_underscore = text.get(range.start..range.start + 1) == Some("_");
1544 emphasis_stack.push((range.start, uses_underscore));
1545 }
1546 Event::End(TagEnd::Emphasis) => {
1547 if let Some((start_byte, uses_underscore)) = emphasis_stack.pop() {
1548 let content_start = start_byte + 1;
1549 let content_end = range.end - 1;
1550 if content_end > content_start
1551 && let Some(content) = text.get(content_start..content_end)
1552 {
1553 emphasis_spans.push(EmphasisSpan {
1554 start: start_byte,
1555 end: range.end,
1556 content: content.to_string(),
1557 is_strong: false,
1558 is_strikethrough: false,
1559 uses_underscore,
1560 strikethrough_double: false,
1561 });
1562 }
1563 }
1564 }
1565 Event::Start(Tag::Strong) => {
1566 let uses_underscore = text.get(range.start..range.start + 2) == Some("__");
1567 strong_stack.push((range.start, uses_underscore));
1568 }
1569 Event::End(TagEnd::Strong) => {
1570 if let Some((start_byte, uses_underscore)) = strong_stack.pop() {
1571 let content_start = start_byte + 2;
1572 let content_end = range.end - 2;
1573 if content_end > content_start
1574 && let Some(content) = text.get(content_start..content_end)
1575 {
1576 emphasis_spans.push(EmphasisSpan {
1577 start: start_byte,
1578 end: range.end,
1579 content: content.to_string(),
1580 is_strong: true,
1581 is_strikethrough: false,
1582 uses_underscore,
1583 strikethrough_double: false,
1584 });
1585 }
1586 }
1587 }
1588 Event::Start(Tag::Strikethrough) => {
1589 strikethrough_stack.push(range.start);
1590 }
1591 Event::End(TagEnd::Strikethrough) => {
1592 if let Some(start_byte) = strikethrough_stack.pop() {
1593 let double = text.get(start_byte..start_byte + 2) == Some("~~");
1594 let marker_len = if double { 2 } else { 1 };
1595 let content_start = start_byte + marker_len;
1596 let content_end = range.end - marker_len;
1597 if content_end > content_start
1598 && let Some(content) = text.get(content_start..content_end)
1599 {
1600 emphasis_spans.push(EmphasisSpan {
1601 start: start_byte,
1602 end: range.end,
1603 content: content.to_string(),
1604 is_strong: false,
1605 is_strikethrough: true,
1606 uses_underscore: false,
1607 strikethrough_double: double,
1608 });
1609 }
1610 }
1611 }
1612 _ => {}
1613 }
1614 }
1615
1616 emphasis_spans.sort_by_key(|s| s.start);
1617 (emphasis_spans, code_spans)
1618}
1619
1620#[derive(Debug, Clone)]
1621struct CodeSpan {
1622 start: usize,
1623 end: usize,
1624}
1625
1626#[derive(Debug, Clone)]
1627struct LinkSpan {
1628 start: usize,
1629 end: usize,
1630 link_type: Option<LinkType>,
1631 is_image: bool,
1632 is_footnote: bool,
1633 depth: usize,
1636}
1637
1638fn extract_link_spans(text: &str, defined_references: Option<&HashSet<String>>) -> Vec<LinkSpan> {
1642 let mut spans = all_link_spans(text, defined_references);
1643 spans.retain(|span| span.depth == 0);
1644 spans
1645}
1646
1647fn all_link_spans(text: &str, defined_references: Option<&HashSet<String>>) -> Vec<LinkSpan> {
1650 if !text.contains('[') {
1653 return Vec::new();
1654 }
1655
1656 let mut spans = Vec::new();
1657 let mut options = Options::empty();
1658 options.insert(Options::ENABLE_FOOTNOTES);
1659
1660 let resolve = move |link: BrokenLink<'_>| -> Option<(CowStr<'_>, CowStr<'_>)> {
1677 let atomic = match link.link_type {
1682 LinkType::Shortcut | LinkType::ShortcutUnknown => match defined_references {
1683 Some(defs) => defs.contains(&normalize_reference_label(link.reference.as_ref())),
1684 None => true,
1685 },
1686 _ => true,
1687 };
1688 atomic.then_some((CowStr::Borrowed(""), CowStr::Borrowed("")))
1689 };
1690 let parser = Parser::new_with_broken_link_callback(text, options, Some(resolve)).into_offset_iter();
1691 let mut stack = Vec::new();
1692
1693 for (event, range) in parser {
1694 match event {
1695 Event::Start(Tag::Link { link_type, .. }) => {
1696 stack.push((range.start, Some(link_type), false));
1697 }
1698 Event::Start(Tag::Image { link_type, .. }) => {
1699 stack.push((range.start, Some(link_type), true));
1700 }
1701 Event::End(TagEnd::Link | TagEnd::Image) => {
1702 if let Some((start_byte, link_type, is_image)) = stack.pop() {
1703 spans.push(LinkSpan {
1704 start: start_byte,
1705 end: range.end,
1706 link_type,
1707 is_image,
1708 is_footnote: false,
1709 depth: stack.len(),
1710 });
1711 }
1712 }
1713 Event::FootnoteReference(_) => {
1714 spans.push(LinkSpan {
1715 start: range.start,
1716 end: range.end,
1717 link_type: None,
1718 is_image: false,
1719 is_footnote: true,
1720 depth: stack.len(),
1721 });
1722 }
1723 _ => {}
1724 }
1725 }
1726
1727 spans.sort_by_key(|s| s.start);
1728 spans
1729}
1730
1731fn myst_role_len_at(text: &str, absolute_pos: usize, code_spans: &[CodeSpan]) -> Option<usize> {
1739 let bytes = text.as_bytes();
1740 if bytes.first() != Some(&b'{') {
1741 return None;
1742 }
1743
1744 let mut j = 1;
1746 match bytes.get(j) {
1747 Some(&b) if b.is_ascii_alphabetic() || b == b'_' => {}
1748 _ => return None,
1749 }
1750 while let Some(&b) = bytes.get(j) {
1751 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b':' | b'.') {
1752 j += 1;
1753 } else {
1754 break;
1755 }
1756 }
1757 if bytes.get(j) != Some(&b'}') {
1758 return None;
1759 }
1760 j += 1; let code_span_start = absolute_pos + j;
1764 if let Ok(idx) = code_spans.binary_search_by_key(&code_span_start, |span| span.start) {
1765 let span = &code_spans[idx];
1766 let code_span_len = span.end - span.start;
1767 return Some(j + code_span_len);
1768 }
1769
1770 None
1771}
1772
1773fn inline_math_len_at_start(s: &str) -> Option<usize> {
1780 let bytes = s.as_bytes();
1781 if bytes.first() != Some(&b'$') || bytes.get(1) == Some(&b'$') {
1783 return None;
1784 }
1785 let close = 1 + s[1..].find('$')?;
1788 if bytes.get(close + 1) == Some(&b'$') {
1790 return None;
1791 }
1792 Some(close + 1)
1793}
1794
1795#[derive(Clone, Copy, Debug)]
1797struct PatternMatch {
1798 start: usize,
1799 end: usize,
1800}
1801
1802#[derive(Clone, Copy)]
1816enum PatternCache {
1817 Unsearched,
1818 NotFound,
1819 Found(PatternMatch),
1820}
1821
1822impl PatternCache {
1823 fn earliest_in(
1827 &mut self,
1828 remaining: &str,
1829 cursor: usize,
1830 find: impl FnOnce(&str) -> Option<(usize, usize)>,
1831 ) -> Option<(usize, usize)> {
1832 let stale = match self {
1833 PatternCache::Found(pm) => pm.start < cursor,
1834 PatternCache::NotFound => false,
1835 PatternCache::Unsearched => true,
1836 };
1837 if stale {
1838 *self = match find(remaining) {
1839 Some((start, end)) => PatternCache::Found(PatternMatch {
1840 start: cursor + start,
1841 end: cursor + end,
1842 }),
1843 None => PatternCache::NotFound,
1844 };
1845 }
1846 match self {
1847 PatternCache::Found(pm) => Some((pm.start - cursor, pm.end - cursor)),
1848 _ => None,
1849 }
1850 }
1851}
1852
1853fn parse_markdown_elements_inner(
1864 text: &str,
1865 attr_lists: bool,
1866 myst_roles: bool,
1867 defined_references: Option<&HashSet<String>>,
1868) -> Vec<Element> {
1869 let mut elements = Vec::new();
1870 let mut remaining = text;
1871
1872 let (emphasis_spans, code_spans) = extract_emphasis_and_code_spans(text);
1877 let link_spans = extract_link_spans(text, defined_references);
1878
1879 let mut cached_wiki_link = PatternCache::Unsearched;
1882 let mut cached_display_math = PatternCache::Unsearched;
1883 let mut cached_inline_math = PatternCache::Unsearched;
1884 let mut cached_emoji = PatternCache::Unsearched;
1885 let mut cached_html_entity = PatternCache::Unsearched;
1886 let mut cached_hugo_shortcode = PatternCache::Unsearched;
1887 let mut cached_html_tag = PatternCache::Unsearched;
1888 let mut cached_next_curly = PatternCache::Unsearched;
1889
1890 let mut link_span_idx = 0usize;
1894 let mut emphasis_span_idx = 0usize;
1895 let mut code_span_idx = 0usize;
1896
1897 while !remaining.is_empty() {
1898 let current_offset = text.len() - remaining.len();
1900 let mut earliest_match: Option<(usize, usize, &str)> = None;
1903
1904 while link_span_idx < link_spans.len() && link_spans[link_span_idx].start < current_offset {
1906 link_span_idx += 1;
1907 }
1908 let next_link: Option<&LinkSpan> = link_spans.get(link_span_idx);
1909
1910 if let Some(span) = next_link {
1911 let pos_in_remaining = span.start - current_offset;
1912 if earliest_match
1913 .as_ref()
1914 .is_none_or(|(start, _, _)| pos_in_remaining < *start)
1915 {
1916 let match_end = span.end - current_offset;
1917 earliest_match = Some((pos_in_remaining, match_end, "link_span"));
1918 }
1919 }
1920
1921 if let Some((start, end)) = cached_wiki_link.earliest_in(remaining, current_offset, |suffix| {
1923 WIKI_LINK_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1924 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1925 {
1926 earliest_match = Some((start, end, "wiki_link"));
1927 }
1928
1929 if let Some((start, end)) = cached_display_math.earliest_in(remaining, current_offset, |suffix| {
1931 DISPLAY_MATH_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1932 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1933 {
1934 earliest_match = Some((start, end, "display_math"));
1935 }
1936
1937 let inline_math_probe = if current_offset > 0 && text.as_bytes()[current_offset - 1] == b'$' {
1951 inline_math_len_at_start(remaining).map(|len| (0, len))
1952 } else {
1953 None
1954 };
1955 if let Some((start, end)) = inline_math_probe.or_else(|| {
1956 cached_inline_math.earliest_in(remaining, current_offset, |suffix| {
1957 INLINE_MATH_REGEX
1958 .find(suffix)
1959 .ok()
1960 .flatten()
1961 .map(|m| (m.start(), m.end()))
1962 })
1963 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1964 {
1965 earliest_match = Some((start, end, "inline_math"));
1966 }
1967
1968 if let Some((start, end)) = cached_emoji.earliest_in(remaining, current_offset, |suffix| {
1970 EMOJI_SHORTCODE_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1971 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1972 {
1973 earliest_match = Some((start, end, "emoji"));
1974 }
1975
1976 if let Some((start, end)) = cached_html_entity.earliest_in(remaining, current_offset, |suffix| {
1978 HTML_ENTITY_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, "html_entity"));
1982 }
1983
1984 if let Some((start, end)) = cached_hugo_shortcode.earliest_in(remaining, current_offset, |suffix| {
1987 HUGO_SHORTCODE_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1988 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1989 {
1990 earliest_match = Some((start, end, "hugo_shortcode"));
1991 }
1992
1993 if let Some((start, end)) = cached_html_tag.earliest_in(remaining, current_offset, |suffix| {
2000 let mut from = 0;
2001 while let Some(m) = HTML_TAG_PATTERN.find(&suffix[from..]) {
2002 let (tag_start, tag_end) = (from + m.start(), from + m.end());
2003 let tag = &suffix[tag_start..tag_end];
2004 let is_url_autolink = tag.starts_with("<http://")
2006 || tag.starts_with("<https://")
2007 || tag.starts_with("<mailto:")
2008 || tag.starts_with("<ftp://")
2009 || tag.starts_with("<ftps://");
2010 let is_email_autolink = {
2013 let content = tag.trim_start_matches('<').trim_end_matches('>');
2014 EMAIL_PATTERN.is_match(content)
2015 };
2016 if is_url_autolink || is_email_autolink {
2017 from = tag_end;
2018 } else {
2019 return Some((tag_start, tag_end));
2020 }
2021 }
2022 None
2023 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
2024 {
2025 earliest_match = Some((start, end, "html_tag"));
2026 }
2027
2028 let mut next_special = remaining.len();
2030 let mut special_type = "";
2031 let mut pulldown_emphasis: Option<&EmphasisSpan> = None;
2032 let mut attr_list_len: usize = 0;
2033 let mut myst_role_len: usize = 0;
2034
2035 while code_span_idx < code_spans.len() && code_spans[code_span_idx].start < current_offset {
2037 code_span_idx += 1;
2038 }
2039 let next_code_span: Option<&CodeSpan> = code_spans.get(code_span_idx);
2040 if let Some(span) = next_code_span {
2041 let pos_in_remaining = span.start - current_offset;
2042 if pos_in_remaining < next_special {
2043 next_special = pos_in_remaining;
2044 special_type = "pulldown_code";
2045 }
2046 }
2047
2048 let next_curly_pos = cached_next_curly
2051 .earliest_in(remaining, current_offset, |suffix| {
2052 suffix.find('{').map(|pos| (pos, pos + 1))
2053 })
2054 .map(|(start, _)| start);
2055
2056 if myst_roles
2061 && let Some(pos) = next_curly_pos
2062 && pos < next_special
2063 && let Some(role_len) = myst_role_len_at(&remaining[pos..], current_offset + pos, &code_spans)
2064 {
2065 next_special = pos;
2066 special_type = "myst_role";
2067 myst_role_len = role_len;
2068 }
2069
2070 if attr_lists
2072 && let Some(pos) = next_curly_pos
2073 && pos < next_special
2074 && let Some(m) = ATTR_LIST_PATTERN.find(&remaining[pos..])
2075 && m.start() == 0
2076 {
2077 next_special = pos;
2078 special_type = "attr_list";
2079 attr_list_len = m.end();
2080 }
2081
2082 while emphasis_span_idx < emphasis_spans.len() && emphasis_spans[emphasis_span_idx].start < current_offset {
2084 emphasis_span_idx += 1;
2085 }
2086 if let Some(span) = emphasis_spans.get(emphasis_span_idx) {
2087 let pos_in_remaining = span.start - current_offset;
2088 if pos_in_remaining < next_special {
2089 next_special = pos_in_remaining;
2090 special_type = "pulldown_emphasis";
2091 pulldown_emphasis = Some(span);
2092 }
2093 }
2094
2095 let should_process_markdown_link = if let Some((pos, _, _)) = earliest_match {
2097 pos < next_special
2098 } else {
2099 false
2100 };
2101
2102 if should_process_markdown_link {
2103 let (pos, match_end, pattern_type) = earliest_match.unwrap();
2104
2105 if pos > 0 {
2107 elements.push(Element::Text(remaining[..pos].to_string()));
2108 }
2109
2110 match pattern_type {
2112 "link_span" => {
2113 let span = next_link.unwrap();
2114 let raw_text = remaining[pos..match_end].to_string();
2115 if span.is_footnote {
2116 elements.push(Element::FootnoteReference(raw_text));
2117 } else if span.is_image {
2118 match span.link_type {
2119 Some(LinkType::Inline) => elements.push(Element::InlineImage(raw_text)),
2120 Some(LinkType::Reference)
2123 | Some(LinkType::ReferenceUnknown)
2124 | Some(LinkType::Shortcut)
2125 | Some(LinkType::ShortcutUnknown) => elements.push(Element::ReferenceImage(raw_text)),
2126 Some(LinkType::Collapsed) | Some(LinkType::CollapsedUnknown) => {
2127 elements.push(Element::EmptyReferenceImage(raw_text))
2128 }
2129 _ => elements.push(Element::InlineImage(raw_text)),
2130 }
2131 } else {
2132 match span.link_type {
2133 Some(LinkType::Inline) => {
2134 if raw_text.starts_with('[') && raw_text.contains("![") {
2135 elements.push(Element::LinkedImage(raw_text));
2136 } else {
2137 elements.push(Element::Link(raw_text));
2138 }
2139 }
2140 Some(LinkType::Reference) | Some(LinkType::ReferenceUnknown) => {
2143 elements.push(Element::ReferenceLink(raw_text))
2144 }
2145 Some(LinkType::Collapsed) | Some(LinkType::CollapsedUnknown) => {
2146 elements.push(Element::EmptyReferenceLink(raw_text))
2147 }
2148 Some(LinkType::Shortcut) | Some(LinkType::ShortcutUnknown) => {
2149 elements.push(Element::ShortcutReference(raw_text))
2150 }
2151 Some(LinkType::Autolink) | Some(LinkType::Email) => {
2152 elements.push(Element::Autolink(raw_text))
2153 }
2154 _ => elements.push(Element::Link(raw_text)),
2155 }
2156 }
2157 remaining = &remaining[match_end..];
2158 }
2159 "wiki_link" => {
2160 if let Some(caps) = WIKI_LINK_REGEX.captures(remaining) {
2161 let content = caps.get(1).map_or("", |m| m.as_str());
2162 elements.push(Element::WikiLink(content.to_string()));
2163 remaining = &remaining[match_end..];
2164 } else {
2165 elements.push(Element::Text("[[".to_string()));
2166 remaining = &remaining[2..];
2167 }
2168 }
2169 "display_math" => {
2170 if let Some(caps) = DISPLAY_MATH_REGEX.captures(remaining) {
2171 let math = caps.get(1).map_or("", |m| m.as_str());
2172 elements.push(Element::DisplayMath(math.to_string()));
2173 remaining = &remaining[match_end..];
2174 } else {
2175 elements.push(Element::Text("$$".to_string()));
2176 remaining = &remaining[2..];
2177 }
2178 }
2179 "inline_math" => {
2180 if let Ok(Some(caps)) = INLINE_MATH_REGEX.captures(remaining) {
2181 let math = caps.get(1).map_or("", |m| m.as_str());
2182 elements.push(Element::InlineMath(math.to_string()));
2183 remaining = &remaining[match_end..];
2184 } else {
2185 elements.push(Element::Text("$".to_string()));
2186 remaining = &remaining[1..];
2187 }
2188 }
2189 "emoji" => {
2190 if let Some(caps) = EMOJI_SHORTCODE_REGEX.captures(remaining) {
2191 let emoji = caps.get(1).map_or("", |m| m.as_str());
2192 elements.push(Element::EmojiShortcode(emoji.to_string()));
2193 remaining = &remaining[match_end..];
2194 } else {
2195 elements.push(Element::Text(":".to_string()));
2196 remaining = &remaining[1..];
2197 }
2198 }
2199 "html_entity" => {
2200 elements.push(Element::HtmlEntity(remaining[pos..match_end].to_string()));
2202 remaining = &remaining[match_end..];
2203 }
2204 "hugo_shortcode" => {
2205 elements.push(Element::HugoShortcode(remaining[pos..match_end].to_string()));
2207 remaining = &remaining[match_end..];
2208 }
2209 "html_tag" => {
2210 elements.push(Element::HtmlTag(remaining[pos..match_end].to_string()));
2212 remaining = &remaining[match_end..];
2213 }
2214 _ => unreachable!("unknown pattern type: {}", pattern_type),
2215 }
2216 } else {
2217 if next_special > 0 && next_special < remaining.len() {
2221 elements.push(Element::Text(remaining[..next_special].to_string()));
2222 remaining = &remaining[next_special..];
2223 }
2224
2225 match special_type {
2227 "pulldown_code" => {
2228 let span = next_code_span.unwrap();
2229 let span_len = span.end - span.start;
2230 let code_raw = &remaining[..span_len];
2231 if let Some((content, marker)) = decompose_code_span(code_raw) {
2232 elements.push(Element::Code {
2233 content: content.to_string(),
2234 marker: marker.to_string(),
2235 });
2236 } else {
2237 elements.push(Element::Text(code_raw.to_string()));
2238 }
2239 remaining = &remaining[span_len..];
2240 }
2241 "attr_list" => {
2242 elements.push(Element::AttrList(remaining[..attr_list_len].to_string()));
2243 remaining = &remaining[attr_list_len..];
2244 }
2245 "myst_role" => {
2246 elements.push(Element::MystRole(remaining[..myst_role_len].to_string()));
2247 remaining = &remaining[myst_role_len..];
2248 }
2249 "pulldown_emphasis" => {
2250 let span = pulldown_emphasis.expect("pulldown_emphasis must be set");
2252 let span_len = span.end - span.start;
2253 if span.is_strikethrough {
2254 elements.push(Element::Strikethrough {
2255 content: span.content.clone(),
2256 double: span.strikethrough_double,
2257 });
2258 } else if span.is_strong {
2259 elements.push(Element::Bold {
2260 content: span.content.clone(),
2261 underscore: span.uses_underscore,
2262 });
2263 } else {
2264 elements.push(Element::Italic {
2265 content: span.content.clone(),
2266 underscore: span.uses_underscore,
2267 });
2268 }
2269 remaining = &remaining[span_len..];
2270 }
2271 _ => {
2272 elements.push(Element::Text(remaining.to_string()));
2274 break;
2275 }
2276 }
2277 }
2278 }
2279
2280 let mut merged_elements = Vec::new();
2282 for el in elements {
2283 match el {
2284 Element::Text(s) => {
2285 if let Some(Element::Text(last_s)) = merged_elements.last_mut() {
2286 last_s.push_str(&s);
2287 } else {
2288 merged_elements.push(Element::Text(s));
2289 }
2290 }
2291 other => merged_elements.push(other),
2292 }
2293 }
2294 merged_elements
2295}
2296
2297fn source_gap_before(elements: &[Element], idx: usize) -> &str {
2311 let Some(Element::Text(previous)) = idx.checked_sub(1).map(|prev| &elements[prev]) else {
2312 return "";
2313 };
2314
2315 let gap = &previous[previous.trim_end_matches(char::is_whitespace).len()..];
2316 if gap.is_empty() {
2317 ""
2318 } else if gap.contains(is_non_breaking_space) {
2319 gap
2320 } else {
2321 " "
2322 }
2323}
2324
2325fn push_source_gap(current_line: &mut String, gap: &str) {
2328 if !gap.is_empty() && !current_line.is_empty() && !current_line.ends_with(char::is_whitespace) {
2329 current_line.push_str(gap);
2330 }
2331}
2332
2333fn is_setext_or_thematic(text: &str) -> bool {
2339 let mut marker = 0u8;
2340 let mut count = 0usize;
2341 let mut has_space = false;
2342 for &b in text.as_bytes() {
2343 match b {
2344 b' ' | b'\t' => has_space = true,
2345 b'-' | b'=' | b'*' | b'_' => {
2346 if marker == 0 {
2347 marker = b;
2348 } else if b != marker {
2349 return false;
2350 }
2351 count += 1;
2352 }
2353 _ => return false,
2354 }
2355 }
2356 match marker {
2357 b'=' => !has_space,
2358 b'-' => !has_space || count >= 3,
2359 b'*' | b'_' => count >= 3,
2360 _ => false,
2361 }
2362}
2363
2364fn starts_block_construct(text: &str) -> bool {
2376 let text = text.trim_start();
2377 let bytes = text.as_bytes();
2378 let Some(&first) = bytes.first() else {
2379 return false;
2380 };
2381 let marker_then_boundary = |len: usize| bytes.len() == len || bytes[len] == b' ' || bytes[len] == b'\t';
2382 match first {
2383 b'>' => true,
2385 b'-' | b'*' | b'+' => marker_then_boundary(1) || is_setext_or_thematic(text),
2386 b'_' | b'=' => is_setext_or_thematic(text),
2387 b':' => is_definition_list_item(text) || text.starts_with(":::"),
2388 b'|' => true,
2389 b'#' => {
2390 let hashes = bytes.iter().take_while(|&&b| b == b'#').count();
2391 hashes <= 6 && marker_then_boundary(hashes)
2392 }
2393 b'`' => bytes.iter().take_while(|&&b| b == b'`').count() >= 3,
2394 b'~' => bytes.iter().take_while(|&&b| b == b'~').count() >= 3,
2395 b'0'..=b'9' => {
2402 let digits = bytes.iter().take_while(|b| b.is_ascii_digit()).count();
2403 digits <= 9
2404 && text[..digits].trim_start_matches('0') == "1"
2405 && bytes.len() > digits + 1
2406 && (bytes[digits] == b'.' || bytes[digits] == b')')
2407 && (bytes[digits + 1] == b' ' || bytes[digits + 1] == b'\t')
2408 }
2409 b'[' => {
2417 let mut escaped = false;
2418 let mut label_close = None;
2419 for (i, &b) in bytes.iter().enumerate().skip(1) {
2420 if escaped {
2421 escaped = false;
2422 } else if b == b'\\' {
2423 escaped = true;
2424 } else if b == b']' {
2425 label_close = Some(i);
2426 break;
2427 }
2428 }
2429 label_close.is_some_and(|i| bytes.get(i + 1) == Some(&b':'))
2430 }
2431 b'<' => crate::utils::html_block::parse_html_block_start(text).is_some(),
2434 _ => false,
2435 }
2436}
2437
2438fn merge_block_construct_continuations(lines: Vec<String>) -> Vec<String> {
2447 let mut merged: Vec<String> = Vec::with_capacity(lines.len());
2448 for line in lines {
2449 merged.push(line);
2450 while merged.len() > 1 && starts_block_construct(merged.last().expect("non-empty")) {
2454 let last = merged.pop().expect("non-empty");
2455 let prev = merged.last_mut().expect("len > 1");
2456 prev.push(' ');
2457 prev.push_str(last.trim_start());
2458 }
2459 }
2460 merged
2461}
2462
2463fn reflow_elements_sentence_per_line(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
2465 let abbreviations = get_abbreviations(&options.abbreviations);
2466 let require_sentence_capital = options.require_sentence_capital;
2467 let mut lines = Vec::new();
2468 let mut current_line = String::new();
2469
2470 for (idx, element) in elements.iter().enumerate() {
2471 let is_span = matches!(
2477 element,
2478 Element::Italic { .. } | Element::Bold { .. } | Element::Strikethrough { .. }
2479 );
2480 let piece = match element {
2481 Element::Text(text) => Some(text.clone()),
2483 Element::Italic { content, underscore } => Some(wrap_emphasis(
2484 content,
2485 if *underscore { "_" } else { "*" },
2486 &mut current_line,
2487 source_gap_before(elements, idx),
2488 )),
2489 Element::Bold { content, underscore } => Some(wrap_emphasis(
2490 content,
2491 if *underscore { "__" } else { "**" },
2492 &mut current_line,
2493 source_gap_before(elements, idx),
2494 )),
2495 Element::Strikethrough { content, double } => Some(wrap_emphasis(
2496 content,
2497 if *double { "~~" } else { "~" },
2498 &mut current_line,
2499 source_gap_before(elements, idx),
2500 )),
2501 _ => None,
2502 };
2503
2504 if let Some(piece) = piece {
2505 let appended_span_start = is_span.then_some(current_line.len());
2509 let combined = format!("{current_line}{piece}");
2510 let sentences = split_into_sentences_with_set(
2512 &combined,
2513 &abbreviations,
2514 require_sentence_capital,
2515 appended_span_start,
2516 options.defined_references.as_ref(),
2517 );
2518
2519 let next_bracketed = elements
2528 .get(idx + 1)
2529 .filter(|next| next.opens_with_bracket())
2530 .map(|next| (source_gap_before(elements, idx + 1), next.to_string()));
2531 let closes_before_next = |sentence: &str| -> bool {
2532 let Some((gap, next_str)) = &next_bracketed else {
2533 return true;
2534 };
2535 let mut probe = sentence.to_string();
2536 push_source_gap(&mut probe, gap);
2537 probe.push_str(next_str);
2538 let probe_sentences = split_into_sentences_with_set(
2539 &probe,
2540 &abbreviations,
2541 require_sentence_capital,
2542 None,
2543 options.defined_references.as_ref(),
2544 );
2545 probe_sentences.last().is_some_and(|last| last == next_str)
2546 };
2547
2548 if sentences.len() > 1 {
2549 let mut pending = String::new();
2553 let last = sentences.len() - 1;
2554 for (i, sentence) in sentences.iter().enumerate() {
2555 if !pending.is_empty() {
2556 pending.push(' ');
2557 }
2558 pending.push_str(sentence);
2559
2560 let closed = i < last || (ends_with_sentence_punct(&pending) && closes_before_next(&pending));
2565 if closed && !text_ends_with_abbreviation(&pending, &abbreviations) {
2566 lines.push(std::mem::take(&mut pending));
2567 }
2568 }
2569 current_line = pending;
2570 } else {
2571 let trimmed = combined.trim();
2573
2574 if trimmed.is_empty() {
2578 continue;
2579 }
2580
2581 let ends_with_sentence_punct = ends_with_sentence_punct(trimmed);
2582
2583 if ends_with_sentence_punct
2584 && !text_ends_with_abbreviation(trimmed, &abbreviations)
2585 && closes_before_next(trimmed)
2586 {
2587 lines.push(combined.trim_matches(is_breakable_whitespace).to_string());
2590 current_line.clear();
2591 } else {
2592 current_line = combined;
2594 }
2595 }
2596 } else {
2597 let element_str = format!("{element}");
2599 push_source_gap(&mut current_line, source_gap_before(elements, idx));
2600 current_line.push_str(&element_str);
2601 }
2602 }
2603
2604 if !current_line.is_empty() {
2616 let split_tail = (!current_line.contains(is_non_breaking_space))
2617 .then(|| {
2618 split_into_sentences_with_set(
2619 ¤t_line,
2620 &abbreviations,
2621 require_sentence_capital,
2622 None,
2623 options.defined_references.as_ref(),
2624 )
2625 })
2626 .filter(|sentences| sentences.len() > 1);
2627
2628 match split_tail {
2629 Some(sentences) => lines.extend(sentences),
2630 None => lines.push(current_line.trim_matches(is_breakable_whitespace).to_string()),
2631 }
2632 }
2633 lines
2634}
2635
2636fn wrap_emphasis(content: &str, marker: &str, current_line: &mut String, gap: &str) -> String {
2640 push_source_gap(current_line, gap);
2641 format!("{marker}{content}{marker}")
2642}
2643
2644const BREAK_WORDS: &[&str] = &[
2648 "and",
2649 "or",
2650 "but",
2651 "nor",
2652 "yet",
2653 "so",
2654 "for",
2655 "which",
2656 "that",
2657 "because",
2658 "when",
2659 "if",
2660 "while",
2661 "where",
2662 "although",
2663 "though",
2664 "unless",
2665 "since",
2666 "after",
2667 "before",
2668 "until",
2669 "as",
2670 "once",
2671 "whether",
2672 "however",
2673 "therefore",
2674 "moreover",
2675 "furthermore",
2676 "nevertheless",
2677 "whereas",
2678];
2679
2680fn is_clause_punctuation(c: char) -> bool {
2682 matches!(c, ',' | ';' | ':' | '\u{2014}') }
2684
2685fn clause_break_allowed_after(chars: &[char], i: usize) -> bool {
2695 match chars.get(i + 1) {
2696 None => true,
2697 Some(next) => is_breakable_whitespace(*next),
2698 }
2699}
2700
2701fn paren_group_end<'a>(slice: &'a str, element_spans: &[ElementSpan], offset: usize) -> Option<(usize, &'a str)> {
2715 debug_assert!(slice.starts_with('('));
2716 let mut depth: i32 = 0;
2717 for (local_byte, c) in slice.char_indices() {
2718 let global_byte = offset + local_byte;
2719 if depth > 0 && is_inside_element(global_byte, element_spans) {
2724 continue;
2725 }
2726 match c {
2727 '(' => depth += 1,
2728 ')' => {
2729 depth -= 1;
2730 if depth == 0 {
2731 let end = local_byte + 1;
2732 let inner = &slice[1..local_byte];
2733 return Some((end, inner));
2734 }
2735 }
2736 _ => {}
2737 }
2738 }
2739 None
2740}
2741
2742fn split_at_parenthetical(
2759 text: &str,
2760 line_length: usize,
2761 element_spans: &[ElementSpan],
2762 length_mode: ReflowLengthMode,
2763) -> Option<(String, String)> {
2764 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
2765
2766 if text.starts_with('(')
2768 && let Some((end_local, inner)) = paren_group_end(text, element_spans, 0)
2769 && inner.contains(' ')
2770 {
2771 let mut first_end = end_local;
2778 loop {
2779 first_end += text[first_end..]
2780 .char_indices()
2781 .take_while(|(_, c)| !is_breakable_whitespace(*c))
2782 .last()
2783 .map_or(0, |(idx, c)| idx + c.len_utf8());
2784 match element_containing(first_end, element_spans) {
2785 Some(span) => first_end = span.end,
2786 None => break,
2787 }
2788 }
2789 let rest_start = first_end;
2790 let first = &text[..first_end];
2791 if measure(first, 0, element_spans, length_mode).fits(line_length) {
2794 let rest = text[rest_start..].trim_start();
2795 if !rest.is_empty() {
2796 return Some((first.to_string(), rest.to_string()));
2797 }
2798 }
2799 }
2800
2801 let mut best_open_byte: Option<usize> = None;
2803 let mut pos = 0usize;
2804 while pos < text.len() {
2805 if text.as_bytes()[pos] != b'(' {
2807 let c = text[pos..].chars().next().unwrap();
2808 pos += c.len_utf8();
2809 continue;
2810 }
2811 if is_inside_element(pos, element_spans) {
2813 pos += 1;
2814 continue;
2815 }
2816 if let Some((end_local, inner)) = paren_group_end(&text[pos..], element_spans, pos) {
2817 let first = text[..pos].trim_end_matches(is_breakable_whitespace);
2818 let first_len = measure(first, 0, element_spans, length_mode).effective();
2819 if first.len() < pos
2822 && !first.is_empty()
2823 && first_len >= min_first_len
2824 && first_len <= line_length
2825 && inner.contains(' ')
2826 && best_open_byte.is_none_or(|prev| pos > prev)
2827 {
2828 best_open_byte = Some(pos);
2829 }
2830 pos += end_local;
2831 } else {
2832 pos += 1;
2833 }
2834 }
2835
2836 let open_byte = best_open_byte?;
2837 let first = text[..open_byte].trim_end_matches(is_breakable_whitespace).to_string();
2838 let rest = text[open_byte..].to_string();
2839 if first.is_empty() || rest.trim().is_empty() {
2840 return None;
2841 }
2842 Some((first, rest))
2843}
2844
2845#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2853struct ElementSpan {
2854 start: usize,
2855 end: usize,
2856 full: usize,
2857 link_saving: usize,
2860 code_saving: usize,
2862 is_hard: bool,
2864}
2865
2866impl ElementSpan {
2867 fn new(start: usize, len: usize, full: usize, width: LineWidth, is_hard: bool) -> Self {
2870 Self {
2871 start,
2872 end: start + len,
2873 full,
2874 link_saving: full - width.link_exempt,
2875 code_saving: full - width.code_exempt,
2876 is_hard,
2877 }
2878 }
2879
2880 fn contains(&self, pos: usize) -> bool {
2881 pos > self.start && pos < self.end
2882 }
2883
2884 fn within(&self, start: usize, end: usize) -> bool {
2885 self.start >= start && self.end <= end
2886 }
2887
2888 fn exempt_width(&self) -> LineWidth {
2889 LineWidth {
2890 link_exempt: self.full - self.link_saving,
2891 code_exempt: self.full - self.code_saving,
2892 }
2893 }
2894}
2895
2896fn compute_element_spans(
2902 elements: &[Element],
2903 mode: ReflowLengthMode,
2904 exemptions: LengthExemptions,
2905) -> Vec<ElementSpan> {
2906 let mut spans = Vec::new();
2907 let mut offset = 0;
2908 for element in elements {
2909 let len = element.display_len(ReflowLengthMode::Bytes);
2910 if !matches!(element, Element::Text(_)) {
2911 let full = element.display_len(mode);
2912 let width = element.exempt_width(mode, exemptions);
2913 let is_hard = match element {
2914 Element::Bold { content, .. }
2915 | Element::Italic { content, .. }
2916 | Element::Strikethrough { content, .. } => content.contains(['[', '`', '<', '$', '{']),
2917 _ => true,
2918 };
2919 spans.push(ElementSpan::new(offset, len, full, width, is_hard));
2920 }
2921 offset += len;
2922 }
2923 spans
2924}
2925
2926fn measure(text: &str, offset: usize, spans: &[ElementSpan], mode: ReflowLengthMode) -> LineWidth {
2934 let full = display_len(text, mode);
2935 let end = offset + text.len();
2936 let mut width = LineWidth::plain(full);
2937 for span in spans.iter().filter(|span| span.within(offset, end)) {
2938 width.link_exempt -= span.link_saving;
2939 width.code_exempt -= span.code_saving;
2940 }
2941 width
2942}
2943
2944fn line_width_components(line: &str, options: &ReflowOptions) -> LineWidth {
2949 let raw = display_len(line, options.length_mode);
2950 if !options.length_exemptions.any() {
2951 return LineWidth::plain(raw);
2952 }
2953 let elements = parse_markdown_elements_inner(
2954 line,
2955 options.attr_lists,
2956 options.myst_roles,
2957 options.defined_references.as_ref(),
2958 );
2959 let spans = compute_element_spans(&elements, options.length_mode, options.length_exemptions);
2960 measure(line, 0, &spans, options.length_mode)
2961}
2962
2963fn line_width(line: &str, options: &ReflowOptions) -> usize {
2965 line_width_components(line, options).effective()
2966}
2967
2968fn line_fits(line: &str, options: &ReflowOptions) -> bool {
2974 display_len(line, options.length_mode) <= options.line_length || line_width(line, options) <= options.line_length
2975}
2976
2977fn element_containing(pos: usize, spans: &[ElementSpan]) -> Option<ElementSpan> {
2979 spans.iter().copied().find(|span| span.contains(pos))
2980}
2981
2982fn is_inside_element(pos: usize, spans: &[ElementSpan]) -> bool {
2984 element_containing(pos, spans).is_some()
2985}
2986
2987const MIN_SPLIT_RATIO: f64 = 0.3;
2990
2991fn split_at_clause_punctuation(
2995 text: &str,
2996 line_length: usize,
2997 element_spans: &[ElementSpan],
2998 length_mode: ReflowLengthMode,
2999) -> Option<(String, String)> {
3000 let chars: Vec<char> = text.chars().collect();
3001 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
3002
3003 let mut width_acc = LineWidth::default();
3009 let mut search_end_char = 0;
3010 let mut byte = 0usize;
3011 let mut idx = 0usize;
3012 while idx < chars.len() {
3013 let (advance_chars, advance_bytes, width) = match element_spans.iter().find(|s| s.start == byte) {
3014 Some(span) => {
3015 let source = &text[span.start..span.end];
3016 (
3017 source.chars().count(),
3018 source.len(),
3019 measure(source, span.start, element_spans, length_mode),
3020 )
3021 }
3022 None => {
3023 let c = chars[idx];
3024 (
3025 1,
3026 c.len_utf8(),
3027 LineWidth::plain(display_len(&c.to_string(), length_mode)),
3028 )
3029 }
3030 };
3031 if !(width_acc + width).fits(line_length) {
3032 break;
3033 }
3034 width_acc += width;
3035 byte += advance_bytes;
3036 idx += advance_chars;
3037 search_end_char = idx;
3038 }
3039
3040 let mut paren_depth: i32 = 0;
3047 let mut best_pos = None;
3048 for i in (0..search_end_char).rev() {
3049 let byte_start: usize = chars[..i].iter().map(|c| c.len_utf8()).sum();
3051 let byte_after: usize = byte_start + chars[i].len_utf8();
3053
3054 if !is_inside_element(byte_start, element_spans) {
3055 match chars[i] {
3056 ')' => paren_depth += 1,
3057 '(' => paren_depth = paren_depth.saturating_sub(1),
3058 _ => {}
3059 }
3060 }
3061
3062 if paren_depth == 0
3063 && is_clause_punctuation(chars[i])
3064 && clause_break_allowed_after(&chars, i)
3065 && !is_inside_element(byte_after, element_spans)
3066 {
3067 best_pos = Some(i);
3068 break;
3069 }
3070 }
3071
3072 let pos = best_pos?;
3073
3074 let first: String = chars[..=pos].iter().collect();
3076 if measure(&first, 0, element_spans, length_mode).effective() < min_first_len {
3077 return None;
3078 }
3079
3080 let rest: String = chars[pos + 1..].iter().collect();
3082 let rest = rest.trim_start().to_string();
3083
3084 if rest.is_empty() {
3085 return None;
3086 }
3087
3088 Some((first, rest))
3089}
3090
3091fn paren_depth_map(text: &str, element_spans: &[ElementSpan]) -> Vec<i32> {
3098 let mut map = vec![0i32; text.len()];
3099 let mut depth = 0i32;
3100 for (byte, c) in text.char_indices() {
3101 if !is_inside_element(byte, element_spans) {
3102 match c {
3103 '(' => depth += 1,
3104 ')' => depth = depth.saturating_sub(1),
3105 _ => {}
3106 }
3107 }
3108 let end = (byte + c.len_utf8()).min(map.len());
3110 for slot in &mut map[byte..end] {
3111 *slot = depth;
3112 }
3113 }
3114 map
3115}
3116
3117fn is_standalone_parenthetical(line: &str) -> bool {
3126 let trimmed = line.trim();
3127 if !trimmed.starts_with('(') {
3128 return false;
3129 }
3130 let Some(close) = trimmed.rfind(')') else {
3133 return false;
3134 };
3135 if trimmed[close + 1..].contains(char::is_whitespace) {
3136 return false;
3137 }
3138 let core = &trimmed[..=close];
3139 let inner = &core[1..core.len() - 1];
3141 if !inner.contains(' ') {
3142 return false;
3143 }
3144 let mut depth = 0i32;
3146 for c in core.chars() {
3147 match c {
3148 '(' => depth += 1,
3149 ')' => depth -= 1,
3150 _ => {}
3151 }
3152 if depth < 0 {
3153 return false;
3154 }
3155 }
3156 depth == 0
3157}
3158
3159fn split_at_break_word(
3163 text: &str,
3164 line_length: usize,
3165 element_spans: &[ElementSpan],
3166 length_mode: ReflowLengthMode,
3167) -> Option<(String, String)> {
3168 let lower = text.to_lowercase();
3169 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
3170 let mut best_split: Option<(usize, usize)> = None; let depth_map = paren_depth_map(text, element_spans);
3175
3176 for &word in BREAK_WORDS {
3177 let mut search_start = 0;
3178 while let Some(pos) = lower[search_start..].find(word) {
3179 let abs_pos = search_start + pos;
3180
3181 let preceded_by_space = abs_pos == 0 || text.as_bytes().get(abs_pos - 1) == Some(&b' ');
3183 let followed_by_space = text.as_bytes().get(abs_pos + word.len()) == Some(&b' ');
3184
3185 if preceded_by_space && followed_by_space {
3186 let first_part = text[..abs_pos].trim_end();
3188 let first_part_len = measure(first_part, 0, element_spans, length_mode).effective();
3189
3190 let inside_paren = depth_map.get(abs_pos).is_some_and(|&d| d > 0);
3192
3193 if first_part_len >= min_first_len
3194 && first_part_len <= line_length
3195 && !is_inside_element(abs_pos, element_spans)
3196 && !inside_paren
3197 {
3198 if best_split.is_none_or(|(prev_pos, _)| abs_pos > prev_pos) {
3200 best_split = Some((abs_pos, word.len()));
3201 }
3202 }
3203 }
3204
3205 search_start = abs_pos + word.len();
3206 }
3207 }
3208
3209 let (byte_start, _word_len) = best_split?;
3210
3211 let first = text[..byte_start].trim_end().to_string();
3212 let rest = text[byte_start..].to_string();
3213
3214 if first.is_empty() || rest.trim().is_empty() {
3215 return None;
3216 }
3217
3218 Some((first, rest))
3219}
3220
3221fn replaces_whitespace(text: &str, first: &str, rest: &str, element_spans: &[ElementSpan]) -> bool {
3232 if !text.starts_with(first) || !text.ends_with(rest) {
3233 return false;
3234 }
3235 let gap_end = text.len() - rest.len();
3236 gap_end > first.len()
3237 && text[first.len()..gap_end].chars().all(is_breakable_whitespace)
3238 && !element_spans
3239 .iter()
3240 .any(|span| first.len() < span.end && span.start < gap_end)
3241}
3242
3243fn cascade_split_line(text: &str, options: &ReflowOptions) -> Vec<String> {
3254 let line_length = options.line_length;
3255 let length_mode = options.length_mode;
3256 let attr_lists = options.attr_lists;
3257 let myst_roles = options.myst_roles;
3258 let defined_references = options.defined_references.as_ref();
3259 if line_length == 0 || display_len(text, length_mode) <= line_length {
3260 return vec![text.to_string()];
3261 }
3262
3263 let elements = parse_markdown_elements_inner(text, attr_lists, myst_roles, defined_references);
3264 let element_spans = compute_element_spans(&elements, length_mode, options.length_exemptions);
3265
3266 if measure(text, 0, &element_spans, length_mode).fits(line_length) {
3269 return vec![text.to_string()];
3270 }
3271
3272 let rebased_spans = |start: usize| -> Vec<ElementSpan> {
3276 if start == 0 {
3277 return element_spans.clone();
3278 }
3279 element_spans
3280 .iter()
3281 .filter(|span| span.end > start)
3282 .map(|span| ElementSpan {
3283 start: span.start.saturating_sub(start),
3284 end: span.end.saturating_sub(start),
3285 ..*span
3286 })
3287 .collect()
3288 };
3289
3290 let mut result = Vec::new();
3291 let mut start = 0usize;
3292
3293 loop {
3294 let remaining = &text[start..];
3295 let spans = rebased_spans(start);
3296 if measure(remaining, 0, &spans, length_mode).fits(line_length) {
3297 result.push(remaining.to_string());
3298 return result;
3299 }
3300
3301 let at_whitespace = |candidate: Option<(String, String)>| {
3310 candidate.filter(|(first, rest)| replaces_whitespace(remaining, first, rest, &spans))
3311 };
3312 let split = at_whitespace(split_at_parenthetical(remaining, line_length, &spans, length_mode))
3313 .or_else(|| at_whitespace(split_at_clause_punctuation(remaining, line_length, &spans, length_mode)))
3314 .or_else(|| at_whitespace(split_at_break_word(remaining, line_length, &spans, length_mode)));
3315
3316 if let Some((first, rest)) = split {
3317 let consumed = remaining.len().saturating_sub(rest.len());
3318 if consumed == 0 {
3321 break;
3322 }
3323 result.push(first);
3324 start += consumed;
3325 continue;
3326 }
3327
3328 break;
3330 }
3331
3332 let mut fallback_options = options.clone();
3334 fallback_options.break_on_sentences = false;
3335 fallback_options.preserve_breaks = false;
3336 fallback_options.sentence_per_line = false;
3337 fallback_options.semantic_line_breaks = false;
3338 fallback_options.require_sentence_capital = true;
3339 fallback_options.max_list_continuation_indent = None;
3340 fallback_options.defined_references = None;
3341 let remaining = &text[start..];
3342 let tail_elements = if start == 0 {
3343 elements
3344 } else {
3345 parse_markdown_elements_inner(remaining, attr_lists, myst_roles, defined_references)
3346 };
3347 result.extend(reflow_elements(&tail_elements, &fallback_options));
3348 result
3349}
3350
3351fn reflow_elements_semantic(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
3355 let sentence_lines = reflow_elements_sentence_per_line(elements, options);
3357
3358 if options.line_length == 0 {
3361 return sentence_lines;
3362 }
3363
3364 let mut result = Vec::new();
3365 for line in sentence_lines {
3366 if line_fits(&line, options) {
3367 result.push(line);
3368 } else {
3369 result.extend(cascade_split_line(&line, options));
3370 }
3371 }
3372
3373 let min_line_len = ((options.line_length as f64) * MIN_SPLIT_RATIO) as usize;
3376 let mut merged: Vec<String> = Vec::with_capacity(result.len());
3377 for line in result {
3378 if !merged.is_empty() && line_width(&line, options) < min_line_len && !line.trim().is_empty() {
3379 if is_standalone_parenthetical(&line) {
3382 merged.push(line);
3383 continue;
3384 }
3385
3386 let prev_ends_at_sentence = {
3388 let trimmed = merged.last().unwrap().trim_end();
3389 trimmed
3390 .chars()
3391 .rev()
3392 .find(|c| !matches!(c, '"' | '\'' | '\u{201D}' | '\u{2019}' | ')' | ']'))
3393 .is_some_and(|c| matches!(c, '.' | '!' | '?'))
3394 };
3395
3396 if !prev_ends_at_sentence {
3397 let prev = merged.last_mut().unwrap();
3398 let combined = format!("{prev} {line}");
3399 if line_fits(&combined, options) {
3401 *prev = combined;
3402 continue;
3403 }
3404 }
3405 }
3406 merged.push(line);
3407 }
3408 merged
3409}
3410
3411fn rfind_safe_space(
3421 line: &str,
3422 element_spans: &[ElementSpan],
3423 options: &ReflowOptions,
3424 relax_soft_spans: bool,
3425) -> Option<usize> {
3426 line.char_indices().rev().map(|(pos, _)| pos).find(|&pos| {
3427 line.as_bytes()[pos] == b' '
3428 && !is_inside_element_filtered(pos, element_spans, options, relax_soft_spans)
3429 && !starts_block_construct(&line[pos + 1..])
3430 })
3431}
3432
3433fn is_inside_element_filtered(
3434 pos: usize,
3435 spans: &[ElementSpan],
3436 options: &ReflowOptions,
3437 relax_soft_spans: bool,
3438) -> bool {
3439 spans.iter().any(|span| {
3440 span.contains(pos)
3441 && (!relax_soft_spans
3442 || span.is_hard
3443 || (options.atomic_spans && span.exempt_width().fits(options.line_length)))
3444 })
3445}
3446
3447#[derive(Clone, Copy)]
3452struct Attached<'a> {
3453 text: &'a str,
3454 width: LineWidth,
3455 separator: &'a str,
3456}
3457
3458fn break_before_attached(
3475 lines: &mut Vec<String>,
3476 current_line: &mut String,
3477 current_width: &mut LineWidth,
3478 element_spans: &mut Vec<ElementSpan>,
3479 attach: Attached<'_>,
3480 options: &ReflowOptions,
3481) -> Option<usize> {
3482 let length_mode = options.length_mode;
3483 let last_space = rfind_safe_space(current_line, element_spans, options, false)
3484 .or_else(|| rfind_safe_space(current_line, element_spans, options, true))?;
3485 let before = current_line[..last_space]
3486 .trim_end_matches(is_breakable_whitespace)
3487 .to_string();
3488 let after = current_line[last_space + 1..].to_string();
3489 let after_width = measure(&after, last_space + 1, element_spans, length_mode);
3490 lines.push(before);
3491 let carried = after.len();
3492 let Attached { text, width, separator } = attach;
3493 *current_line = format!("{after}{separator}{text}");
3494 *current_width = after_width + LineWidth::plain(display_len(separator, length_mode)) + width;
3495 rebase_spans_after_break(element_spans, last_space + 1);
3496 Some(carried)
3497}
3498
3499fn rebase_spans_after_break(element_spans: &mut Vec<ElementSpan>, carried_start: usize) {
3508 element_spans.retain(|span| span.end > carried_start);
3509 for span in element_spans.iter_mut() {
3510 span.start = span.start.saturating_sub(carried_start);
3511 span.end -= carried_start;
3512 }
3513}
3514
3515fn reflow_elements(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
3517 let mut lines = Vec::new();
3518 let mut current_line = String::new();
3519 let mut current_width = LineWidth::default();
3522 let mut current_line_element_spans: Vec<ElementSpan> = Vec::new();
3524 let length_mode = options.length_mode;
3525 let exemptions = options.length_exemptions;
3526
3527 for (idx, element) in elements.iter().enumerate() {
3528 let element_len = element.display_len(length_mode);
3529 let element_width = element.exempt_width(length_mode, exemptions);
3530 let is_hard = match element {
3531 Element::Bold { content, .. }
3532 | Element::Italic { content, .. }
3533 | Element::Strikethrough { content, .. } => content.contains(['[', '`', '<', '$', '{']),
3534 _ => true,
3535 };
3536
3537 let is_adjacent_to_prev = if idx > 0 {
3546 match (&elements[idx - 1], element) {
3547 (Element::Text(t), _) => !t.is_empty() && !t.ends_with(is_breakable_whitespace),
3548 (_, Element::Text(t)) => !t.is_empty() && !t.starts_with(is_breakable_whitespace),
3549 _ => true,
3550 }
3551 } else {
3552 false
3553 };
3554
3555 if let Element::Text(text) = element {
3557 let has_leading_space = text.starts_with(is_breakable_whitespace);
3559 let words: Vec<&str> = split_breakable_words(text).collect();
3561
3562 for (i, word) in words.iter().enumerate() {
3563 let word_width = LineWidth::plain(display_len(word, length_mode));
3565 let is_trailing_punct = word.chars().all(|c| {
3571 matches!(c, ',' | '.' | ':' | ';' | '!' | '?' | ')' | ']' | '}') || is_non_breaking_space(c)
3572 });
3573
3574 let is_first_adjacent = i == 0 && is_adjacent_to_prev;
3577
3578 if is_first_adjacent {
3579 if !(current_width + word_width).fits(options.line_length)
3581 && !current_width.is_empty()
3582 && break_before_attached(
3583 &mut lines,
3584 &mut current_line,
3585 &mut current_width,
3586 &mut current_line_element_spans,
3587 Attached {
3588 text: word,
3589 width: word_width,
3590 separator: "",
3591 },
3592 options,
3593 )
3594 .is_some()
3595 {
3596 } else {
3601 current_line.push_str(word);
3602 current_width += word_width;
3603 }
3604 } else if !current_width.is_empty()
3605 && !(current_width + LineWidth::plain(1) + word_width).fits(options.line_length)
3606 {
3607 if is_trailing_punct {
3608 if break_before_attached(
3615 &mut lines,
3616 &mut current_line,
3617 &mut current_width,
3618 &mut current_line_element_spans,
3619 Attached {
3620 text: word,
3621 width: word_width,
3622 separator: " ",
3623 },
3624 options,
3625 )
3626 .is_none()
3627 {
3628 current_line.push(' ');
3629 current_line.push_str(word);
3630 current_width += LineWidth::plain(1) + word_width;
3631 }
3632 } else if !starts_block_construct(word) {
3633 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
3635 current_line = word.to_string();
3636 current_width = word_width;
3637 current_line_element_spans.clear();
3638 } else if break_before_attached(
3639 &mut lines,
3640 &mut current_line,
3641 &mut current_width,
3642 &mut current_line_element_spans,
3643 Attached {
3644 text: word,
3645 width: word_width,
3646 separator: " ",
3647 },
3648 options,
3649 )
3650 .is_some()
3651 {
3652 } else {
3657 if i > 0 || has_leading_space {
3660 current_line.push(' ');
3661 current_width += LineWidth::plain(1);
3662 }
3663 current_line.push_str(word);
3664 current_width += word_width;
3665 }
3666 } else {
3667 let add_space = !current_width.is_empty() && (i > 0 || has_leading_space);
3679 if add_space {
3680 current_line.push(' ');
3681 current_width += LineWidth::plain(1);
3682 }
3683 current_line.push_str(word);
3684 current_width += word_width;
3685 }
3686 }
3687 } else {
3688 let span_info = match element {
3689 Element::Italic { content, underscore } => {
3690 Some((content.as_str(), if *underscore { "_" } else { "*" }, false))
3691 }
3692 Element::Bold { content, underscore } => {
3693 Some((content.as_str(), if *underscore { "__" } else { "**" }, false))
3694 }
3695 Element::Strikethrough { content, double } => {
3696 Some((content.as_str(), if *double { "~~" } else { "~" }, false))
3697 }
3698 Element::Code { content, marker } => Some((content.as_str(), marker.as_str(), true)),
3699 _ => None,
3700 };
3701
3702 let breakable: Option<Vec<&str>> = match span_info {
3706 Some((content, _, is_code)) => {
3707 if is_code {
3708 (!options.atomic_spans && code_span_wraps_losslessly(content))
3709 .then(|| split_breakable_words(content).collect())
3710 } else {
3711 (!options.atomic_spans || element_len > options.line_length)
3712 .then(|| breakable_units(content, options.defined_references.as_ref(), options.attr_lists))
3713 .flatten()
3714 }
3715 }
3716 None => None,
3717 };
3718
3719 if let Some(words) = breakable {
3720 let (_, marker, is_code) = span_info.expect("breakable implies a span");
3721 let n = words.len();
3722 if n == 0 {
3723 let full = format!("{marker}{marker}");
3725 let full_width = LineWidth::plain(display_len(&full, length_mode));
3726 if !is_adjacent_to_prev && !current_width.is_empty() {
3727 current_line.push(' ');
3728 current_width += LineWidth::plain(1);
3729 }
3730 current_line.push_str(&full);
3731 current_width += full_width;
3732 } else {
3733 for (i, word) in words.iter().enumerate() {
3734 let is_first = i == 0;
3735 let is_last = i == n - 1;
3736
3737 let space_start = if is_first && is_code && word.starts_with('`') {
3738 " "
3739 } else {
3740 ""
3741 };
3742 let space_end = if is_last && is_code && word.ends_with('`') {
3743 " "
3744 } else {
3745 ""
3746 };
3747
3748 let word_str: String = match (is_first, is_last) {
3749 (true, true) => format!("{marker}{space_start}{word}{space_end}{marker}"),
3750 (true, false) => format!("{marker}{space_start}{word}"),
3751 (false, true) => format!("{word}{space_end}{marker}"),
3752 (false, false) => word.to_string(),
3753 };
3754 let word_elements = parse_elements(&word_str, options);
3755 let word_spans = compute_element_spans(&word_elements, length_mode, exemptions);
3756 let word_width = measure(&word_str, 0, &word_spans, length_mode);
3757
3758 let needs_space = if is_first {
3759 !is_adjacent_to_prev && !current_width.is_empty()
3760 } else {
3761 !current_width.is_empty()
3762 };
3763
3764 if needs_space
3765 && !(current_width + LineWidth::plain(1) + word_width).fits(options.line_length)
3766 && !starts_block_construct(&word_str)
3767 {
3768 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
3769 current_line = word_str;
3770 current_width = word_width;
3771 current_line_element_spans.clear();
3772 for span in word_spans {
3773 current_line_element_spans.push(span);
3774 }
3775 } else {
3776 let mut start_pos = current_line.len();
3777 if needs_space {
3778 current_line.push(' ');
3779 current_width += LineWidth::plain(1);
3780 start_pos += 1;
3781 }
3782 current_line.push_str(&word_str);
3783 current_width += word_width;
3784 for mut span in word_spans {
3785 span.start += start_pos;
3786 span.end += start_pos;
3787 current_line_element_spans.push(span);
3788 }
3789 }
3790 }
3791 }
3792 } else {
3793 let element_str = format!("{element}");
3796
3797 if is_adjacent_to_prev {
3798 if !(current_width + element_width).fits(options.line_length)
3800 && let Some(carried) = break_before_attached(
3801 &mut lines,
3802 &mut current_line,
3803 &mut current_width,
3804 &mut current_line_element_spans,
3805 Attached {
3806 text: &element_str,
3807 width: element_width,
3808 separator: "",
3809 },
3810 options,
3811 )
3812 {
3813 current_line_element_spans.push(ElementSpan::new(
3817 carried,
3818 element_str.len(),
3819 element_len,
3820 element_width,
3821 is_hard,
3822 ));
3823 } else {
3824 let start = current_line.len();
3825 current_line.push_str(&element_str);
3826 current_width += element_width;
3827 current_line_element_spans.push(ElementSpan::new(
3828 start,
3829 element_str.len(),
3830 element_len,
3831 element_width,
3832 is_hard,
3833 ));
3834 }
3835 } else if !current_width.is_empty()
3836 && !(current_width + LineWidth::plain(1) + element_width).fits(options.line_length)
3837 {
3838 if !starts_block_construct(&element_str) {
3839 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
3841 current_line.clone_from(&element_str);
3842 current_width = element_width;
3843 current_line_element_spans.clear();
3844 current_line_element_spans.push(ElementSpan::new(
3845 0,
3846 element_str.len(),
3847 element_len,
3848 element_width,
3849 is_hard,
3850 ));
3851 } else if let Some(carried) = break_before_attached(
3852 &mut lines,
3853 &mut current_line,
3854 &mut current_width,
3855 &mut current_line_element_spans,
3856 Attached {
3857 text: &element_str,
3858 width: element_width,
3859 separator: " ",
3860 },
3861 options,
3862 ) {
3863 let start = carried + 1;
3867 current_line_element_spans.push(ElementSpan::new(
3868 start,
3869 element_str.len(),
3870 element_len,
3871 element_width,
3872 is_hard,
3873 ));
3874 } else {
3875 let ends_with_opener =
3878 current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
3879 if !ends_with_opener {
3880 current_line.push(' ');
3881 current_width += LineWidth::plain(1);
3882 }
3883 let start = current_line.len();
3884 current_line.push_str(&element_str);
3885 current_width += element_width;
3886 current_line_element_spans.push(ElementSpan::new(
3887 start,
3888 element_str.len(),
3889 element_len,
3890 element_width,
3891 is_hard,
3892 ));
3893 }
3894 } else {
3895 let ends_with_opener =
3897 current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
3898 if !current_width.is_empty() && !ends_with_opener {
3899 current_line.push(' ');
3900 current_width += LineWidth::plain(1);
3901 }
3902 let start = current_line.len();
3903 current_line.push_str(&element_str);
3904 current_width += element_width;
3905 current_line_element_spans.push(ElementSpan::new(
3906 start,
3907 element_str.len(),
3908 element_len,
3909 element_width,
3910 is_hard,
3911 ));
3912 }
3913 }
3914 }
3915 }
3916
3917 if !current_line.is_empty() {
3919 lines.push(current_line.trim_end_matches(is_breakable_whitespace).to_string());
3920 }
3921
3922 lines
3923}
3924
3925pub fn reflow_markdown(content: &str, options: &ReflowOptions) -> String {
3927 let lines: Vec<&str> = content.lines().collect();
3928 let mut result = Vec::new();
3929 let mut i = 0;
3930
3931 while i < lines.len() {
3932 let line = lines[i];
3933 let trimmed = line.trim();
3934
3935 if trimmed.is_empty() {
3937 result.push(String::new());
3938 i += 1;
3939 continue;
3940 }
3941
3942 if trimmed.starts_with('#') {
3944 result.push(line.to_string());
3945 i += 1;
3946 continue;
3947 }
3948
3949 if trimmed.starts_with(":::") {
3951 result.push(line.to_string());
3952 i += 1;
3953 continue;
3954 }
3955
3956 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
3958 result.push(line.to_string());
3959 i += 1;
3960 while i < lines.len() {
3962 result.push(lines[i].to_string());
3963 if lines[i].trim().starts_with("```") || lines[i].trim().starts_with("~~~") {
3964 i += 1;
3965 break;
3966 }
3967 i += 1;
3968 }
3969 continue;
3970 }
3971
3972 if calculate_indentation_width_default(line) >= 4 {
3974 result.push(line.to_string());
3976 i += 1;
3977 while i < lines.len() {
3978 let next_line = lines[i];
3979 if calculate_indentation_width_default(next_line) >= 4 || next_line.trim().is_empty() {
3981 result.push(next_line.to_string());
3982 i += 1;
3983 } else {
3984 break;
3985 }
3986 }
3987 continue;
3988 }
3989
3990 if trimmed.starts_with('>') {
3992 let gt_pos = line.find('>').expect("'>' must exist since trimmed.starts_with('>')");
3995 let quote_prefix = line[0..=gt_pos].to_string();
3996 let quote_content = &line[quote_prefix.len()..].trim_start();
3997
3998 let reflowed = reflow_line(quote_content, options);
3999 for reflowed_line in &reflowed {
4000 result.push(format!("{quote_prefix} {reflowed_line}"));
4001 }
4002 i += 1;
4003 continue;
4004 }
4005
4006 if is_horizontal_rule(trimmed) {
4008 result.push(line.to_string());
4009 i += 1;
4010 continue;
4011 }
4012
4013 if is_unordered_list_marker(trimmed) || is_numbered_list_item(trimmed) {
4015 let indent = line.len() - line.trim_start().len();
4017 let indent_str = " ".repeat(indent);
4018
4019 let mut marker_end = indent;
4022 let mut content_start = indent;
4023
4024 if trimmed.chars().next().is_some_and(char::is_numeric) {
4025 if let Some(period_pos) = line[indent..].find('.') {
4027 marker_end = indent + period_pos + 1; content_start = marker_end;
4029 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
4033 content_start += 1;
4034 }
4035 }
4036 } else {
4037 marker_end = indent + 1; content_start = marker_end;
4040 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
4044 content_start += 1;
4045 }
4046 }
4047
4048 let min_continuation_indent = content_start;
4050
4051 let rest = &line[content_start..];
4054 if rest.starts_with("[ ] ") || rest.starts_with("[x] ") || rest.starts_with("[X] ") {
4055 marker_end = content_start + 3; content_start += 4; }
4058
4059 let marker = &line[indent..marker_end];
4060
4061 let mut list_content = vec![trim_preserving_hard_break(&line[content_start..])];
4064 i += 1;
4065
4066 while i < lines.len() {
4070 let next_line = lines[i];
4071 let next_trimmed = next_line.trim();
4072
4073 if is_block_boundary(next_trimmed) {
4075 break;
4076 }
4077
4078 let next_indent = next_line.len() - next_line.trim_start().len();
4080 if next_indent >= min_continuation_indent {
4081 let trimmed_start = next_line.trim_start();
4084 list_content.push(trim_preserving_hard_break(trimmed_start));
4085 i += 1;
4086 } else {
4087 break;
4089 }
4090 }
4091
4092 let combined_content = if options.preserve_breaks {
4095 list_content[0].clone()
4096 } else {
4097 let has_hard_breaks = list_content.iter().any(|line| has_hard_break(line));
4099 if has_hard_breaks {
4100 list_content.join("\n")
4102 } else {
4103 list_content.join(" ")
4105 }
4106 };
4107
4108 let trimmed_marker = marker;
4110 let continuation_spaces = if let Some(max_indent) = options.max_list_continuation_indent {
4111 indent + (content_start - indent).min(max_indent)
4114 } else {
4115 content_start
4116 };
4117
4118 let prefix_length = indent + trimmed_marker.len() + 1;
4120
4121 let adjusted_options = ReflowOptions {
4123 line_length: options.line_length.saturating_sub(prefix_length),
4124 ..options.clone()
4125 };
4126
4127 let reflowed = reflow_line(&combined_content, &adjusted_options);
4128 for (j, reflowed_line) in reflowed.iter().enumerate() {
4129 if j == 0 {
4130 result.push(format!("{indent_str}{trimmed_marker} {reflowed_line}"));
4131 } else {
4132 let continuation_indent = " ".repeat(continuation_spaces);
4134 result.push(format!("{continuation_indent}{reflowed_line}"));
4135 }
4136 }
4137 continue;
4138 }
4139
4140 if crate::utils::table_utils::TableUtils::is_potential_table_row(line) {
4142 result.push(line.to_string());
4143 i += 1;
4144 continue;
4145 }
4146
4147 if trimmed.starts_with('[') && line.contains("]:") {
4149 result.push(line.to_string());
4150 i += 1;
4151 continue;
4152 }
4153
4154 if is_definition_list_item(trimmed) {
4156 result.push(line.to_string());
4157 i += 1;
4158 continue;
4159 }
4160
4161 let mut is_single_line_paragraph = true;
4163 if i + 1 < lines.len() {
4164 let next_trimmed = lines[i + 1].trim();
4165 if !is_block_boundary(next_trimmed) {
4167 is_single_line_paragraph = false;
4168 }
4169 }
4170
4171 if is_single_line_paragraph && line_fits(line, options) {
4173 result.push(line.to_string());
4174 i += 1;
4175 continue;
4176 }
4177
4178 let mut paragraph_parts = Vec::new();
4180 let mut current_part = vec![line];
4181 i += 1;
4182
4183 if options.preserve_breaks {
4185 let hard_break_type = if line.strip_suffix('\r').unwrap_or(line).ends_with('\\') {
4187 Some("\\")
4188 } else if line.ends_with(" ") {
4189 Some(" ")
4190 } else {
4191 None
4192 };
4193 let reflowed = reflow_line(line, options);
4194
4195 if let Some(break_marker) = hard_break_type {
4197 if !reflowed.is_empty() {
4198 let mut reflowed_with_break = reflowed;
4199 let last_idx = reflowed_with_break.len() - 1;
4200 if !has_hard_break(&reflowed_with_break[last_idx]) {
4201 reflowed_with_break[last_idx].push_str(break_marker);
4202 }
4203 result.extend(reflowed_with_break);
4204 }
4205 } else {
4206 result.extend(reflowed);
4207 }
4208 } else {
4209 while i < lines.len() {
4211 let prev_line = if !current_part.is_empty() {
4212 current_part.last().unwrap()
4213 } else {
4214 ""
4215 };
4216 let next_line = lines[i];
4217 let next_trimmed = next_line.trim();
4218
4219 if is_block_boundary(next_trimmed) {
4221 break;
4222 }
4223
4224 let prev_trimmed = prev_line.trim();
4227 let abbreviations = get_abbreviations(&options.abbreviations);
4228 let ends_with_sentence = (prev_trimmed.ends_with('.')
4229 || 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("!_")
4236 || prev_trimmed.ends_with("?_")
4237 || prev_trimmed.ends_with(".\"")
4239 || prev_trimmed.ends_with("!\"")
4240 || prev_trimmed.ends_with("?\"")
4241 || prev_trimmed.ends_with(".'")
4242 || prev_trimmed.ends_with("!'")
4243 || prev_trimmed.ends_with("?'")
4244 || prev_trimmed.ends_with(".\u{201D}")
4245 || prev_trimmed.ends_with("!\u{201D}")
4246 || prev_trimmed.ends_with("?\u{201D}")
4247 || prev_trimmed.ends_with(".\u{2019}")
4248 || prev_trimmed.ends_with("!\u{2019}")
4249 || prev_trimmed.ends_with("?\u{2019}"))
4250 && !text_ends_with_abbreviation(
4251 prev_trimmed.trim_end_matches(['*', '_', '"', '\'', '\u{201D}', '\u{2019}']),
4252 &abbreviations,
4253 );
4254
4255 if has_hard_break(prev_line) || (options.sentence_per_line && ends_with_sentence) {
4256 paragraph_parts.push(current_part.join(" "));
4258 current_part = vec![next_line];
4259 } else {
4260 current_part.push(next_line);
4261 }
4262 i += 1;
4263 }
4264
4265 if !current_part.is_empty() {
4267 if current_part.len() == 1 {
4268 paragraph_parts.push(current_part[0].to_string());
4270 } else {
4271 paragraph_parts.push(current_part.join(" "));
4272 }
4273 }
4274
4275 for (j, part) in paragraph_parts.iter().enumerate() {
4277 let reflowed = reflow_line(part, options);
4278 result.extend(reflowed);
4279
4280 if j < paragraph_parts.len() - 1 && !result.is_empty() && !options.sentence_per_line {
4284 let last_idx = result.len() - 1;
4285 if !has_hard_break(&result[last_idx]) {
4286 result[last_idx].push_str(" ");
4287 }
4288 }
4289 }
4290 }
4291 }
4292
4293 let result_text = result.join("\n");
4295 if content.ends_with('\n') && !result_text.ends_with('\n') {
4296 format!("{result_text}\n")
4297 } else {
4298 result_text
4299 }
4300}
4301
4302#[derive(Debug, Clone)]
4304pub struct ParagraphReflow {
4305 pub start_byte: usize,
4307 pub end_byte: usize,
4309 pub reflowed_text: String,
4311}
4312
4313#[derive(Debug, Clone)]
4319pub struct BlockquoteLineData {
4320 pub(crate) content: String,
4322 pub(crate) is_explicit: bool,
4324 pub(crate) prefix: Option<String>,
4326}
4327
4328impl BlockquoteLineData {
4329 pub fn explicit(content: String, prefix: String) -> Self {
4331 Self {
4332 content,
4333 is_explicit: true,
4334 prefix: Some(prefix),
4335 }
4336 }
4337
4338 pub fn lazy(content: String) -> Self {
4340 Self {
4341 content,
4342 is_explicit: false,
4343 prefix: None,
4344 }
4345 }
4346}
4347
4348#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4350pub enum BlockquoteContinuationStyle {
4351 Explicit,
4352 Lazy,
4353}
4354
4355pub fn blockquote_continuation_style(lines: &[BlockquoteLineData]) -> BlockquoteContinuationStyle {
4363 let mut explicit_count = 0usize;
4364 let mut lazy_count = 0usize;
4365
4366 for line in lines.iter().skip(1) {
4367 if line.is_explicit {
4368 explicit_count += 1;
4369 } else {
4370 lazy_count += 1;
4371 }
4372 }
4373
4374 if explicit_count > 0 && lazy_count == 0 {
4375 BlockquoteContinuationStyle::Explicit
4376 } else if lazy_count > 0 && explicit_count == 0 {
4377 BlockquoteContinuationStyle::Lazy
4378 } else if explicit_count >= lazy_count {
4379 BlockquoteContinuationStyle::Explicit
4380 } else {
4381 BlockquoteContinuationStyle::Lazy
4382 }
4383}
4384
4385pub fn dominant_blockquote_prefix(lines: &[BlockquoteLineData], fallback: &str) -> String {
4390 let mut counts: std::collections::HashMap<String, (usize, usize)> = std::collections::HashMap::new();
4391
4392 for (idx, line) in lines.iter().enumerate() {
4393 let Some(prefix) = line.prefix.as_ref() else {
4394 continue;
4395 };
4396 counts
4397 .entry(prefix.clone())
4398 .and_modify(|entry| entry.0 += 1)
4399 .or_insert((1, idx));
4400 }
4401
4402 counts
4403 .into_iter()
4404 .max_by(|(_, (count_a, first_idx_a)), (_, (count_b, first_idx_b))| {
4405 count_a.cmp(count_b).then_with(|| first_idx_b.cmp(first_idx_a))
4406 })
4407 .map_or_else(|| fallback.to_string(), |(prefix, _)| prefix)
4408}
4409
4410pub(crate) fn should_force_explicit_blockquote_line(content_line: &str) -> bool {
4415 let trimmed = content_line.trim_start();
4416 trimmed.starts_with('>')
4417 || trimmed.starts_with('#')
4418 || trimmed.starts_with("```")
4419 || trimmed.starts_with("~~~")
4420 || is_unordered_list_marker(trimmed)
4421 || is_numbered_list_item(trimmed)
4422 || is_horizontal_rule(trimmed)
4423 || is_definition_list_item(trimmed)
4424 || (trimmed.starts_with('[') && trimmed.contains("]:"))
4425 || trimmed.starts_with(":::")
4426 || (trimmed.starts_with('<')
4427 && !trimmed.starts_with("<http")
4428 && !trimmed.starts_with("<https")
4429 && !trimmed.starts_with("<mailto:"))
4430}
4431
4432pub fn reflow_blockquote_content(
4441 lines: &[BlockquoteLineData],
4442 explicit_prefix: &str,
4443 continuation_style: BlockquoteContinuationStyle,
4444 options: &ReflowOptions,
4445) -> Vec<String> {
4446 let content_strs: Vec<&str> = lines.iter().map(|l| l.content.as_str()).collect();
4447 let segments = split_into_segments_strs(&content_strs);
4448 let mut reflowed_content_lines: Vec<String> = Vec::new();
4449
4450 for segment in segments {
4451 let hard_break_type = segment.last().and_then(|&line| {
4452 let line = line.strip_suffix('\r').unwrap_or(line);
4453 if line.ends_with('\\') {
4454 Some("\\")
4455 } else if line.ends_with(" ") {
4456 Some(" ")
4457 } else {
4458 None
4459 }
4460 });
4461
4462 let pieces: Vec<&str> = segment
4463 .iter()
4464 .map(|&line| {
4465 if let Some(l) = line.strip_suffix('\\') {
4466 l.trim_end()
4467 } else if let Some(l) = line.strip_suffix(" ") {
4468 l.trim_end()
4469 } else {
4470 line.trim_end()
4471 }
4472 })
4473 .collect();
4474
4475 let segment_text = pieces.join(" ");
4476 let segment_text = segment_text.trim();
4477 if segment_text.is_empty() {
4478 continue;
4479 }
4480
4481 let mut reflowed = reflow_line(segment_text, options);
4482 if let Some(break_marker) = hard_break_type
4483 && !reflowed.is_empty()
4484 {
4485 let last_idx = reflowed.len() - 1;
4486 if !has_hard_break(&reflowed[last_idx]) {
4487 reflowed[last_idx].push_str(break_marker);
4488 }
4489 }
4490 reflowed_content_lines.extend(reflowed);
4491 }
4492
4493 let mut styled_lines: Vec<String> = Vec::new();
4494 for (idx, line) in reflowed_content_lines.iter().enumerate() {
4495 let force_explicit = idx == 0
4496 || continuation_style == BlockquoteContinuationStyle::Explicit
4497 || should_force_explicit_blockquote_line(line);
4498 if force_explicit {
4499 styled_lines.push(format!("{explicit_prefix}{line}"));
4500 } else {
4501 styled_lines.push(line.clone());
4502 }
4503 }
4504
4505 styled_lines
4506}
4507
4508fn is_blockquote_content_boundary(content: &str) -> bool {
4509 let trimmed = content.trim();
4510 trimmed.is_empty()
4511 || is_block_boundary(trimmed)
4512 || crate::utils::table_utils::TableUtils::is_potential_table_row(content)
4513 || trimmed.starts_with(":::")
4514 || crate::utils::is_template_directive_only(content)
4515 || is_standalone_attr_list(content)
4516 || is_snippet_block_delimiter(content)
4517}
4518
4519fn split_into_segments_strs<'a>(lines: &[&'a str]) -> Vec<Vec<&'a str>> {
4520 let mut segments = Vec::new();
4521 let mut current = Vec::new();
4522
4523 for &line in lines {
4524 current.push(line);
4525 if has_hard_break(line) {
4526 segments.push(current);
4527 current = Vec::new();
4528 }
4529 }
4530
4531 if !current.is_empty() {
4532 segments.push(current);
4533 }
4534
4535 segments
4536}
4537
4538fn reflow_blockquote_paragraph_at_line(
4539 content: &str,
4540 lines: &[&str],
4541 target_idx: usize,
4542 options: &ReflowOptions,
4543) -> Option<ParagraphReflow> {
4544 let mut anchor_idx = target_idx;
4545 let mut target_level = if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[target_idx]) {
4546 parsed.nesting_level
4547 } else {
4548 let mut found = None;
4549 let mut idx = target_idx;
4550 loop {
4551 if lines[idx].trim().is_empty() {
4552 break;
4553 }
4554 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[idx]) {
4555 found = Some((idx, parsed.nesting_level));
4556 break;
4557 }
4558 if idx == 0 {
4559 break;
4560 }
4561 idx -= 1;
4562 }
4563 let (idx, level) = found?;
4564 anchor_idx = idx;
4565 level
4566 };
4567
4568 let mut para_start = anchor_idx;
4570 while para_start > 0 {
4571 let prev_idx = para_start - 1;
4572 let prev_line = lines[prev_idx];
4573
4574 if prev_line.trim().is_empty() {
4575 break;
4576 }
4577
4578 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(prev_line) {
4579 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
4580 break;
4581 }
4582 para_start = prev_idx;
4583 continue;
4584 }
4585
4586 let prev_lazy = prev_line.trim_start();
4587 if is_blockquote_content_boundary(prev_lazy) {
4588 break;
4589 }
4590 para_start = prev_idx;
4591 }
4592
4593 while para_start < lines.len() {
4595 let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[para_start]) else {
4596 para_start += 1;
4597 continue;
4598 };
4599 target_level = parsed.nesting_level;
4600 break;
4601 }
4602
4603 if para_start >= lines.len() || para_start > target_idx {
4604 return None;
4605 }
4606
4607 let mut collected: Vec<(usize, BlockquoteLineData)> = Vec::new();
4610 let mut idx = para_start;
4611 while idx < lines.len() {
4612 if !collected.is_empty() && has_hard_break(&collected[collected.len() - 1].1.content) {
4613 break;
4614 }
4615
4616 let line = lines[idx];
4617 if line.trim().is_empty() {
4618 break;
4619 }
4620
4621 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(line) {
4622 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
4623 break;
4624 }
4625 collected.push((
4626 idx,
4627 BlockquoteLineData::explicit(trim_preserving_hard_break(parsed.content), parsed.prefix.to_string()),
4628 ));
4629 idx += 1;
4630 continue;
4631 }
4632
4633 let lazy_content = line.trim_start();
4634 if is_blockquote_content_boundary(lazy_content) {
4635 break;
4636 }
4637
4638 collected.push((idx, BlockquoteLineData::lazy(trim_preserving_hard_break(lazy_content))));
4639 idx += 1;
4640 }
4641
4642 if collected.is_empty() {
4643 return None;
4644 }
4645
4646 let para_end = collected[collected.len() - 1].0;
4647 if target_idx < para_start || target_idx > para_end {
4648 return None;
4649 }
4650
4651 let line_data: Vec<BlockquoteLineData> = collected.iter().map(|(_, d)| d.clone()).collect();
4652
4653 let fallback_prefix = line_data
4654 .iter()
4655 .find_map(|d| d.prefix.clone())
4656 .unwrap_or_else(|| "> ".to_string());
4657 let explicit_prefix = dominant_blockquote_prefix(&line_data, &fallback_prefix);
4658 let continuation_style = blockquote_continuation_style(&line_data);
4659
4660 let adjusted_line_length = options
4661 .line_length
4662 .saturating_sub(display_len(&explicit_prefix, options.length_mode))
4663 .max(1);
4664
4665 let adjusted_options = ReflowOptions {
4666 line_length: adjusted_line_length,
4667 ..options.clone()
4668 };
4669
4670 let styled_lines = reflow_blockquote_content(&line_data, &explicit_prefix, continuation_style, &adjusted_options);
4671
4672 if styled_lines.is_empty() {
4673 return None;
4674 }
4675
4676 let mut start_byte = 0;
4678 for line in lines.iter().take(para_start) {
4679 start_byte += line.len() + 1;
4680 }
4681
4682 let mut end_byte = start_byte;
4683 for line in lines.iter().take(para_end + 1).skip(para_start) {
4684 end_byte += line.len() + 1;
4685 }
4686
4687 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
4688 if !includes_trailing_newline {
4689 end_byte -= 1;
4690 }
4691
4692 let reflowed_joined = styled_lines.join("\n");
4693 let reflowed_text = if includes_trailing_newline {
4694 if reflowed_joined.ends_with('\n') {
4695 reflowed_joined
4696 } else {
4697 format!("{reflowed_joined}\n")
4698 }
4699 } else if reflowed_joined.ends_with('\n') {
4700 reflowed_joined.trim_end_matches('\n').to_string()
4701 } else {
4702 reflowed_joined
4703 };
4704
4705 Some(ParagraphReflow {
4706 start_byte,
4707 end_byte,
4708 reflowed_text,
4709 })
4710}
4711
4712pub fn reflow_paragraph_at_line(content: &str, line_number: usize, line_length: usize) -> Option<ParagraphReflow> {
4730 reflow_paragraph_at_line_with_mode(content, line_number, line_length, ReflowLengthMode::default())
4731}
4732
4733pub fn reflow_paragraph_at_line_with_mode(
4735 content: &str,
4736 line_number: usize,
4737 line_length: usize,
4738 length_mode: ReflowLengthMode,
4739) -> Option<ParagraphReflow> {
4740 let options = ReflowOptions {
4741 line_length,
4742 length_mode,
4743 ..Default::default()
4744 };
4745 reflow_paragraph_at_line_with_options(content, line_number, &options)
4746}
4747
4748pub fn reflow_paragraph_at_line_with_options(
4759 content: &str,
4760 line_number: usize,
4761 options: &ReflowOptions,
4762) -> Option<ParagraphReflow> {
4763 if line_number == 0 {
4764 return None;
4765 }
4766
4767 let lines: Vec<&str> = content.lines().collect();
4768
4769 if line_number > lines.len() {
4771 return None;
4772 }
4773
4774 let target_idx = line_number - 1; let target_line = lines[target_idx];
4776 let trimmed = target_line.trim();
4777
4778 if let Some(blockquote_reflow) = reflow_blockquote_paragraph_at_line(content, &lines, target_idx, options) {
4781 return Some(blockquote_reflow);
4782 }
4783
4784 if is_paragraph_boundary(trimmed, target_line) {
4786 return None;
4787 }
4788
4789 let mut para_start = target_idx;
4791 while para_start > 0 {
4792 let prev_idx = para_start - 1;
4793 let prev_line = lines[prev_idx];
4794 let prev_trimmed = prev_line.trim();
4795
4796 if is_paragraph_boundary(prev_trimmed, prev_line) {
4798 break;
4799 }
4800
4801 para_start = prev_idx;
4802 }
4803
4804 let mut para_end = target_idx;
4806 while para_end + 1 < lines.len() {
4807 let next_idx = para_end + 1;
4808 let next_line = lines[next_idx];
4809 let next_trimmed = next_line.trim();
4810
4811 if is_paragraph_boundary(next_trimmed, next_line) {
4813 break;
4814 }
4815
4816 para_end = next_idx;
4817 }
4818
4819 let paragraph_lines = &lines[para_start..=para_end];
4821
4822 let mut start_byte = 0;
4824 for line in lines.iter().take(para_start) {
4825 start_byte += line.len() + 1; }
4827
4828 let mut end_byte = start_byte;
4829 for line in paragraph_lines {
4830 end_byte += line.len() + 1; }
4832
4833 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
4836
4837 if !includes_trailing_newline {
4839 end_byte -= 1;
4840 }
4841
4842 let paragraph_text = paragraph_lines.join("\n");
4844
4845 let reflowed = reflow_markdown(¶graph_text, options);
4847
4848 let reflowed_text = if includes_trailing_newline {
4852 if reflowed.ends_with('\n') {
4854 reflowed
4855 } else {
4856 format!("{reflowed}\n")
4857 }
4858 } else {
4859 if reflowed.ends_with('\n') {
4861 reflowed.trim_end_matches('\n').to_string()
4862 } else {
4863 reflowed
4864 }
4865 };
4866
4867 Some(ParagraphReflow {
4868 start_byte,
4869 end_byte,
4870 reflowed_text,
4871 })
4872}
4873fn decompose_code_span(raw: &str) -> Option<(&str, &str)> {
4879 let marker_len = raw.bytes().take_while(|&b| b == b'`').count();
4880 if marker_len == 0 {
4881 return None;
4882 }
4883 let marker = &raw[..marker_len];
4884 if raw.len() < marker_len * 2 {
4885 return None;
4886 }
4887 let content = &raw[marker_len..raw.len() - marker_len];
4888 Some((content, marker))
4889}
4890
4891#[cfg(test)]
4892mod tests {
4893 use super::*;
4894
4895 #[test]
4899 fn preserves_content_accepts_whitespace_changes_and_rejects_the_rest() {
4900 let accepted: &[(&str, &[&str])] = &[
4901 ("one two three", &["one two three"]),
4902 ("one two three", &["one two", "three"]),
4903 ("one two three", &["one", "two", "three"]),
4904 ("one two ", &["one two"]),
4906 ("日本語のテキスト", &["日本語の", "テキスト"]),
4908 ("_First. Second._", &["_First.", "Second._"]),
4910 ];
4911 for (original, reflowed) in accepted {
4912 let reflowed: Vec<String> = reflowed.iter().map(ToString::to_string).collect();
4913 assert!(
4914 preserves_content(original, &reflowed),
4915 "{original:?} -> {reflowed:?} only moves whitespace"
4916 );
4917 }
4918
4919 let rejected: &[(&str, &[&str])] = &[
4920 ("one two three", &["one two"]),
4922 ("one two", &["one two three"]),
4924 ("one two", &["two one"]),
4926 ("_First. Second._", &["_First._", "_Second._"]),
4928 ("alpha and beta", &["alpha", "andbeta"]),
4930 ("mot suivant : autre", &["mot suivant: autre"]),
4932 ];
4933 for (original, reflowed) in rejected {
4934 let reflowed: Vec<String> = reflowed.iter().map(ToString::to_string).collect();
4935 assert!(
4936 !preserves_content(original, &reflowed),
4937 "{original:?} -> {reflowed:?} changes the text, not just its line breaks"
4938 );
4939 }
4940 }
4941
4942 #[test]
4944 fn reflow_line_falls_back_to_the_input_when_content_would_change() {
4945 let options = ReflowOptions {
4946 line_length: 40,
4947 ..Default::default()
4948 };
4949 let line = "one two three four five six seven eight nine ten";
4950
4951 assert!(preserves_content(line, &reflow_line(line, &options)));
4952 assert_eq!(reflow_line(line, &options), reflow_line_unchecked(line, &options));
4953 }
4954
4955 #[test]
4956 fn cascade_split_line_handles_a_very_long_line_without_overflowing() {
4957 let words: Vec<String> = (0..4000).map(|i| format!("word{i}")).collect();
4963 let line = words.join(" ");
4964
4965 let options = ReflowOptions {
4966 line_length: 80,
4967 length_mode: ReflowLengthMode::Chars,
4968 ..Default::default()
4969 };
4970 let out = cascade_split_line(&line, &options);
4971
4972 assert!(out.len() > 1, "a very long line should split into many lines");
4973 for segment in &out {
4974 assert!(
4975 display_len(segment, ReflowLengthMode::Chars) <= 80 || !segment.contains(' '),
4976 "each wrapped line should fit the width (or be a single unbreakable token)"
4977 );
4978 }
4979 let rejoined = out.join(" ");
4981 let original_words: Vec<&str> = line.split(' ').collect();
4982 let result_words: Vec<&str> = rejoined.split_whitespace().collect();
4983 assert_eq!(original_words, result_words, "reflow must preserve all words in order");
4984 }
4985
4986 #[test]
4991 fn test_helper_function_text_ends_with_abbreviation() {
4992 let abbreviations = get_abbreviations(&None);
4994
4995 assert!(text_ends_with_abbreviation("Dr.", &abbreviations));
4997 assert!(text_ends_with_abbreviation("word Dr.", &abbreviations));
4998 assert!(text_ends_with_abbreviation("e.g.", &abbreviations));
4999 assert!(text_ends_with_abbreviation("i.e.", &abbreviations));
5000 assert!(text_ends_with_abbreviation("Mr.", &abbreviations));
5001 assert!(text_ends_with_abbreviation("Mrs.", &abbreviations));
5002 assert!(text_ends_with_abbreviation("Ms.", &abbreviations));
5003 assert!(text_ends_with_abbreviation("Prof.", &abbreviations));
5004
5005 assert!(!text_ends_with_abbreviation("etc.", &abbreviations));
5007 assert!(!text_ends_with_abbreviation("paradigms.", &abbreviations));
5008 assert!(!text_ends_with_abbreviation("programs.", &abbreviations));
5009 assert!(!text_ends_with_abbreviation("items.", &abbreviations));
5010 assert!(!text_ends_with_abbreviation("systems.", &abbreviations));
5011 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)); }
5017
5018 #[test]
5019 fn test_footnote_after_period_splits_sentence() {
5020 let text = "First sentence.[^1] Second sentence.";
5024 let sentences = split_into_sentences(text, None, true);
5025 assert_eq!(
5026 sentences,
5027 vec!["First sentence.[^1]".to_string(), "Second sentence.".to_string()],
5028 "footnote glued to the period should keep the boundary and stay attached to the first sentence"
5029 );
5030 }
5031
5032 #[test]
5033 fn test_multiple_consecutive_footnotes_after_period_splits_sentence() {
5034 let text = "Notes here.[^1][^2] Second sentence.";
5036 let sentences = split_into_sentences(text, None, true);
5037 assert_eq!(
5038 sentences,
5039 vec!["Notes here.[^1][^2]".to_string(), "Second sentence.".to_string()]
5040 );
5041 }
5042
5043 #[test]
5044 fn test_footnote_before_period_still_splits_sentence() {
5045 let text = "Annotation here[^1]. Second sentence.";
5049 let sentences = split_into_sentences(text, None, true);
5050 assert_eq!(
5051 sentences,
5052 vec!["Annotation here[^1].".to_string(), "Second sentence.".to_string()]
5053 );
5054 }
5055
5056 #[test]
5057 fn test_mid_sentence_footnote_does_not_split() {
5058 let text = "The system word[^1] more words. Next sentence.";
5061 let sentences = split_into_sentences(text, None, true);
5062 assert_eq!(
5063 sentences,
5064 vec![
5065 "The system word[^1] more words.".to_string(),
5066 "Next sentence.".to_string()
5067 ]
5068 );
5069 }
5070
5071 #[test]
5072 fn test_bare_numeric_bracket_after_period_does_not_split() {
5073 let text = "Citation here.[1] Second sentence.";
5076 let sentences = split_into_sentences(text, None, true);
5077 assert_eq!(
5078 sentences,
5079 vec![text.to_string()],
5080 "a bare numeric bracket must not be treated as a sentence boundary"
5081 );
5082 }
5083
5084 #[test]
5085 fn test_footnote_glued_to_following_word_does_not_split() {
5086 let text = "First sentence.[^1]Continued glued text.";
5089 let sentences = split_into_sentences(text, None, true);
5090 assert_eq!(sentences, vec![text.to_string()]);
5091 }
5092
5093 #[test]
5094 fn test_footnote_at_end_of_text_is_preserved() {
5095 let text = "Sentence.[^1]";
5098 let sentences = split_into_sentences(text, None, true);
5099 assert_eq!(sentences, vec![text.to_string()]);
5100 }
5101
5102 #[test]
5103 fn test_abbreviation_before_footnote_does_not_split() {
5104 let text = "See the notes, e.g.[^1] this one.";
5107 let sentences = split_into_sentences(text, None, true);
5108 assert_eq!(
5109 sentences,
5110 vec![text.to_string()],
5111 "e.g. is an abbreviation, not a sentence boundary"
5112 );
5113 }
5114
5115 #[test]
5116 fn sentence_boundary_never_falls_inside_an_atomic_construct() {
5117 let cases = [
5123 "Prefix [link. Still link](https://example.com) tail. Next sentence.",
5124 "Prefix [target](<https://example.com/First. Second>) tail. Next sentence.",
5125 "Prefix [text](url \"Title. More\") tail. Next sentence.",
5126 "Prefix  tail. Next sentence.",
5127 "Prefix [ref text. More][ref] tail. Next sentence.",
5128 "Prefix [collapsed. More][] tail. Next sentence.",
5129 "Prefix [[Page name. Title]] tail. Next sentence.",
5130 "Prefix $x. Y$ tail. Next sentence.",
5131 "Prefix $$x. Y$$ tail. Next sentence.",
5132 "Prefix <span title=\"A. B\">x</span> tail. Next sentence.",
5133 "Prefix `code. Still code` tail. Next sentence.",
5134 ];
5135 for text in cases {
5136 let sentences = split_into_sentences(text, None, true);
5137 let (head, tail) = text.rsplit_once(" tail. ").expect("case has a tail");
5138 assert_eq!(
5139 sentences,
5140 vec![format!("{head} tail."), tail.to_string()],
5141 "input {text:?}"
5142 );
5143 }
5144
5145 let text = "Prefix [shortcut. More] tail. Next sentence.";
5149 let whole = vec![
5150 "Prefix [shortcut. More] tail.".to_string(),
5151 "Next sentence.".to_string(),
5152 ];
5153 let defined = HashSet::from(["shortcut. more".to_string()]);
5154 assert_eq!(split_into_sentences(text, Some(&defined), true), whole);
5155 assert_eq!(split_into_sentences(text, None, true), whole);
5156 assert_eq!(
5157 split_into_sentences(text, Some(&HashSet::new()), true),
5158 vec!["Prefix [shortcut.", "More] tail.", "Next sentence."]
5159 );
5160 }
5161
5162 #[test]
5163 fn a_sentence_may_open_with_a_link_or_image() {
5164 for text in [
5169 "Opening sentence. [First. Second](https://example.com)",
5170 "Opening sentence. ",
5171 "Opening sentence. [[First. Second]]",
5172 "Opening sentence. [[first-note|First. Second]]",
5173 "Opening sentence. [Ref link][ref]",
5174 "Opening sentence. [](url) continues.",
5177 "Opening sentence. [][ref] continues.",
5178 "Opening sentence. [![First image][img]](url) continues.",
5181 "Opening sentence. [![First image][]](url) continues.",
5182 "Opening sentence. [![First image][img]][ref] continues.",
5183 ] {
5184 let (head, tail) = text.split_once(". ").expect("case has a boundary");
5185 assert_eq!(
5186 split_into_sentences(text, None, true),
5187 vec![format!("{head}."), tail.to_string()],
5188 "input {text:?}"
5189 );
5190 }
5191 let text = "Opening sentence. [![First image]](url) continues.";
5194 let defined = HashSet::from(["first image".to_string()]);
5195 assert_eq!(
5196 split_into_sentences(text, Some(&defined), true),
5197 vec!["Opening sentence.", "[![First image]](url) continues."]
5198 );
5199 assert_eq!(
5200 split_into_sentences(text, Some(&HashSet::new()), true),
5201 vec![text.to_string()],
5202 "an undefined shortcut is bracketed text, and `!` opens no sentence"
5203 );
5204 assert_eq!(
5207 split_into_sentences(
5208 "Opening sentence. [](url) continues.",
5209 None,
5210 true
5211 ),
5212 vec](url) continues."]
5213 );
5214 let defined = HashSet::from(["smith 2020".to_string()]);
5217 assert_eq!(
5218 split_into_sentences("Claim ends here. [Smith 2020] more text.", Some(&defined), true),
5219 vec!["Claim ends here.", "[Smith 2020] more text."]
5220 );
5221 let none_defined = HashSet::new();
5227 for text in [
5228 "Opening sentence. [first link](https://example.com) continues.",
5229 "Opening sentence. [[first note]] continues.",
5230 "Opening sentence. [[First Note|first note]] continues.",
5231 "Opening sentence. [[Page continues.",
5232 "Opening sentence. [[First] stray]] continues.",
5233 "Opening sentence.  continues.",
5234 "Opening sentence. [1] is the citation.",
5235 "Opening sentence. [First](unterminated",
5236 "Opening sentence. [First][unterminated",
5237 "Opening sentence. [First] (aside) continues.",
5238 "Claim ends here. [Smith 2020]",
5239 "Claim ends here. [Smith 2020] more text.",
5240 "See the RFC. [RFC] More text.",
5241 "Claim ends here. [^Note] more text.",
5242 ] {
5243 assert_eq!(
5244 split_into_sentences(text, Some(&none_defined), true),
5245 vec![text.to_string()],
5246 "input {text:?}"
5247 );
5248 }
5249 }
5250
5251 #[test]
5252 fn link_opener_is_read_off_the_parse() {
5253 let len = |text: &str, defs: Option<&HashSet<String>>| {
5256 let chars: Vec<char> = text.chars().collect();
5257 let char_offsets = char_byte_offsets(&chars);
5258 let NestedStructure { links, .. } = sentence_structure(text, defs);
5259 let st = SentenceText {
5260 text,
5261 chars: &chars,
5262 char_offsets: &char_offsets,
5263 links: &links,
5264 code_spans: &[],
5265 };
5266 st.link_end_at(0).map_or(0, |end| link_opener_len(&chars, 0, end))
5267 };
5268 let none = HashSet::new();
5269 assert_eq!(len("[text](url)", Some(&none)), 1);
5270 assert_eq!(
5271 len("[text][ref]", Some(&none)),
5272 1,
5273 "a full reference is a link whether or not defined"
5274 );
5275 assert_eq!(len("[text][]", Some(&none)), 1);
5276 assert_eq!(len("", Some(&none)), 2);
5277 assert_eq!(len("[[wiki]]", Some(&none)), 2);
5278 assert_eq!(
5279 len("[[wiki|shown]]", Some(&none)),
5280 7,
5281 "the displayed text starts after the alias pipe"
5282 );
5283 assert_eq!(len("![[img.png|100]]", Some(&none)), 11);
5284 assert_eq!(len("[[wiki|a|b]]", Some(&none)), 7, "the first pipe starts the alias");
5285 assert_eq!(
5286 len("[[wiki|shown]] [[a|b]]", Some(&none)),
5287 7,
5288 "a pipe past the closing `]]` is not this alias"
5289 );
5290 assert_eq!(
5291 len("[a \\] b](url)", Some(&none)),
5292 1,
5293 "an escaped bracket does not close the text"
5294 );
5295 assert_eq!(
5296 len("[](url)", Some(&none)),
5297 1,
5298 "the outer opener is skipped first"
5299 );
5300 for text in [
5304 "[^1]",
5305 "[text](unterminated",
5306 "[text][unterminated",
5307 "[text] (url)",
5308 "[[wiki",
5309 "[[wiki]",
5310 "[[First] stray]]",
5311 "[Smith 2020]",
5312 "[Smith 2020] (see also)",
5313 "[unclosed",
5314 "!bang",
5315 "text",
5316 ] {
5317 assert_eq!(len(text, Some(&none)), 0, "input {text:?}");
5318 }
5319 let smith = HashSet::from(["smith 2020".to_string()]);
5322 assert_eq!(len("[Smith 2020]", Some(&smith)), 1);
5323 assert_eq!(len("[Smith 2020]", None), 1);
5324 }
5325
5326 #[test]
5327 fn sentence_per_line_reflow_breaks_before_a_bracket_only_where_the_check_counts() {
5328 let defined = HashSet::from(["spec".to_string()]);
5336 let options = ReflowOptions {
5337 line_length: 120,
5338 sentence_per_line: true,
5339 defined_references: Some(defined.clone()),
5340 ..Default::default()
5341 };
5342 for (text, expected) in [
5343 (
5344 "Claim ends here. [Smith](https://example.com) more text. Second sentence.",
5345 vec more text.",
5348 "Second sentence.",
5349 ],
5350 ),
5351 (
5352 "Wow! [smith](https://example.com) more text. Second sentence.",
5353 vec more text.", "Second sentence."],
5354 ),
5355 (
5356 "Claim ends here. [smith](https://example.com) more text. Second sentence.",
5357 vec more text.",
5359 "Second sentence.",
5360 ],
5361 ),
5362 (
5363 "Claim ends here. [smith][ref] more text. Second sentence.",
5364 vec!["Claim ends here. [smith][ref] more text.", "Second sentence."],
5365 ),
5366 (
5367 "Claim ends here.  more text. Second sentence.",
5368 vec more text.", "Second sentence."],
5369 ),
5370 (
5371 "Claim ends here.[Link](https://example.com) more text. Second sentence.",
5372 vec more text.",
5374 "Second sentence.",
5375 ],
5376 ),
5377 (
5378 "See the RFC. [RFC] More text. Second sentence.",
5379 vec!["See the RFC. [RFC] More text.", "Second sentence."],
5380 ),
5381 (
5382 "See the spec. [Spec] More text. Second sentence.",
5383 vec!["See the spec.", "[Spec] More text.", "Second sentence."],
5384 ),
5385 (
5386 "See the spec. [spec] more text. Second sentence.",
5387 vec!["See the spec. [spec] more text.", "Second sentence."],
5388 ),
5389 (
5390 "Claim ends here. [[page|Second sentence]] continues. Third sentence.",
5391 vec![
5392 "Claim ends here.",
5393 "[[page|Second sentence]] continues.",
5394 "Third sentence.",
5395 ],
5396 ),
5397 (
5398 "Claim ends here. [[Page|second sentence]] continues. Third sentence.",
5399 vec![
5400 "Claim ends here. [[Page|second sentence]] continues.",
5401 "Third sentence.",
5402 ],
5403 ),
5404 ] {
5405 let lines = reflow_line(text, &options);
5406 assert_eq!(lines, expected, "input {text:?}");
5407 assert_eq!(
5410 split_into_sentences(text, Some(&defined), true).len(),
5411 expected.len(),
5412 "check count for {text:?}"
5413 );
5414 for line in &lines {
5415 assert_eq!(
5416 split_into_sentences(line, Some(&defined), true).len(),
5417 1,
5418 "line {line:?} of {text:?}"
5419 );
5420 }
5421 }
5422 }
5423
5424 #[test]
5425 fn sentence_per_line_reflow_holds_atomic_constructs_whole() {
5426 let options = ReflowOptions {
5430 line_length: 80,
5431 sentence_per_line: true,
5432 ..Default::default()
5433 };
5434 let lines = reflow_line(
5435 "Prefix `code. Still code` and [link. Still link](https://example.com) tail. Next sentence.",
5436 &options,
5437 );
5438 assert_eq!(
5439 lines,
5440 vec tail.".to_string(),
5442 "Next sentence.".to_string(),
5443 ]
5444 );
5445
5446 let lines = reflow_line(
5447 "Prefix  and [target](<https://example.com/First. Second>) tail. Next sentence.",
5448 &options,
5449 );
5450 assert_eq!(
5451 lines,
5452 vec and [target](<https://example.com/First. Second>) tail.".to_string(),
5454 "Next sentence.".to_string(),
5455 ]
5456 );
5457
5458 let lines = reflow_line("First one. Then [link](url) second. Third one.", &options);
5461 assert_eq!(
5462 lines,
5463 vec second.".to_string(),
5466 "Third one.".to_string(),
5467 ]
5468 );
5469 }
5470
5471 #[test]
5472 fn test_is_unordered_list_marker() {
5473 assert!(is_unordered_list_marker("- item"));
5475 assert!(is_unordered_list_marker("* item"));
5476 assert!(is_unordered_list_marker("+ item"));
5477 assert!(is_unordered_list_marker("-")); assert!(is_unordered_list_marker("*"));
5479 assert!(is_unordered_list_marker("+"));
5480
5481 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")); }
5492
5493 #[test]
5494 fn test_is_block_boundary() {
5495 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"));
5517 assert!(!is_block_boundary("*emphasis*")); assert!(!is_block_boundary("[link](url)")); assert!(!is_block_boundary("some words here"));
5520 }
5521
5522 #[test]
5523 fn test_definition_list_boundary_in_single_line_paragraph() {
5524 let options = ReflowOptions {
5527 line_length: 80,
5528 ..Default::default()
5529 };
5530 let input = "Term\n: Definition of the term";
5531 let result = reflow_markdown(input, &options);
5532 assert!(
5534 result.contains(": Definition"),
5535 "Definition list item should not be merged into previous line. Got: {result:?}"
5536 );
5537 let lines: Vec<&str> = result.lines().collect();
5538 assert_eq!(lines.len(), 2, "Should remain two separate lines. Got: {lines:?}");
5539 assert_eq!(lines[0], "Term");
5540 assert_eq!(lines[1], ": Definition of the term");
5541 }
5542
5543 #[test]
5544 fn test_is_paragraph_boundary() {
5545 assert!(is_paragraph_boundary("# Heading", "# Heading"));
5547 assert!(is_paragraph_boundary("- item", "- item"));
5548 assert!(is_paragraph_boundary(":::", ":::"));
5549 assert!(is_paragraph_boundary(": definition", ": definition"));
5550
5551 assert!(is_paragraph_boundary("code", " code"));
5553 assert!(is_paragraph_boundary("code", "\tcode"));
5554
5555 assert!(is_paragraph_boundary("| a | b |", "| a | b |"));
5557 assert!(is_paragraph_boundary("a | b", "a | b")); assert!(!is_paragraph_boundary("regular text", "regular text"));
5561 assert!(!is_paragraph_boundary("text", " text")); }
5563
5564 #[test]
5565 fn test_div_marker_boundary_in_reflow_paragraph_at_line() {
5566 let content = "Some paragraph text here.\n\n::: {.callout-note}\nThis is a callout.\n:::\n";
5569 let result = reflow_paragraph_at_line(content, 3, 80);
5571 assert!(result.is_none(), "Div marker line should not be reflowed");
5572 }
5573
5574 #[test]
5575 fn starts_block_construct_detects_block_openers() {
5576 for case in ["- item", "-", "* item", "*", "+ item", "+", "-\titem"] {
5578 assert!(starts_block_construct(case), "bullet: {case:?}");
5579 }
5580 for case in ["1. item", "1) item", "01. x", "000000001. x", "1.\titem"] {
5583 assert!(starts_block_construct(case), "ordered: {case:?}");
5584 }
5585 for case in ["> quote", ">quote", ">"] {
5587 assert!(starts_block_construct(case), "blockquote: {case:?}");
5588 }
5589 for case in ["# heading", "###### h6", "#", "##"] {
5591 assert!(starts_block_construct(case), "heading: {case:?}");
5592 }
5593 for case in ["```", "```rust", "````", "~~~", "~~~text"] {
5595 assert!(starts_block_construct(case), "fence: {case:?}");
5596 }
5597 for case in ["---", "--", "===", "=", "***", "___", "_ _ _", "- - -"] {
5599 assert!(starts_block_construct(case), "setext/thematic: {case:?}");
5600 }
5601 for case in [
5604 "[^1]: text",
5605 "[^note]:",
5606 "[ref]: http://example.com",
5607 "[wat]: url follows",
5608 ] {
5609 assert!(starts_block_construct(case), "definition: {case:?}");
5610 }
5611 for case in ["<div>content", "</div>", "<p>text", "<table>", "<pre>code", "<h1>x"] {
5613 assert!(starts_block_construct(case), "html block: {case:?}");
5614 }
5615 }
5616
5617 #[test]
5618 fn starts_block_construct_allows_ordinary_prose() {
5619 for case in [
5620 "",
5621 "word",
5622 "-5 degrees",
5623 "--flag",
5624 "-item",
5625 "#hashtag",
5626 "####### seven hashes is not a heading",
5627 "1.5 million",
5628 "1234567890. ten digits is not a list marker",
5629 "0000000001. ten digits is not a list marker either",
5630 "2. item",
5633 "7. item",
5634 "0. item",
5635 "42) x",
5636 "123456. item",
5637 "1.",
5638 "1)",
5639 "123456.",
5640 "123456)",
5641 "1.item",
5642 "1:30 pm",
5643 "*emphasis*",
5644 "**bold** text",
5645 "__bold__ text",
5646 "_emphasis_ text",
5647 "`code` span",
5648 "`` double backtick span ``",
5649 "~~strikethrough~~",
5650 "=x",
5651 "== ==",
5652 "(parenthetical)",
5653 "[link](url)",
5654 "[text][ref] more",
5655 "[bracketed] aside",
5656 "[a](b) [ref]: first bracket is a link, not a label",
5657 "[esc\\]: not a close] text",
5658 "<span>inline</span>",
5659 "<b>bold</b>",
5660 "<https://example.com> autolink",
5661 "<mailto:a@b.com>",
5662 "<notarealtag>",
5663 ] {
5664 assert!(!starts_block_construct(case), "prose: {case:?}");
5665 }
5666 }
5667
5668 #[test]
5669 fn merge_block_construct_continuations_merges_marker_led_lines() {
5670 let lines = vec![
5671 "First sentence?".to_string(),
5672 "- looks like a list item".to_string(),
5673 "Second sentence.".to_string(),
5674 ];
5675 assert_eq!(
5676 merge_block_construct_continuations(lines),
5677 vec![
5678 "First sentence? - looks like a list item".to_string(),
5679 "Second sentence.".to_string(),
5680 ]
5681 );
5682
5683 let lines = vec!["- real list content".to_string(), "continuation".to_string()];
5686 assert_eq!(
5687 merge_block_construct_continuations(lines.clone()),
5688 lines,
5689 "first line must never be merged"
5690 );
5691
5692 let lines = vec!["prose".to_string(), "1.".to_string(), "[ref]:".to_string()];
5695 assert_eq!(
5696 merge_block_construct_continuations(lines),
5697 vec!["prose 1. [ref]:".to_string()],
5698 "a merge that creates an opener must fold again"
5699 );
5700 }
5701
5702 #[test]
5703 fn wrap_never_starts_a_line_with_a_block_marker() {
5704 let options = ReflowOptions {
5705 line_length: 25,
5706 ..Default::default()
5707 };
5708 let lines = reflow_line(
5711 "Some words here and then - a dash clause that wraps around the limit.",
5712 &options,
5713 );
5714 assert_eq!(
5715 lines,
5716 vec![
5717 "Some words here and",
5718 "then - a dash clause that",
5719 "wraps around the limit."
5720 ]
5721 );
5722
5723 for input in [
5725 "Alpha beta gamma delta epsilon - dash clause here to wrap",
5726 "Alpha beta gamma delta epsilon > quote lookalike here to wrap",
5727 "Alpha beta gamma delta epsilon # heading lookalike here to wrap",
5728 "Alpha beta gamma delta epsilon 1. ordered lookalike here to wrap",
5729 "Alpha beta gamma delta epsilon * star clause here to wrap",
5730 "Alpha beta gamma delta epsilon + plus clause here to wrap",
5731 ] {
5732 for width in 10..40 {
5733 let options = ReflowOptions {
5734 line_length: width,
5735 ..Default::default()
5736 };
5737 for line in reflow_line(input, &options) {
5738 assert!(
5739 !starts_block_construct(&line),
5740 "width {width}: wrapped line opens a block construct: {line:?} (input {input:?})"
5741 );
5742 }
5743 }
5744 }
5745 }
5746
5747 #[test]
5748 fn sentence_per_line_keeps_block_markers_mid_line() {
5749 let options = ReflowOptions {
5750 line_length: 80,
5751 sentence_per_line: true,
5752 ..Default::default()
5753 };
5754 let lines = reflow_line(
5757 "Google Calendar (Can't we get rid of this dependency? - I don't really see the need)",
5758 &options,
5759 );
5760 assert_eq!(
5761 lines,
5762 vec!["Google Calendar (Can't we get rid of this dependency? - I don't really see the need)".to_string()]
5763 );
5764
5765 let lines = reflow_line("See section 4? # is the marker we use. Fine.", &options);
5767 assert_eq!(lines, vec!["See section 4? # is the marker we use.", "Fine."]);
5768
5769 let lines = reflow_line("Is this a problem? > I quote someone here.", &options);
5770 assert_eq!(lines, vec!["Is this a problem? > I quote someone here."]);
5771
5772 let lines = reflow_line("Another case! 1. Not a list. More text follows here.", &options);
5773 for line in &lines {
5774 assert!(
5775 !starts_block_construct(line),
5776 "sentence-per-line output opens a block construct: {line:?}"
5777 );
5778 }
5779 }
5780
5781 fn strict_sentence_lines(input: &str, require_sentence_capital: bool) -> Vec<String> {
5783 let options = ReflowOptions {
5784 line_length: 80,
5785 sentence_per_line: true,
5786 require_sentence_capital,
5787 ..Default::default()
5788 };
5789 reflow_line(input, &options)
5790 }
5791
5792 #[test]
5793 fn strict_mode_lets_a_sentence_open_with_a_number() {
5794 for (input, expected) in [
5798 (
5799 "The number of items was 5. 2 of them failed.",
5800 vec!["The number of items was 5.", "2 of them failed."],
5801 ),
5802 (
5803 "Sometimes we have 2. 3 might be here.",
5804 vec!["Sometimes we have 2.", "3 might be here."],
5805 ),
5806 (
5807 "The number of items was 5. 2nd sentence.",
5808 vec!["The number of items was 5.", "2nd sentence."],
5809 ),
5810 (
5811 "Released in 2020. 3 of them failed.",
5812 vec!["Released in 2020.", "3 of them failed."],
5813 ),
5814 (
5815 "First sentence. 2nd sentence.",
5816 vec!["First sentence.", "2nd sentence."],
5817 ),
5818 (
5819 "We met at 6:00 sharp. 6:00 is early.",
5820 vec!["We met at 6:00 sharp.", "6:00 is early."],
5821 ),
5822 ("Pi is 3.14 roughly. Next.", vec!["Pi is 3.14 roughly.", "Next."]),
5823 (
5826 "A \"Is this a test?\" 2020 was memorable.",
5827 vec!["A \"Is this a test?\"", "2020 was memorable."],
5828 ),
5829 ] {
5830 assert_eq!(strict_sentence_lines(input, true), expected, "input {input:?}");
5831 }
5832
5833 for input in [
5836 "The count was 5. and that was all.",
5837 "See fig. 3 for details.",
5838 "See no. 5 in the list.",
5839 "See ch. 12 and vol. 3 for more.",
5840 "A \"Is this a test?\" guide to it.",
5841 ] {
5842 assert_eq!(
5843 strict_sentence_lines(input, true),
5844 vec![input.to_string()],
5845 "input {input:?}"
5846 );
5847 }
5848 }
5849
5850 #[test]
5851 fn sentence_never_opens_with_an_ordered_list_marker() {
5852 for (input, require_capital, expected) in [
5859 (
5860 "Steps: 1. Do this. 2. Do that.",
5861 true,
5862 vec!["Steps: 1.", "Do this. 2.", "Do that."],
5863 ),
5864 (
5865 "First sentence. 1. Do that.",
5866 true,
5867 vec!["First sentence. 1.", "Do that."],
5868 ),
5869 ("Do this! 2. Do that.", true, vec!["Do this! 2.", "Do that."]),
5870 ("Do this. 12) Do that.", true, vec!["Do this. 12) Do that."]),
5871 ("Do this. 2. do that.", true, vec!["Do this. 2. do that."]),
5872 ("Do this. 2. do that.", false, vec!["Do this. 2.", "do that."]),
5873 (
5874 "Twelve. 1234567890. next one here.",
5875 true,
5876 vec!["Twelve. 1234567890. next one here."],
5877 ),
5878 ("Do this. 2 more times.", true, vec!["Do this.", "2 more times."]),
5881 ("How many? 2.", true, vec!["How many?", "2."]),
5882 ("第一句。2. Do that.", true, vec!["第一句。2.", "Do that."]),
5885 ("第一句。 2) 第二句。", true, vec!["第一句。 2) 第二句。"]),
5886 ("第一句。2 more.", true, vec!["第一句。", "2 more."]),
5887 ("第一句。第二句。", true, vec!["第一句。", "第二句。"]),
5888 ] {
5889 let lines = strict_sentence_lines(input, require_capital);
5890 assert_eq!(lines, expected, "input {input:?}, require capital {require_capital}");
5891 for line in &lines {
5892 let chars: Vec<char> = line.chars().collect();
5893 assert!(
5894 !opens_ordered_list_marker(&chars),
5895 "line opens with an ordered-list marker: {line:?} (input {input:?})"
5896 );
5897 }
5898 }
5899 }
5900
5901 #[test]
5902 fn opens_ordered_list_marker_matches_the_marker_shape() {
5903 let chars = |s: &str| s.chars().collect::<Vec<char>>();
5904 for text in ["2. x", "1) x", "12. x", "1.\tx", "1234567890. x", "0. x"] {
5905 assert!(opens_ordered_list_marker(&chars(text)), "{text:?} is a marker");
5906 }
5907 for text in ["2.x", "2.", "2)", "2 x", "x. y", "", " 2. x", "2.5 x", "-2. x"] {
5908 assert!(!opens_ordered_list_marker(&chars(text)), "{text:?} is not a marker");
5909 }
5910 }
5911
5912 #[test]
5913 fn inline_math_directly_after_display_math_stays_atomic() {
5914 let options = ReflowOptions {
5922 line_length: 8,
5923 ..Default::default()
5924 };
5925 let lines = reflow_line("$$a$$$bb cc dd$ x", &options);
5926 assert_eq!(lines, vec!["$$a$$$bb cc dd$".to_string(), "x".to_string()]);
5927 }
5928
5929 #[test]
5930 fn test_code_span_parsing() {
5931 let elements = parse_markdown_elements_inner("`code`", false, false, None);
5933 assert_eq!(elements.len(), 1);
5934 assert!(matches!(&elements[0], Element::Code { content, marker } if content == "code" && marker == "`"));
5935
5936 let elements = parse_markdown_elements_inner("``code``", false, false, None);
5938 assert_eq!(elements.len(), 1);
5939 assert!(matches!(&elements[0], Element::Code { content, marker } if content == "code" && marker == "``"));
5940
5941 let elements = parse_markdown_elements_inner("``code`inside``", false, false, None);
5943 assert_eq!(elements.len(), 1);
5944 assert!(
5945 matches!(&elements[0], Element::Code { content, marker } if content == "code`inside" && marker == "``")
5946 );
5947
5948 let elements = parse_markdown_elements_inner("`` code ``", false, false, None);
5950 assert_eq!(elements.len(), 1);
5951 assert!(matches!(&elements[0], Element::Code { content, marker } if content == " code " && marker == "``"));
5952
5953 let elements = parse_markdown_elements_inner("`unclosed", false, false, None);
5955 assert_eq!(elements.len(), 1);
5956 assert!(matches!(&elements[0], Element::Text(s) if s == "`unclosed"));
5957
5958 let elements = parse_markdown_elements_inner("`unclosed [link](url)", false, false, None);
5960 assert_eq!(elements.len(), 2);
5962 assert!(matches!(&elements[0], Element::Text(s) if s == "`unclosed "));
5963 assert!(matches!(&elements[1], Element::Link(s) if s == "[link](url)"));
5964 }
5965
5966 #[test]
5967 fn test_reflow_performance_long_input() {
5968 let mut text = String::new();
5971 for i in 1..400 {
5972 let backticks = "`".repeat(i);
5973 text.push_str(&backticks);
5974 text.push(' ');
5975 }
5976
5977 let start = std::time::Instant::now();
5978 let elements = parse_markdown_elements_inner(&text, false, false, None);
5979 let duration = start.elapsed();
5980
5981 assert!(duration.as_millis() < 100, "Parsing took too long: {duration:?}");
5983 assert!(!elements.is_empty());
5984 }
5985
5986 #[test]
5987 fn test_reflow_performance_display_math_heavy() {
5988 let text = "$$a$$".repeat(4000);
5993
5994 let start = std::time::Instant::now();
5995 let elements = parse_markdown_elements_inner(&text, false, false, None);
5996 let duration = start.elapsed();
5997
5998 assert!(duration.as_millis() < 100, "Parsing took too long: {duration:?}");
5999 assert_eq!(elements.len(), 4000);
6000 }
6001
6002 #[test]
6003 fn inline_math_len_at_start_matches_regex_at_slice_start() {
6004 let alphabet = ['$', 'a', ' '];
6009 let mut inputs: Vec<String> = vec![String::new()];
6010 let mut frontier: Vec<String> = vec![String::new()];
6011 for _ in 0..6 {
6012 let mut longer = Vec::new();
6013 for prefix in &frontier {
6014 for ch in alphabet {
6015 let mut s = prefix.clone();
6016 s.push(ch);
6017 longer.push(s);
6018 }
6019 }
6020 inputs.extend(longer.iter().cloned());
6021 frontier = longer;
6022 }
6023 inputs.push("$αβ$x".to_string());
6025 inputs.push("$α$$".to_string());
6026
6027 for s in &inputs {
6028 let expected = INLINE_MATH_REGEX
6029 .find(s)
6030 .ok()
6031 .flatten()
6032 .filter(|m| m.start() == 0)
6033 .map(|m| m.end());
6034 assert_eq!(inline_math_len_at_start(s), expected, "input: {s:?}");
6035 }
6036 }
6037
6038 #[test]
6039 fn inline_math_probe_after_dollar_matches_uncached_parse() {
6040 let cases = [
6046 ("$$a$$$b c$ x", r#"[DisplayMath("a"), InlineMath("b c"), Text(" x")]"#),
6047 (
6048 "$$a$$$b$ $$a$$$b$",
6049 r#"[DisplayMath("a"), InlineMath("b"), Text(" "), DisplayMath("a"), InlineMath("b")]"#,
6050 ),
6051 (
6053 "$$a$$$ x $y z$",
6054 r#"[DisplayMath("a"), InlineMath(" x "), Text("y z$")]"#,
6055 ),
6056 ("$$a$$$$ x", r#"[DisplayMath("a"), Text("$$ x")]"#),
6058 ("$$a$$$$b$$ x", r#"[DisplayMath("a"), DisplayMath("b"), Text(" x")]"#),
6059 (
6061 "$a$$b$$c$$d$ tail",
6062 r#"[Text("$a"), DisplayMath("b"), Text("c$$d$ tail")]"#,
6063 ),
6064 ];
6065 for (input, expected) in cases {
6066 let elements = parse_markdown_elements_inner(input, false, false, None);
6067 assert_eq!(format!("{elements:?}"), expected, "input: {input:?}");
6068 }
6069 }
6070
6071 #[test]
6072 fn test_atomic_spans() {
6073 let text_emphasis = "hello **word1 word2**";
6075
6076 let options_disabled = ReflowOptions {
6077 line_length: 18,
6078 atomic_spans: true,
6079 ..Default::default()
6080 };
6081 let lines_disabled = reflow_line(text_emphasis, &options_disabled);
6082 assert_eq!(lines_disabled, vec!["hello", "**word1 word2**"]);
6083
6084 let options_enabled = ReflowOptions {
6085 line_length: 18,
6086 atomic_spans: false,
6087 ..Default::default()
6088 };
6089 let lines_enabled = reflow_line(text_emphasis, &options_enabled);
6090 assert_eq!(lines_enabled, vec!["hello **word1", "word2**"]);
6091
6092 let text_code = "hello `word1 word2`";
6094
6095 let lines_code_disabled = reflow_line(text_code, &options_disabled);
6096 assert_eq!(lines_code_disabled, vec!["hello", "`word1 word2`"]);
6097
6098 let lines_code_enabled = reflow_line(text_code, &options_enabled);
6099 assert_eq!(lines_code_enabled, vec!["hello `word1", "word2`"]);
6100
6101 let text_code_padding = "hello `` `word1` `word2` ``";
6103 let lines_padding_enabled = reflow_line(text_code_padding, &options_enabled);
6104 assert_eq!(lines_padding_enabled, vec![r#"hello `` `word1`"#, r#"`word2` ``"#]);
6105
6106 let text_attached = "**one two**,"; let options_11 = ReflowOptions {
6111 line_length: 11,
6112 atomic_spans: true,
6113 ..Default::default()
6114 };
6115 assert_eq!(reflow_line(text_attached, &options_11), vec!["**one two**,"]);
6116
6117 let options_10 = ReflowOptions {
6119 line_length: 10,
6120 atomic_spans: true,
6121 ..Default::default()
6122 };
6123 assert_eq!(reflow_line(text_attached, &options_10), vec!["**one", "two**,"]);
6124 }
6125
6126 #[test]
6127 fn test_emphasis_containing_markers_is_not_split() {
6128 let options = ReflowOptions {
6129 line_length: 5,
6130 atomic_spans: false,
6131 ..Default::default()
6132 };
6133 let lines = reflow_line(r#"*foo \*bar*"#, &options);
6135 assert_eq!(lines, vec![r#"*foo \*bar*"#.to_string()]);
6136 }
6137
6138 fn semantic_shape(markdown: &str) -> String {
6143 let mut options = Options::empty();
6144 options.insert(Options::ENABLE_STRIKETHROUGH);
6145 let mut out = String::new();
6146 let push_prose = |out: &mut String, text: &str| {
6147 for c in text.chars() {
6148 if c.is_whitespace() {
6149 if !out.ends_with(char::is_whitespace) {
6150 out.push(' ');
6151 }
6152 } else {
6153 out.push(c);
6154 }
6155 }
6156 };
6157 for event in Parser::new_ext(markdown, options) {
6158 match event {
6159 Event::Text(text) => push_prose(&mut out, &text),
6160 Event::SoftBreak | Event::HardBreak => push_prose(&mut out, " "),
6161 Event::Code(code) => out.push_str(&format!("<code>{code}</code>")),
6163 Event::Start(tag) => out.push_str(&format!("<{tag:?}>")),
6164 Event::End(tag) => out.push_str(&format!("</{tag:?}>")),
6165 other => out.push_str(&format!("{other:?}")),
6166 }
6167 }
6168 out.trim().to_string()
6169 }
6170
6171 #[test]
6172 fn test_wrapping_a_span_never_changes_what_it_parses_to() {
6173 let corpus = [
6177 "_This is a very, very, very, very, very long line with some `code` inside._",
6178 "_alpha beta gamma delta epsilon `a b` zeta eta theta iota kappa lambda_",
6179 "**strong text with `code` and more words than fit on one single line**",
6180 "~~struck text with `code` and more words than fit on one single line~~",
6181 "_emphasis with **nested strong that is quite long** and trailing words_",
6182 "***A doubly nested bold italic span with more words than fit on a line***",
6185 "___Another doubly nested span with more words than fit on a single line___",
6186 "**_mixed strong then emphasis with more words than fit on a single line_**",
6187 "*__mixed emphasis then strong with more words than fit on a single line__*",
6188 "**~~strong strikethrough with more words than fit on a single line here~~**",
6189 "**a * b with a stray marker and plenty more words to pass the budget**",
6192 "_foo `a` bar `b` baz qux quux corge grault garply waldo fred plugh xyzzy_",
6193 "text before _a long emphasis with `code` inside of it here_ and after",
6194 "(_a parenthesized long emphasis with `code` inside of it right here_)",
6195 r#"*foo \*bar baz qux quux corge grault garply waldo fred plugh xyzzy*"#,
6196 "_tab\tseparated `a\tb` words spread out over quite a long emphasis span_",
6197 "_This has [text](<a b c d e f g h i j k>) and trailing words to wrap._",
6200 "_This has [`code`](<a b c d e f g h i j k>) and trailing words to wrap._",
6201 r#"_See [x](https://example.com "a long link title here") and `code` too._"#,
6202 "_A [link with a long label](https://example.com/path) and `code` here._",
6203 "_An image  plus `code` and more text_",
6204 ];
6205 for text in corpus {
6206 let expected = semantic_shape(text);
6207 for line_length in [20, 30, 40, 80] {
6208 for atomic_spans in [true, false] {
6209 let options = ReflowOptions {
6210 line_length,
6211 atomic_spans,
6212 ..Default::default()
6213 };
6214 let wrapped = reflow_line(text, &options).join("\n");
6215 assert_eq!(
6216 semantic_shape(&wrapped),
6217 expected,
6218 "reflow changed the parse of {text:?} at line_length={line_length} \
6219 atomic_spans={atomic_spans}\n wrapped: {wrapped:?}"
6220 );
6221 }
6222 }
6223 }
6224 }
6225
6226 #[test]
6227 fn test_wrapping_a_span_keeps_whole_the_constructs_pulldown_cannot_see() {
6228 let cases = [
6232 (
6233 "_alpha beta gamma [[a wiki link]] delta epsilon zeta eta_",
6234 "[[a wiki link]]",
6235 ),
6236 (
6237 "_alpha beta gamma {{< foo bar >}} delta epsilon zeta eta_",
6238 "{{< foo bar >}}",
6239 ),
6240 ("_alpha beta gamma $a + b$ delta epsilon zeta eta theta_", "$a + b$"),
6241 ("_alpha beta gamma $$a + b$$ delta epsilon zeta eta theta_", "$$a + b$$"),
6242 ];
6243 for (text, construct) in cases {
6244 for line_length in [12, 20, 30] {
6245 for atomic_spans in [true, false] {
6246 let options = ReflowOptions {
6247 line_length,
6248 atomic_spans,
6249 ..Default::default()
6250 };
6251 let wrapped = reflow_line(text, &options).join("\n");
6252 assert!(
6253 wrapped.contains(construct),
6254 "{construct} was broken at line_length={line_length} \
6255 atomic_spans={atomic_spans}: {wrapped:?}"
6256 );
6257 }
6258 }
6259 }
6260 }
6261
6262 #[test]
6263 fn test_overlong_emphasis_with_nested_code_span_wraps() {
6264 let options = ReflowOptions {
6268 line_length: 80,
6269 atomic_spans: true,
6270 ..Default::default()
6271 };
6272 let text = "_This is a very, very, very, very, very, very, very long line that exceeds 80 characters with some `code` inside._";
6273 let lines = reflow_line(text, &options);
6274 assert_eq!(
6275 lines,
6276 vec![
6277 "_This is a very, very, very, very, very, very, very long line that exceeds 80",
6278 "characters with some `code` inside._",
6279 ]
6280 );
6281 }
6282
6283 #[test]
6284 fn test_overlong_emphasis_with_nested_strong_wraps() {
6285 let options = ReflowOptions {
6287 line_length: 80,
6288 atomic_spans: true,
6289 ..Default::default()
6290 };
6291 let text = "_This is a very, very, very, very, very, very, very long line that exceeds 80 characters with some **bold** inside._";
6292 let lines = reflow_line(text, &options);
6293 assert_eq!(
6294 lines,
6295 vec![
6296 "_This is a very, very, very, very, very, very, very long line that exceeds 80",
6297 "characters with some **bold** inside._",
6298 ]
6299 );
6300 }
6301
6302 #[test]
6303 fn test_overlong_doubly_nested_span_wraps() {
6304 let options = ReflowOptions {
6309 line_length: 80,
6310 atomic_spans: true,
6311 ..Default::default()
6312 };
6313 let body = "This is a very, very, very, very, very, very, very, very, very, very long line that is emphasised.";
6314 for (open, close) in [
6315 ("***", "***"),
6316 ("___", "___"),
6317 ("**_", "_**"),
6318 ("*__", "__*"),
6319 ("**~~", "~~**"),
6320 ] {
6321 let text = format!("{open}{body}{close}");
6322 assert!(text.len() > options.line_length, "case must start over budget");
6323 let lines = reflow_line(&text, &options);
6324 assert!(
6325 lines.len() > 1,
6326 "{open}...{close} should wrap but stayed on one line: {lines:?}"
6327 );
6328 assert!(
6329 lines.iter().all(|line| line.len() <= options.line_length),
6330 "{open}...{close} left a line over the budget: {lines:?}"
6331 );
6332 assert_eq!(
6333 lines.join(" "),
6334 text,
6335 "{open}...{close} wrapping must only replace a space with a newline"
6336 );
6337 }
6338 }
6339
6340 #[test]
6341 fn test_overlong_span_with_stray_marker_stays_whole() {
6342 let options = ReflowOptions {
6346 line_length: 40,
6347 atomic_spans: true,
6348 ..Default::default()
6349 };
6350 let text = "**alpha * beta gamma delta epsilon zeta eta theta iota kappa**";
6351 let lines = reflow_line(text, &options);
6352 assert_eq!(lines, vec![text], "stray marker must keep the span whole");
6353 }
6354
6355 #[test]
6356 fn test_overlong_span_never_breaks_inside_a_nested_reference_link() {
6357 let options = ReflowOptions {
6363 line_length: 30,
6364 atomic_spans: true,
6365 defined_references: Some(HashSet::from([
6366 "ref".to_string(),
6367 "one two three four five six seven".to_string(),
6369 ])),
6370 ..Default::default()
6371 };
6372 for (text, link) in [
6373 (
6374 "_**alpha [one two three four five six seven][ref] beta gamma delta**_",
6375 "[one two three four five six seven][ref]",
6376 ),
6377 (
6378 "**alpha [one two three four five six seven][ref] beta gamma delta**",
6379 "[one two three four five six seven][ref]",
6380 ),
6381 (
6382 "_**alpha ![one two three four five six seven][ref] beta gamma delta**_",
6383 "![one two three four five six seven][ref]",
6384 ),
6385 (
6386 "_**alpha [one two three four five six seven][] beta gamma delta**_",
6387 "[one two three four five six seven][]",
6388 ),
6389 (
6390 "_**alpha [one two three four five six seven] beta gamma delta**_",
6391 "[one two three four five six seven]",
6392 ),
6393 ] {
6394 let lines = reflow_line(text, &options);
6395 assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
6396 assert!(
6397 lines.iter().any(|line| line.contains(link)),
6398 "{link} must stay on one line: {lines:?}"
6399 );
6400 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
6401 }
6402 }
6403
6404 #[test]
6405 fn test_overlong_span_breaks_inside_an_undefined_shortcut_reference() {
6406 let options = ReflowOptions {
6410 line_length: 30,
6411 atomic_spans: true,
6412 defined_references: Some(HashSet::new()),
6413 ..Default::default()
6414 };
6415 let text = "_**alpha [one two three four five six seven] beta gamma delta**_";
6416 let lines = reflow_line(text, &options);
6417 assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
6418 assert!(
6419 !lines
6420 .iter()
6421 .any(|line| line.contains("[one two three four five six seven]")),
6422 "an undefined shortcut is prose and should break: {lines:?}"
6423 );
6424 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
6425 }
6426
6427 #[test]
6428 fn test_overlong_span_never_breaks_inside_a_nested_attr_list() {
6429 let attr = "{.highlight key=\"a b c\"}";
6433 let text = format!("_**alpha beta gamma delta epsilon zeta{attr} eta theta iota kappa**_");
6434 let options = ReflowOptions {
6435 line_length: 20,
6436 atomic_spans: true,
6437 attr_lists: true,
6438 ..Default::default()
6439 };
6440 let lines = reflow_line(&text, &options);
6441 assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
6442 assert!(
6443 lines.iter().any(|line| line.contains(attr)),
6444 "attr list must stay on one line: {lines:?}"
6445 );
6446 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
6447
6448 let plain = ReflowOptions {
6451 attr_lists: false,
6452 ..options
6453 };
6454 let lines = reflow_line(&text, &plain);
6455 assert!(
6456 !lines.iter().any(|line| line.contains(attr)),
6457 "without the flavor the braces are prose and should break: {lines:?}"
6458 );
6459 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
6460 }
6461
6462 #[test]
6463 fn test_overlong_emphasis_never_breaks_inside_nested_code_span() {
6464 let options = ReflowOptions {
6468 line_length: 30,
6469 atomic_spans: true,
6470 ..Default::default()
6471 };
6472 let text = "_alpha beta gamma delta epsilon `a b` zeta eta theta iota kappa_";
6473 let lines = reflow_line(text, &options);
6474 assert!(lines.len() > 1, "over-long emphasis should wrap: {lines:?}");
6475 assert!(
6476 lines.iter().any(|line| line.contains("`a b`")),
6477 "nested code span must stay whole with its interior spaces: {lines:?}"
6478 );
6479 for line in &lines {
6480 assert_eq!(
6481 line.matches('`').count() % 2,
6482 0,
6483 "no line may contain half a code span: {line:?}"
6484 );
6485 }
6486 }
6487
6488 #[test]
6489 fn test_definition_list_marker_does_not_start_line() {
6490 let options = ReflowOptions {
6491 line_length: 20,
6492 ..Default::default()
6493 };
6494 let lines = reflow_line("This is a term and : definition here.", &options);
6496 for line in &lines {
6497 assert!(
6498 !line.trim_start().starts_with(": "),
6499 "Wrapped line should not start with definition marker: {line}"
6500 );
6501 }
6502 }
6503
6504 #[test]
6505 fn test_div_marker_does_not_start_line() {
6506 let options = ReflowOptions {
6507 line_length: 20,
6508 ..Default::default()
6509 };
6510 let lines = reflow_line("This is some text with ::: class marker.", &options);
6512 for line in &lines {
6513 assert!(
6514 !line.trim_start().starts_with(":::"),
6515 "Wrapped line should not start with div marker: {line}"
6516 );
6517 }
6518 }
6519}