1use crate::utils::calculate_indentation_width_default;
7use crate::utils::is_definition_list_item;
8use crate::utils::mkdocs_attr_list::{ATTR_LIST_PATTERN, is_standalone_attr_list};
9use crate::utils::mkdocs_snippets::is_snippet_block_delimiter;
10use crate::utils::regex_cache::{
11 DISPLAY_MATH_REGEX, EMAIL_PATTERN, EMOJI_SHORTCODE_REGEX, HTML_ENTITY_REGEX, HTML_TAG_PATTERN,
12 HUGO_SHORTCODE_REGEX, INLINE_MATH_REGEX, WIKI_LINK_REGEX,
13};
14use crate::utils::sentence_utils::{
15 get_abbreviations, is_cjk_char, is_cjk_sentence_ending, is_closing_quote, is_opening_quote,
16 text_ends_with_abbreviation,
17};
18use pulldown_cmark::{BrokenLink, CowStr, Event, LinkType, Options, Parser, Tag, TagEnd};
19use std::collections::HashSet;
20use unicode_width::UnicodeWidthStr;
21
22#[derive(Clone, Copy, Debug, Default, PartialEq)]
24pub enum ReflowLengthMode {
25 Chars,
27 #[default]
29 Visual,
30 Bytes,
32}
33
34fn display_len(s: &str, mode: ReflowLengthMode) -> usize {
36 match mode {
37 ReflowLengthMode::Chars => s.chars().count(),
38 ReflowLengthMode::Visual => s.width(),
39 ReflowLengthMode::Bytes => s.len(),
40 }
41}
42
43fn is_non_breaking_space(c: char) -> bool {
47 matches!(c, '\u{00A0}' | '\u{202F}' | '\u{2007}')
48}
49
50fn is_breakable_whitespace(c: char) -> bool {
55 c.is_whitespace() && !is_non_breaking_space(c)
56}
57
58fn split_breakable_words(text: &str) -> impl Iterator<Item = &str> {
60 text.split(is_breakable_whitespace).filter(|word| !word.is_empty())
61}
62
63fn code_span_wraps_losslessly(content: &str) -> bool {
72 let mut prev_ws = false;
73 for c in content.chars() {
74 let ws = is_breakable_whitespace(c);
75 if ws && (prev_ws || c != ' ') {
76 return false;
77 }
78 prev_ws = ws;
79 }
80 true
81}
82
83struct NestedStructure {
86 atomic: Vec<(usize, usize)>,
93 markers: Vec<(usize, usize)>,
98 links: Vec<(usize, usize)>,
103}
104
105struct OpenSpan {
107 span: (usize, usize),
109 content: Option<(usize, usize)>,
112}
113
114fn note_span_content(open: &mut [OpenSpan], start: usize, end: usize) {
117 for open_span in open.iter_mut() {
118 if start >= open_span.span.0 && end <= open_span.span.1 {
119 open_span.content = Some(match open_span.content {
120 Some((known_start, known_end)) => (known_start.min(start), known_end.max(end)),
121 None => (start, end),
122 });
123 }
124 }
125}
126
127fn merge_ranges(mut ranges: Vec<(usize, usize)>) -> Vec<(usize, usize)> {
128 ranges.sort_unstable();
131 let mut merged: Vec<(usize, usize)> = Vec::with_capacity(ranges.len());
132 for (start, end) in ranges {
133 match merged.last_mut() {
134 Some(last) if start <= last.1 => last.1 = last.1.max(end),
135 _ => merged.push((start, end)),
136 }
137 }
138 merged
139}
140
141fn nested_structure(content: &str, defined_references: Option<&HashSet<String>>, attr_lists: bool) -> NestedStructure {
143 let mut options = Options::empty();
144 options.insert(Options::ENABLE_STRIKETHROUGH);
145
146 let mut atomic: Vec<(usize, usize)> = Vec::new();
147 let mut markers: Vec<(usize, usize)> = Vec::new();
148 let mut links: Vec<(usize, usize)> = Vec::new();
149 let mut open: Vec<OpenSpan> = Vec::new();
152
153 for (event, range) in Parser::new_ext(content, options).into_offset_iter() {
154 let (start, end) = (range.start, range.end);
155 if !matches!(event, Event::End(_)) {
159 note_span_content(&mut open, start, end);
160 }
161 match event {
162 Event::Start(Tag::Link { .. } | Tag::Image { .. }) => {
163 atomic.push((start, end));
164 links.push((start, end));
165 }
166 Event::Code(_) | Event::InlineHtml(_) => {
167 atomic.push((start, end));
168 }
169 Event::Start(Tag::Emphasis | Tag::Strong | Tag::Strikethrough) => {
170 open.push(OpenSpan {
171 span: (start, end),
172 content: None,
173 });
174 }
175 Event::End(TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough) => {
176 if let Some(OpenSpan {
177 span: (span_start, span_end),
178 content,
179 }) = open.pop()
180 {
181 match content {
182 Some((content_start, content_end)) => {
186 markers.push((span_start, content_start));
187 markers.push((content_end, span_end));
188 }
189 None => atomic.push((span_start, span_end)),
192 }
193 }
194 }
195 _ => {}
196 }
197 }
198
199 for span in all_link_spans(content, defined_references) {
206 atomic.push((span.start, span.end));
207 links.push((span.start, span.end));
208 }
209
210 for found in WIKI_LINK_REGEX.find_iter(content) {
214 atomic.push((found.start(), found.end()));
215 links.push((found.start(), found.end()));
216 }
217 for found in HUGO_SHORTCODE_REGEX
218 .find_iter(content)
219 .chain(DISPLAY_MATH_REGEX.find_iter(content))
220 {
221 atomic.push((found.start(), found.end()));
222 }
223 let mut from = 0;
224 while let Ok(Some(found)) = INLINE_MATH_REGEX.find_from_pos(content, from) {
225 atomic.push((found.start(), found.end()));
226 from = found.end();
227 }
228
229 if attr_lists {
235 for found in ATTR_LIST_PATTERN.find_iter(content) {
236 atomic.push((found.start(), found.end()));
237 }
238 }
239
240 links.sort_unstable();
243 links.dedup();
244
245 NestedStructure {
246 atomic: merge_ranges(atomic),
247 markers: merge_ranges(markers),
248 links,
249 }
250}
251
252fn breakable_units<'a>(
275 content: &'a str,
276 defined_references: Option<&HashSet<String>>,
277 attr_lists: bool,
278) -> Option<Vec<&'a str>> {
279 if !content.contains(['`', '*', '_', '~', '[', '<', '$', '{']) {
282 return Some(split_breakable_words(content).collect());
283 }
284
285 let NestedStructure { atomic, markers, .. } = nested_structure(content, defined_references, attr_lists);
286
287 let mut units = Vec::new();
288 let mut unit_start = None;
289 let mut next_atomic = 0;
290 let mut next_marker = 0;
291 for (offset, ch) in content.char_indices() {
292 while atomic.get(next_atomic).is_some_and(|&(_, end)| end <= offset) {
293 next_atomic += 1;
294 }
295 if atomic.get(next_atomic).is_some_and(|&(start, _)| offset >= start) {
296 if unit_start.is_none() {
299 unit_start = Some(offset);
300 }
301 continue;
302 }
303 while markers.get(next_marker).is_some_and(|&(_, end)| end <= offset) {
304 next_marker += 1;
305 }
306 if matches!(ch, '`' | '*' | '_' | '~') && markers.get(next_marker).is_none_or(|&(start, _)| offset < start) {
307 return None;
308 }
309 if is_breakable_whitespace(ch) {
310 if let Some(start) = unit_start.take() {
311 units.push(&content[start..offset]);
312 }
313 } else if unit_start.is_none() {
314 unit_start = Some(offset);
315 }
316 }
317 if let Some(start) = unit_start {
318 units.push(&content[start..]);
319 }
320 Some(units)
321}
322
323#[derive(Clone)]
325pub struct ReflowOptions {
326 pub line_length: usize,
328 pub break_on_sentences: bool,
330 pub preserve_breaks: bool,
332 pub sentence_per_line: bool,
334 pub semantic_line_breaks: bool,
336 pub abbreviations: Option<Vec<String>>,
340 pub length_mode: ReflowLengthMode,
342 pub attr_lists: bool,
345 pub myst_roles: bool,
349 pub require_sentence_capital: bool,
354 pub max_list_continuation_indent: Option<usize>,
358 pub defined_references: Option<HashSet<String>>,
372 pub atomic_spans: bool,
376 pub length_exemptions: LengthExemptions,
379}
380
381#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
389pub struct LengthExemptions {
390 pub link_urls: bool,
393 pub code_spans: bool,
395}
396
397impl LengthExemptions {
398 fn any(&self) -> bool {
401 self.link_urls || self.code_spans
402 }
403}
404
405impl Default for ReflowOptions {
406 fn default() -> Self {
407 Self {
408 line_length: 80,
409 break_on_sentences: true,
410 preserve_breaks: false,
411 sentence_per_line: false,
412 semantic_line_breaks: false,
413 abbreviations: None,
414 length_mode: ReflowLengthMode::default(),
415 attr_lists: false,
416 myst_roles: false,
417 require_sentence_capital: true,
418 max_list_continuation_indent: None,
419 defined_references: None,
420 atomic_spans: true,
421 length_exemptions: LengthExemptions::default(),
422 }
423 }
424}
425
426#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
433struct LineWidth {
434 link_exempt: usize,
436 code_exempt: usize,
438}
439
440impl LineWidth {
441 fn plain(width: usize) -> Self {
444 Self {
445 link_exempt: width,
446 code_exempt: width,
447 }
448 }
449
450 fn effective(self) -> usize {
453 self.link_exempt.min(self.code_exempt)
454 }
455
456 fn fits(self, line_length: usize) -> bool {
457 self.effective() <= line_length
458 }
459
460 fn is_empty(self) -> bool {
464 self.link_exempt == 0 && self.code_exempt == 0
465 }
466}
467
468impl std::ops::Add for LineWidth {
469 type Output = Self;
470
471 fn add(self, other: Self) -> Self {
472 Self {
473 link_exempt: self.link_exempt + other.link_exempt,
474 code_exempt: self.code_exempt + other.code_exempt,
475 }
476 }
477}
478
479impl std::ops::AddAssign for LineWidth {
480 fn add_assign(&mut self, other: Self) {
481 *self = *self + other;
482 }
483}
484
485pub fn normalize_reference_label(label: &str) -> String {
492 label.split_whitespace().collect::<Vec<_>>().join(" ").to_lowercase()
493}
494
495fn footnote_refs_end(chars: &[char], start: usize) -> Option<usize> {
501 let mut pos = start;
502 let mut found = false;
503
504 loop {
505 if chars.get(pos) != Some(&'[') || chars.get(pos + 1) != Some(&'^') {
506 break;
507 }
508 let label_start = pos + 2;
509 let mut label_end = label_start;
510 while matches!(chars.get(label_end), Some(c) if c.is_ascii_alphanumeric() || *c == '_' || *c == '-') {
511 label_end += 1;
512 }
513 if label_end == label_start || chars.get(label_end) != Some(&']') {
514 break;
515 }
516 pos = label_end + 1;
517 found = true;
518 }
519
520 found.then_some(pos)
521}
522
523fn char_byte_offsets(chars: &[char]) -> Vec<usize> {
526 let mut offsets = Vec::with_capacity(chars.len() + 1);
527 let mut offset = 0;
528 for c in chars {
529 offsets.push(offset);
530 offset += c.len_utf8();
531 }
532 offsets.push(offset);
533 offsets
534}
535
536struct SentenceText<'a> {
541 text: &'a str,
542 chars: &'a [char],
543 char_offsets: &'a [usize],
544 links: &'a [(usize, usize)],
545}
546
547impl SentenceText<'_> {
548 fn link_end_at(&self, pos: usize) -> Option<usize> {
557 let range_start = match self.chars.get(pos) {
558 Some('[') => pos,
559 Some('!') if self.chars.get(pos + 1) == Some(&'[') => match self.link_range_end_at(pos) {
560 Some(end) => return Some(end),
561 None => pos + 1,
562 },
563 _ => return None,
564 };
565 self.link_range_end_at(range_start)
566 }
567
568 fn link_range_end_at(&self, pos: usize) -> Option<usize> {
570 let start = self.char_offsets[pos];
571 let idx = self.links.binary_search_by_key(&start, |&(s, _)| s).ok()?;
572 let end = self.links[idx].1;
573 Some(self.char_offsets.binary_search(&end).unwrap_or_else(|i| i))
574 }
575}
576
577fn is_sentence_boundary(
581 st: &SentenceText<'_>,
582 pos: usize,
583 abbreviations: &HashSet<String>,
584 require_sentence_capital: bool,
585) -> bool {
586 let SentenceText { text, chars, .. } = *st;
587 if pos + 1 >= chars.len() {
588 return false;
589 }
590 let byte_offset_after_punct = st.char_offsets[pos + 1];
591
592 let c = chars[pos];
593 let next_char = chars[pos + 1];
594
595 if is_cjk_sentence_ending(c) {
598 let mut after_punct_pos = pos + 1;
600 while after_punct_pos < chars.len()
601 && (chars[after_punct_pos] == '*' || chars[after_punct_pos] == '_' || chars[after_punct_pos] == '~')
602 {
603 after_punct_pos += 1;
604 }
605
606 while after_punct_pos < chars.len() && chars[after_punct_pos].is_whitespace() {
608 after_punct_pos += 1;
609 }
610
611 if after_punct_pos >= chars.len() {
613 return false;
614 }
615
616 while after_punct_pos < chars.len()
618 && (chars[after_punct_pos] == '*' || chars[after_punct_pos] == '_' || chars[after_punct_pos] == '~')
619 {
620 after_punct_pos += 1;
621 }
622
623 if after_punct_pos >= chars.len() {
624 return false;
625 }
626
627 return true;
630 }
631
632 if c != '.' && c != '!' && c != '?' {
634 return false;
635 }
636
637 let inside_quotation = is_closing_quote(next_char);
640
641 let (_space_pos, after_space_pos) = if next_char == ' ' {
643 (pos + 1, pos + 2)
645 } else if is_closing_quote(next_char) && pos + 2 < chars.len() {
646 if chars[pos + 2] == ' ' {
648 (pos + 2, pos + 3)
650 } else if (chars[pos + 2] == '*' || chars[pos + 2] == '_') && pos + 3 < chars.len() && chars[pos + 3] == ' ' {
651 (pos + 3, pos + 4)
653 } else if (chars[pos + 2] == '*' || chars[pos + 2] == '_')
654 && pos + 4 < chars.len()
655 && chars[pos + 3] == chars[pos + 2]
656 && chars[pos + 4] == ' '
657 {
658 (pos + 4, pos + 5)
660 } else {
661 return false;
662 }
663 } else if (next_char == '*' || next_char == '_') && pos + 2 < chars.len() && chars[pos + 2] == ' ' {
664 (pos + 2, pos + 3)
666 } else if (next_char == '*' || next_char == '_')
667 && pos + 3 < chars.len()
668 && chars[pos + 2] == next_char
669 && chars[pos + 3] == ' '
670 {
671 (pos + 3, pos + 4)
673 } else if next_char == '~' && pos + 3 < chars.len() && chars[pos + 2] == '~' && chars[pos + 3] == ' ' {
674 (pos + 3, pos + 4)
676 } else if next_char == '[' {
677 match footnote_refs_end(chars, pos + 1) {
683 Some(end_pos) if chars.get(end_pos) == Some(&' ') => (end_pos, end_pos + 1),
684 _ => return false,
685 }
686 } else {
687 return false;
688 };
689
690 let mut next_char_pos = after_space_pos;
692 while next_char_pos < chars.len() && chars[next_char_pos].is_whitespace() {
693 next_char_pos += 1;
694 }
695
696 if next_char_pos >= chars.len() {
698 return false;
699 }
700
701 let mut first_letter_pos = next_char_pos;
707 while first_letter_pos < chars.len() {
708 let ch = chars[first_letter_pos];
709 if let Some(end) = st.link_end_at(first_letter_pos) {
710 first_letter_pos += link_opener_len(chars, first_letter_pos, end);
711 } else if matches!(ch, '*' | '_' | '~') || is_opening_quote(ch) {
712 first_letter_pos += 1;
713 } else {
714 break;
715 }
716 }
717
718 if first_letter_pos >= chars.len() {
720 return false;
721 }
722
723 let first_char = chars[first_letter_pos];
724
725 if c == '!' || c == '?' {
731 return !inside_quotation || !require_sentence_capital || first_char.is_uppercase() || is_cjk_char(first_char);
732 }
733
734 if pos > 0 {
738 if text_ends_with_abbreviation(&text[..byte_offset_after_punct], abbreviations) {
740 return false;
741 }
742
743 if chars[pos - 1].is_numeric() && first_char.is_ascii_digit() {
745 return false;
746 }
747
748 if chars[pos - 1].is_ascii_uppercase() && (pos == 1 || (pos >= 2 && chars[pos - 2].is_whitespace())) {
752 return false;
753 }
754 }
755
756 if require_sentence_capital && !first_char.is_uppercase() && !is_cjk_char(first_char) {
759 return false;
760 }
761
762 true
763}
764
765fn link_opener_len(chars: &[char], pos: usize, end: usize) -> usize {
771 let open = if chars[pos] == '!' { pos + 1 } else { pos };
772 let body = open + 1;
773 if chars.get(body) != Some(&'[') {
774 return body - pos;
775 }
776 let body = body + 1;
777 let alias = chars[body..end.saturating_sub(2).max(body)]
778 .iter()
779 .position(|&c| c == '|')
780 .map_or(body, |p| body + p + 1);
781 alias - pos
782}
783
784pub fn split_into_sentences(text: &str, defined_references: Option<&HashSet<String>>) -> Vec<String> {
792 let abbreviations = get_abbreviations(&None);
793 split_into_sentences_with_set(text, &abbreviations, true, None, defined_references)
794}
795
796fn split_into_sentences_with_set(
806 text: &str,
807 abbreviations: &HashSet<String>,
808 require_sentence_capital: bool,
809 appended_span_start: Option<usize>,
810 defined_references: Option<&HashSet<String>>,
811) -> Vec<String> {
812 let char_vec: Vec<char> = text.chars().collect();
813 let char_offsets = char_byte_offsets(&char_vec);
814
815 let NestedStructure { atomic, links, .. } = sentence_structure(text, defined_references);
818 let mut atomic_it = atomic.iter().peekable();
819 let st = SentenceText {
820 text,
821 chars: &char_vec,
822 char_offsets: &char_offsets,
823 links: &links,
824 };
825
826 let mut sentences = Vec::new();
827 let mut current_sentence = String::new();
828 let mut pos = 0;
829
830 while pos < char_vec.len() {
831 let c = char_vec[pos];
832 current_sentence.push(c);
833
834 let byte_idx = char_offsets[pos];
835
836 while let Some(&&(_, end)) = atomic_it.peek() {
838 if end <= byte_idx {
839 atomic_it.next();
840 } else {
841 break;
842 }
843 }
844
845 let in_atomic = atomic_it
847 .peek()
848 .is_some_and(|&&(start, end)| byte_idx >= start && byte_idx < end);
849
850 if !in_atomic && is_sentence_boundary(&st, pos, abbreviations, require_sentence_capital) {
851 if let Some(end_pos) = footnote_refs_end(&char_vec, pos + 1) {
853 while pos + 1 < end_pos {
854 pos += 1;
855 current_sentence.push(char_vec[pos]);
856 }
857 }
858
859 while pos + 1 < char_vec.len() {
861 let next = char_vec[pos + 1];
862 if matches!(next, '*' | '_' | '~') && Some(char_offsets[pos + 1]) == appended_span_start {
863 break;
864 }
865 if next == '*' || next == '_' || next == '~' || is_closing_quote(next) {
866 pos += 1;
867 current_sentence.push(char_vec[pos]);
868 } else {
869 break;
870 }
871 }
872
873 if pos + 1 < char_vec.len() && char_vec[pos + 1] == ' ' {
875 pos += 1; }
877
878 sentences.push(current_sentence.trim().to_string());
879 current_sentence.clear();
880 }
881
882 pos += 1;
883 }
884
885 if !current_sentence.trim().is_empty() {
887 sentences.push(current_sentence.trim().to_string());
888 }
889 sentences
890}
891
892fn sentence_structure(text: &str, defined_references: Option<&HashSet<String>>) -> NestedStructure {
908 if !text.contains(['`', '[', '<', '$']) {
911 return NestedStructure {
912 atomic: Vec::new(),
913 markers: Vec::new(),
914 links: Vec::new(),
915 };
916 }
917 nested_structure(text, defined_references, false)
918}
919
920fn is_horizontal_rule(line: &str) -> bool {
922 if line.len() < 3 {
923 return false;
924 }
925
926 let mut chars = line.chars();
929 let Some(first_char) = chars.next() else {
930 return false;
931 };
932 if first_char != '-' && first_char != '_' && first_char != '*' {
933 return false;
934 }
935
936 let mut non_space_count = 1usize; for c in chars {
938 if c == ' ' {
939 continue;
940 }
941 if c != first_char {
942 return false;
943 }
944 non_space_count += 1;
945 }
946 non_space_count >= 3
947}
948
949fn is_numbered_list_item(line: &str) -> bool {
951 let mut chars = line.chars();
952
953 if !chars.next().is_some_and(char::is_numeric) {
955 return false;
956 }
957
958 while let Some(c) = chars.next() {
960 if c == '.' {
961 return chars.next() == Some(' ');
964 }
965 if !c.is_numeric() {
966 return false;
967 }
968 }
969
970 false
971}
972
973fn is_unordered_list_marker(s: &str) -> bool {
975 matches!(s.as_bytes().first(), Some(b'-' | b'*' | b'+'))
976 && !is_horizontal_rule(s)
977 && (s.len() == 1 || s.as_bytes().get(1) == Some(&b' '))
978}
979
980fn is_block_boundary_core(trimmed: &str) -> bool {
983 trimmed.is_empty()
984 || trimmed.starts_with('#')
985 || trimmed.starts_with("```")
986 || trimmed.starts_with("~~~")
987 || trimmed.starts_with('>')
988 || (trimmed.starts_with('[') && trimmed.contains("]:"))
989 || is_horizontal_rule(trimmed)
990 || is_unordered_list_marker(trimmed)
991 || is_numbered_list_item(trimmed)
992 || is_definition_list_item(trimmed)
993 || trimmed.starts_with(":::")
994}
995
996fn is_block_boundary(trimmed: &str) -> bool {
999 is_block_boundary_core(trimmed) || trimmed.starts_with('|')
1000}
1001
1002fn is_paragraph_boundary(trimmed: &str, line: &str) -> bool {
1006 is_block_boundary_core(trimmed)
1007 || calculate_indentation_width_default(line) >= 4
1008 || crate::utils::table_utils::TableUtils::is_potential_table_row(line)
1009}
1010
1011fn has_hard_break(line: &str) -> bool {
1017 let line = line.strip_suffix('\r').unwrap_or(line);
1018 line.ends_with(" ") || line.ends_with('\\')
1019}
1020
1021fn ends_with_sentence_punct(text: &str) -> bool {
1023 text.ends_with('.') || text.ends_with('!') || text.ends_with('?')
1024}
1025
1026fn trim_preserving_hard_break(s: &str) -> String {
1032 let s = s.strip_suffix('\r').unwrap_or(s);
1034
1035 if s.ends_with('\\') {
1037 return s.to_string();
1039 }
1040
1041 if s.ends_with(" ") {
1043 let content_end = s.trim_end().len();
1045 if content_end == 0 {
1046 return String::new();
1048 }
1049 format!("{} ", &s[..content_end])
1051 } else {
1052 s.trim_end().to_string()
1054 }
1055}
1056
1057fn parse_elements(text: &str, options: &ReflowOptions) -> Vec<Element> {
1059 parse_markdown_elements_inner(
1060 text,
1061 options.attr_lists,
1062 options.myst_roles,
1063 options.defined_references.as_ref(),
1064 )
1065}
1066
1067pub fn reflow_line(line: &str, options: &ReflowOptions) -> Vec<String> {
1077 let reflowed = reflow_line_unchecked(line, options);
1078 if preserves_content(line, &reflowed) {
1079 reflowed
1080 } else {
1081 vec![line.to_string()]
1082 }
1083}
1084
1085fn preserves_content(original: &str, reflowed: &[String]) -> bool {
1092 let (original_text, original_breaks) = visible_text_and_breaks(original.chars());
1093 let (reflowed_text, reflowed_breaks) =
1094 visible_text_and_breaks(reflowed.iter().flat_map(|line| line.chars().chain(['\n'])));
1095
1096 original_text == reflowed_text && contains_all(&reflowed_breaks, &original_breaks)
1097}
1098
1099fn visible_text_and_breaks(text: impl Iterator<Item = char>) -> (String, Vec<usize>) {
1102 let mut visible = String::new();
1103 let mut breaks = Vec::new();
1104 let mut count = 0usize;
1105 let mut pending_break = false;
1106
1107 for c in text {
1108 if c.is_whitespace() {
1109 pending_break = count > 0;
1110 } else {
1111 if pending_break {
1112 breaks.push(count);
1113 pending_break = false;
1114 }
1115 visible.push(c);
1116 count += 1;
1117 }
1118 }
1119
1120 (visible, breaks)
1121}
1122
1123fn contains_all(superset: &[usize], subset: &[usize]) -> bool {
1125 let mut candidates = superset.iter();
1126 subset
1127 .iter()
1128 .all(|wanted| candidates.by_ref().any(|found| found == wanted))
1129}
1130
1131fn reflow_line_unchecked(line: &str, options: &ReflowOptions) -> Vec<String> {
1132 if options.sentence_per_line {
1134 let elements = parse_elements(line, options);
1135 return merge_block_construct_continuations(reflow_elements_sentence_per_line(&elements, options));
1136 }
1137
1138 if options.semantic_line_breaks {
1140 let elements = parse_elements(line, options);
1141 return merge_block_construct_continuations(reflow_elements_semantic(&elements, options));
1142 }
1143
1144 if options.line_length == 0 || line_fits(line, options) {
1147 return vec![line.to_string()];
1148 }
1149
1150 let elements = parse_elements(line, options);
1152
1153 merge_block_construct_continuations(reflow_elements(&elements, options))
1155}
1156
1157#[derive(Debug, Clone)]
1159enum Element {
1160 Text(String),
1162 Link(String),
1164 ReferenceLink(String),
1166 EmptyReferenceLink(String),
1168 ShortcutReference(String),
1170 InlineImage(String),
1172 ReferenceImage(String),
1174 EmptyReferenceImage(String),
1176 LinkedImage(String),
1178 FootnoteReference(String),
1180 Strikethrough {
1182 content: String,
1183 double: bool,
1185 },
1186 WikiLink(String),
1188 InlineMath(String),
1190 DisplayMath(String),
1192 EmojiShortcode(String),
1194 Autolink(String),
1196 HtmlTag(String),
1198 HtmlEntity(String),
1200 HugoShortcode(String),
1202 AttrList(String),
1204 MystRole(String),
1208 Code { content: String, marker: String },
1210 Bold {
1212 content: String,
1213 underscore: bool,
1215 },
1216 Italic {
1218 content: String,
1219 underscore: bool,
1221 },
1222}
1223
1224impl Element {
1225 fn opens_with_bracket(&self) -> bool {
1230 matches!(
1231 self,
1232 Element::Link(_)
1233 | Element::ReferenceLink(_)
1234 | Element::EmptyReferenceLink(_)
1235 | Element::ShortcutReference(_)
1236 | Element::FootnoteReference(_)
1237 | Element::InlineImage(_)
1238 | Element::ReferenceImage(_)
1239 | Element::EmptyReferenceImage(_)
1240 | Element::LinkedImage(_)
1241 | Element::WikiLink(_)
1242 )
1243 }
1244}
1245
1246impl std::fmt::Display for Element {
1247 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1248 match self {
1249 Element::Text(s) => write!(f, "{s}"),
1250 Element::Link(s) => write!(f, "{s}"),
1251 Element::ReferenceLink(s) => write!(f, "{s}"),
1252 Element::EmptyReferenceLink(s) => write!(f, "{s}"),
1253 Element::ShortcutReference(s) => write!(f, "{s}"),
1254 Element::InlineImage(s) => write!(f, "{s}"),
1255 Element::ReferenceImage(s) => write!(f, "{s}"),
1256 Element::EmptyReferenceImage(s) => write!(f, "{s}"),
1257 Element::LinkedImage(s) => write!(f, "{s}"),
1258 Element::FootnoteReference(s) => write!(f, "{s}"),
1259 Element::Strikethrough { content, double } => {
1260 let marker = if *double { "~~" } else { "~" };
1261 write!(f, "{marker}{content}{marker}")
1262 }
1263 Element::WikiLink(s) => write!(f, "[[{s}]]"),
1264 Element::InlineMath(s) => write!(f, "${s}$"),
1265 Element::DisplayMath(s) => write!(f, "$${s}$$"),
1266 Element::EmojiShortcode(s) => write!(f, ":{s}:"),
1267 Element::Autolink(s) => write!(f, "{s}"),
1268 Element::HtmlTag(s) => write!(f, "{s}"),
1269 Element::HtmlEntity(s) => write!(f, "{s}"),
1270 Element::HugoShortcode(s) => write!(f, "{s}"),
1271 Element::AttrList(s) => write!(f, "{s}"),
1272 Element::MystRole(s) => write!(f, "{s}"),
1273 Element::Code { content, marker } => write!(f, "{marker}{content}{marker}"),
1274 Element::Bold { content, underscore } => {
1275 if *underscore {
1276 write!(f, "__{content}__")
1277 } else {
1278 write!(f, "**{content}**")
1279 }
1280 }
1281 Element::Italic { content, underscore } => {
1282 if *underscore {
1283 write!(f, "_{content}_")
1284 } else {
1285 write!(f, "*{content}*")
1286 }
1287 }
1288 }
1289 }
1290}
1291
1292impl Element {
1293 fn display_len(&self, mode: ReflowLengthMode) -> usize {
1294 match self {
1295 Element::Text(s)
1296 | Element::Link(s)
1297 | Element::ReferenceLink(s)
1298 | Element::EmptyReferenceLink(s)
1299 | Element::ShortcutReference(s)
1300 | Element::InlineImage(s)
1301 | Element::ReferenceImage(s)
1302 | Element::EmptyReferenceImage(s)
1303 | Element::LinkedImage(s)
1304 | Element::FootnoteReference(s)
1305 | Element::Autolink(s)
1306 | Element::HtmlTag(s)
1307 | Element::HtmlEntity(s)
1308 | Element::HugoShortcode(s)
1309 | Element::AttrList(s)
1310 | Element::MystRole(s) => display_len(s, mode),
1311 Element::WikiLink(s) => display_len(s, mode) + 4,
1312 Element::InlineMath(s) => display_len(s, mode) + 2,
1313 Element::DisplayMath(s) => display_len(s, mode) + 4,
1314 Element::EmojiShortcode(s) => display_len(s, mode) + 2,
1315 Element::Strikethrough { content, double } => display_len(content, mode) + if *double { 4 } else { 2 },
1316 Element::Code { content, marker } => display_len(content, mode) + display_len(marker, mode) * 2,
1317 Element::Bold { content, .. } => display_len(content, mode) + 4,
1318 Element::Italic { content, .. } => display_len(content, mode) + 2,
1319 }
1320 }
1321
1322 fn exempt_width(&self, mode: ReflowLengthMode, exemptions: LengthExemptions) -> LineWidth {
1333 let full = self.display_len(mode);
1334 let mut width = LineWidth::plain(full);
1335 match self {
1336 Element::Link(s) | Element::LinkedImage(s) if exemptions.link_urls => {
1337 if let Some(text) = bracketed_text(s, 0) {
1338 width.link_exempt = (2 + display_len(text, mode)).min(full);
1339 }
1340 }
1341 Element::InlineImage(s) if exemptions.link_urls => {
1342 if let Some(alt) = bracketed_text(s, 1) {
1343 width.link_exempt = (3 + display_len(alt, mode)).min(full);
1344 }
1345 }
1346 Element::Code { .. } if exemptions.code_spans => width.code_exempt = 0,
1347 _ => {}
1348 }
1349 width
1350 }
1351}
1352
1353fn bracketed_text(s: &str, open: usize) -> Option<&str> {
1360 let bytes = s.as_bytes();
1361 if bytes.get(open) != Some(&b'[') {
1362 return None;
1363 }
1364 let mut depth = 0usize;
1365 let mut in_code_span = false;
1366 let mut escaped = false;
1367 for (i, &byte) in bytes.iter().enumerate().skip(open + 1) {
1368 if escaped {
1369 escaped = false;
1370 continue;
1371 }
1372 match byte {
1373 b'\\' => escaped = true,
1374 b'`' => in_code_span = !in_code_span,
1375 b'[' if !in_code_span => depth += 1,
1376 b']' if !in_code_span => match depth.checked_sub(1) {
1377 Some(next) => depth = next,
1378 None => return s.get(open + 1..i),
1379 },
1380 _ => {}
1381 }
1382 }
1383 None
1384}
1385
1386#[derive(Debug, Clone)]
1388struct EmphasisSpan {
1389 start: usize,
1391 end: usize,
1393 content: String,
1395 is_strong: bool,
1397 is_strikethrough: bool,
1399 uses_underscore: bool,
1401 strikethrough_double: bool,
1404}
1405
1406fn extract_emphasis_and_code_spans(text: &str) -> (Vec<EmphasisSpan>, Vec<CodeSpan>) {
1416 let has_emphasis = text.contains(['*', '_', '~']);
1418 let has_code = text.contains('`');
1419 if !has_emphasis && !has_code {
1420 return (Vec::new(), Vec::new());
1421 }
1422
1423 let mut emphasis_spans = Vec::new();
1424 let mut code_spans = Vec::new();
1425
1426 let mut options = Options::empty();
1427 if has_emphasis {
1428 options.insert(Options::ENABLE_STRIKETHROUGH);
1429 }
1430
1431 let mut emphasis_stack: Vec<(usize, bool)> = Vec::new(); let mut strong_stack: Vec<(usize, bool)> = Vec::new();
1434 let mut strikethrough_stack: Vec<usize> = Vec::new();
1435
1436 let parser = Parser::new_ext(text, options).into_offset_iter();
1437
1438 for (event, range) in parser {
1439 match event {
1440 Event::Code(_) => {
1441 code_spans.push(CodeSpan {
1442 start: range.start,
1443 end: range.end,
1444 });
1445 }
1446 Event::Start(Tag::Emphasis) => {
1447 let uses_underscore = text.get(range.start..range.start + 1) == Some("_");
1449 emphasis_stack.push((range.start, uses_underscore));
1450 }
1451 Event::End(TagEnd::Emphasis) => {
1452 if let Some((start_byte, uses_underscore)) = emphasis_stack.pop() {
1453 let content_start = start_byte + 1;
1454 let content_end = range.end - 1;
1455 if content_end > content_start
1456 && let Some(content) = text.get(content_start..content_end)
1457 {
1458 emphasis_spans.push(EmphasisSpan {
1459 start: start_byte,
1460 end: range.end,
1461 content: content.to_string(),
1462 is_strong: false,
1463 is_strikethrough: false,
1464 uses_underscore,
1465 strikethrough_double: false,
1466 });
1467 }
1468 }
1469 }
1470 Event::Start(Tag::Strong) => {
1471 let uses_underscore = text.get(range.start..range.start + 2) == Some("__");
1472 strong_stack.push((range.start, uses_underscore));
1473 }
1474 Event::End(TagEnd::Strong) => {
1475 if let Some((start_byte, uses_underscore)) = strong_stack.pop() {
1476 let content_start = start_byte + 2;
1477 let content_end = range.end - 2;
1478 if content_end > content_start
1479 && let Some(content) = text.get(content_start..content_end)
1480 {
1481 emphasis_spans.push(EmphasisSpan {
1482 start: start_byte,
1483 end: range.end,
1484 content: content.to_string(),
1485 is_strong: true,
1486 is_strikethrough: false,
1487 uses_underscore,
1488 strikethrough_double: false,
1489 });
1490 }
1491 }
1492 }
1493 Event::Start(Tag::Strikethrough) => {
1494 strikethrough_stack.push(range.start);
1495 }
1496 Event::End(TagEnd::Strikethrough) => {
1497 if let Some(start_byte) = strikethrough_stack.pop() {
1498 let double = text.get(start_byte..start_byte + 2) == Some("~~");
1499 let marker_len = if double { 2 } else { 1 };
1500 let content_start = start_byte + marker_len;
1501 let content_end = range.end - marker_len;
1502 if content_end > content_start
1503 && let Some(content) = text.get(content_start..content_end)
1504 {
1505 emphasis_spans.push(EmphasisSpan {
1506 start: start_byte,
1507 end: range.end,
1508 content: content.to_string(),
1509 is_strong: false,
1510 is_strikethrough: true,
1511 uses_underscore: false,
1512 strikethrough_double: double,
1513 });
1514 }
1515 }
1516 }
1517 _ => {}
1518 }
1519 }
1520
1521 emphasis_spans.sort_by_key(|s| s.start);
1522 (emphasis_spans, code_spans)
1523}
1524
1525#[derive(Debug, Clone)]
1526struct CodeSpan {
1527 start: usize,
1528 end: usize,
1529}
1530
1531#[derive(Debug, Clone)]
1532struct LinkSpan {
1533 start: usize,
1534 end: usize,
1535 link_type: Option<LinkType>,
1536 is_image: bool,
1537 is_footnote: bool,
1538 depth: usize,
1541}
1542
1543fn extract_link_spans(text: &str, defined_references: Option<&HashSet<String>>) -> Vec<LinkSpan> {
1547 let mut spans = all_link_spans(text, defined_references);
1548 spans.retain(|span| span.depth == 0);
1549 spans
1550}
1551
1552fn all_link_spans(text: &str, defined_references: Option<&HashSet<String>>) -> Vec<LinkSpan> {
1555 if !text.contains('[') {
1558 return Vec::new();
1559 }
1560
1561 let mut spans = Vec::new();
1562 let mut options = Options::empty();
1563 options.insert(Options::ENABLE_FOOTNOTES);
1564
1565 let resolve = move |link: BrokenLink<'_>| -> Option<(CowStr<'_>, CowStr<'_>)> {
1582 let atomic = match link.link_type {
1587 LinkType::Shortcut | LinkType::ShortcutUnknown => match defined_references {
1588 Some(defs) => defs.contains(&normalize_reference_label(link.reference.as_ref())),
1589 None => true,
1590 },
1591 _ => true,
1592 };
1593 atomic.then_some((CowStr::Borrowed(""), CowStr::Borrowed("")))
1594 };
1595 let parser = Parser::new_with_broken_link_callback(text, options, Some(resolve)).into_offset_iter();
1596 let mut stack = Vec::new();
1597
1598 for (event, range) in parser {
1599 match event {
1600 Event::Start(Tag::Link { link_type, .. }) => {
1601 stack.push((range.start, Some(link_type), false));
1602 }
1603 Event::Start(Tag::Image { link_type, .. }) => {
1604 stack.push((range.start, Some(link_type), true));
1605 }
1606 Event::End(TagEnd::Link | TagEnd::Image) => {
1607 if let Some((start_byte, link_type, is_image)) = stack.pop() {
1608 spans.push(LinkSpan {
1609 start: start_byte,
1610 end: range.end,
1611 link_type,
1612 is_image,
1613 is_footnote: false,
1614 depth: stack.len(),
1615 });
1616 }
1617 }
1618 Event::FootnoteReference(_) => {
1619 spans.push(LinkSpan {
1620 start: range.start,
1621 end: range.end,
1622 link_type: None,
1623 is_image: false,
1624 is_footnote: true,
1625 depth: stack.len(),
1626 });
1627 }
1628 _ => {}
1629 }
1630 }
1631
1632 spans.sort_by_key(|s| s.start);
1633 spans
1634}
1635
1636fn myst_role_len_at(text: &str, absolute_pos: usize, code_spans: &[CodeSpan]) -> Option<usize> {
1644 let bytes = text.as_bytes();
1645 if bytes.first() != Some(&b'{') {
1646 return None;
1647 }
1648
1649 let mut j = 1;
1651 match bytes.get(j) {
1652 Some(&b) if b.is_ascii_alphabetic() || b == b'_' => {}
1653 _ => return None,
1654 }
1655 while let Some(&b) = bytes.get(j) {
1656 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b':' | b'.') {
1657 j += 1;
1658 } else {
1659 break;
1660 }
1661 }
1662 if bytes.get(j) != Some(&b'}') {
1663 return None;
1664 }
1665 j += 1; let code_span_start = absolute_pos + j;
1669 if let Ok(idx) = code_spans.binary_search_by_key(&code_span_start, |span| span.start) {
1670 let span = &code_spans[idx];
1671 let code_span_len = span.end - span.start;
1672 return Some(j + code_span_len);
1673 }
1674
1675 None
1676}
1677
1678fn inline_math_len_at_start(s: &str) -> Option<usize> {
1685 let bytes = s.as_bytes();
1686 if bytes.first() != Some(&b'$') || bytes.get(1) == Some(&b'$') {
1688 return None;
1689 }
1690 let close = 1 + s[1..].find('$')?;
1693 if bytes.get(close + 1) == Some(&b'$') {
1695 return None;
1696 }
1697 Some(close + 1)
1698}
1699
1700#[derive(Clone, Copy, Debug)]
1702struct PatternMatch {
1703 start: usize,
1704 end: usize,
1705}
1706
1707#[derive(Clone, Copy)]
1721enum PatternCache {
1722 Unsearched,
1723 NotFound,
1724 Found(PatternMatch),
1725}
1726
1727impl PatternCache {
1728 fn earliest_in(
1732 &mut self,
1733 remaining: &str,
1734 cursor: usize,
1735 find: impl FnOnce(&str) -> Option<(usize, usize)>,
1736 ) -> Option<(usize, usize)> {
1737 let stale = match self {
1738 PatternCache::Found(pm) => pm.start < cursor,
1739 PatternCache::NotFound => false,
1740 PatternCache::Unsearched => true,
1741 };
1742 if stale {
1743 *self = match find(remaining) {
1744 Some((start, end)) => PatternCache::Found(PatternMatch {
1745 start: cursor + start,
1746 end: cursor + end,
1747 }),
1748 None => PatternCache::NotFound,
1749 };
1750 }
1751 match self {
1752 PatternCache::Found(pm) => Some((pm.start - cursor, pm.end - cursor)),
1753 _ => None,
1754 }
1755 }
1756}
1757
1758fn parse_markdown_elements_inner(
1769 text: &str,
1770 attr_lists: bool,
1771 myst_roles: bool,
1772 defined_references: Option<&HashSet<String>>,
1773) -> Vec<Element> {
1774 let mut elements = Vec::new();
1775 let mut remaining = text;
1776
1777 let (emphasis_spans, code_spans) = extract_emphasis_and_code_spans(text);
1782 let link_spans = extract_link_spans(text, defined_references);
1783
1784 let mut cached_wiki_link = PatternCache::Unsearched;
1787 let mut cached_display_math = PatternCache::Unsearched;
1788 let mut cached_inline_math = PatternCache::Unsearched;
1789 let mut cached_emoji = PatternCache::Unsearched;
1790 let mut cached_html_entity = PatternCache::Unsearched;
1791 let mut cached_hugo_shortcode = PatternCache::Unsearched;
1792 let mut cached_html_tag = PatternCache::Unsearched;
1793 let mut cached_next_curly = PatternCache::Unsearched;
1794
1795 let mut link_span_idx = 0usize;
1799 let mut emphasis_span_idx = 0usize;
1800 let mut code_span_idx = 0usize;
1801
1802 while !remaining.is_empty() {
1803 let current_offset = text.len() - remaining.len();
1805 let mut earliest_match: Option<(usize, usize, &str)> = None;
1808
1809 while link_span_idx < link_spans.len() && link_spans[link_span_idx].start < current_offset {
1811 link_span_idx += 1;
1812 }
1813 let next_link: Option<&LinkSpan> = link_spans.get(link_span_idx);
1814
1815 if let Some(span) = next_link {
1816 let pos_in_remaining = span.start - current_offset;
1817 if earliest_match
1818 .as_ref()
1819 .is_none_or(|(start, _, _)| pos_in_remaining < *start)
1820 {
1821 let match_end = span.end - current_offset;
1822 earliest_match = Some((pos_in_remaining, match_end, "link_span"));
1823 }
1824 }
1825
1826 if let Some((start, end)) = cached_wiki_link.earliest_in(remaining, current_offset, |suffix| {
1828 WIKI_LINK_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1829 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1830 {
1831 earliest_match = Some((start, end, "wiki_link"));
1832 }
1833
1834 if let Some((start, end)) = cached_display_math.earliest_in(remaining, current_offset, |suffix| {
1836 DISPLAY_MATH_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1837 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1838 {
1839 earliest_match = Some((start, end, "display_math"));
1840 }
1841
1842 let inline_math_probe = if current_offset > 0 && text.as_bytes()[current_offset - 1] == b'$' {
1856 inline_math_len_at_start(remaining).map(|len| (0, len))
1857 } else {
1858 None
1859 };
1860 if let Some((start, end)) = inline_math_probe.or_else(|| {
1861 cached_inline_math.earliest_in(remaining, current_offset, |suffix| {
1862 INLINE_MATH_REGEX
1863 .find(suffix)
1864 .ok()
1865 .flatten()
1866 .map(|m| (m.start(), m.end()))
1867 })
1868 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1869 {
1870 earliest_match = Some((start, end, "inline_math"));
1871 }
1872
1873 if let Some((start, end)) = cached_emoji.earliest_in(remaining, current_offset, |suffix| {
1875 EMOJI_SHORTCODE_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1876 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1877 {
1878 earliest_match = Some((start, end, "emoji"));
1879 }
1880
1881 if let Some((start, end)) = cached_html_entity.earliest_in(remaining, current_offset, |suffix| {
1883 HTML_ENTITY_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1884 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1885 {
1886 earliest_match = Some((start, end, "html_entity"));
1887 }
1888
1889 if let Some((start, end)) = cached_hugo_shortcode.earliest_in(remaining, current_offset, |suffix| {
1892 HUGO_SHORTCODE_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1893 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1894 {
1895 earliest_match = Some((start, end, "hugo_shortcode"));
1896 }
1897
1898 if let Some((start, end)) = cached_html_tag.earliest_in(remaining, current_offset, |suffix| {
1905 let mut from = 0;
1906 while let Some(m) = HTML_TAG_PATTERN.find(&suffix[from..]) {
1907 let (tag_start, tag_end) = (from + m.start(), from + m.end());
1908 let tag = &suffix[tag_start..tag_end];
1909 let is_url_autolink = tag.starts_with("<http://")
1911 || tag.starts_with("<https://")
1912 || tag.starts_with("<mailto:")
1913 || tag.starts_with("<ftp://")
1914 || tag.starts_with("<ftps://");
1915 let is_email_autolink = {
1918 let content = tag.trim_start_matches('<').trim_end_matches('>');
1919 EMAIL_PATTERN.is_match(content)
1920 };
1921 if is_url_autolink || is_email_autolink {
1922 from = tag_end;
1923 } else {
1924 return Some((tag_start, tag_end));
1925 }
1926 }
1927 None
1928 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1929 {
1930 earliest_match = Some((start, end, "html_tag"));
1931 }
1932
1933 let mut next_special = remaining.len();
1935 let mut special_type = "";
1936 let mut pulldown_emphasis: Option<&EmphasisSpan> = None;
1937 let mut attr_list_len: usize = 0;
1938 let mut myst_role_len: usize = 0;
1939
1940 while code_span_idx < code_spans.len() && code_spans[code_span_idx].start < current_offset {
1942 code_span_idx += 1;
1943 }
1944 let next_code_span: Option<&CodeSpan> = code_spans.get(code_span_idx);
1945 if let Some(span) = next_code_span {
1946 let pos_in_remaining = span.start - current_offset;
1947 if pos_in_remaining < next_special {
1948 next_special = pos_in_remaining;
1949 special_type = "pulldown_code";
1950 }
1951 }
1952
1953 let next_curly_pos = cached_next_curly
1956 .earliest_in(remaining, current_offset, |suffix| {
1957 suffix.find('{').map(|pos| (pos, pos + 1))
1958 })
1959 .map(|(start, _)| start);
1960
1961 if myst_roles
1966 && let Some(pos) = next_curly_pos
1967 && pos < next_special
1968 && let Some(role_len) = myst_role_len_at(&remaining[pos..], current_offset + pos, &code_spans)
1969 {
1970 next_special = pos;
1971 special_type = "myst_role";
1972 myst_role_len = role_len;
1973 }
1974
1975 if attr_lists
1977 && let Some(pos) = next_curly_pos
1978 && pos < next_special
1979 && let Some(m) = ATTR_LIST_PATTERN.find(&remaining[pos..])
1980 && m.start() == 0
1981 {
1982 next_special = pos;
1983 special_type = "attr_list";
1984 attr_list_len = m.end();
1985 }
1986
1987 while emphasis_span_idx < emphasis_spans.len() && emphasis_spans[emphasis_span_idx].start < current_offset {
1989 emphasis_span_idx += 1;
1990 }
1991 if let Some(span) = emphasis_spans.get(emphasis_span_idx) {
1992 let pos_in_remaining = span.start - current_offset;
1993 if pos_in_remaining < next_special {
1994 next_special = pos_in_remaining;
1995 special_type = "pulldown_emphasis";
1996 pulldown_emphasis = Some(span);
1997 }
1998 }
1999
2000 let should_process_markdown_link = if let Some((pos, _, _)) = earliest_match {
2002 pos < next_special
2003 } else {
2004 false
2005 };
2006
2007 if should_process_markdown_link {
2008 let (pos, match_end, pattern_type) = earliest_match.unwrap();
2009
2010 if pos > 0 {
2012 elements.push(Element::Text(remaining[..pos].to_string()));
2013 }
2014
2015 match pattern_type {
2017 "link_span" => {
2018 let span = next_link.unwrap();
2019 let raw_text = remaining[pos..match_end].to_string();
2020 if span.is_footnote {
2021 elements.push(Element::FootnoteReference(raw_text));
2022 } else if span.is_image {
2023 match span.link_type {
2024 Some(LinkType::Inline) => elements.push(Element::InlineImage(raw_text)),
2025 Some(LinkType::Reference)
2028 | Some(LinkType::ReferenceUnknown)
2029 | Some(LinkType::Shortcut)
2030 | Some(LinkType::ShortcutUnknown) => elements.push(Element::ReferenceImage(raw_text)),
2031 Some(LinkType::Collapsed) | Some(LinkType::CollapsedUnknown) => {
2032 elements.push(Element::EmptyReferenceImage(raw_text))
2033 }
2034 _ => elements.push(Element::InlineImage(raw_text)),
2035 }
2036 } else {
2037 match span.link_type {
2038 Some(LinkType::Inline) => {
2039 if raw_text.starts_with('[') && raw_text.contains("![") {
2040 elements.push(Element::LinkedImage(raw_text));
2041 } else {
2042 elements.push(Element::Link(raw_text));
2043 }
2044 }
2045 Some(LinkType::Reference) | Some(LinkType::ReferenceUnknown) => {
2048 elements.push(Element::ReferenceLink(raw_text))
2049 }
2050 Some(LinkType::Collapsed) | Some(LinkType::CollapsedUnknown) => {
2051 elements.push(Element::EmptyReferenceLink(raw_text))
2052 }
2053 Some(LinkType::Shortcut) | Some(LinkType::ShortcutUnknown) => {
2054 elements.push(Element::ShortcutReference(raw_text))
2055 }
2056 Some(LinkType::Autolink) | Some(LinkType::Email) => {
2057 elements.push(Element::Autolink(raw_text))
2058 }
2059 _ => elements.push(Element::Link(raw_text)),
2060 }
2061 }
2062 remaining = &remaining[match_end..];
2063 }
2064 "wiki_link" => {
2065 if let Some(caps) = WIKI_LINK_REGEX.captures(remaining) {
2066 let content = caps.get(1).map_or("", |m| m.as_str());
2067 elements.push(Element::WikiLink(content.to_string()));
2068 remaining = &remaining[match_end..];
2069 } else {
2070 elements.push(Element::Text("[[".to_string()));
2071 remaining = &remaining[2..];
2072 }
2073 }
2074 "display_math" => {
2075 if let Some(caps) = DISPLAY_MATH_REGEX.captures(remaining) {
2076 let math = caps.get(1).map_or("", |m| m.as_str());
2077 elements.push(Element::DisplayMath(math.to_string()));
2078 remaining = &remaining[match_end..];
2079 } else {
2080 elements.push(Element::Text("$$".to_string()));
2081 remaining = &remaining[2..];
2082 }
2083 }
2084 "inline_math" => {
2085 if let Ok(Some(caps)) = INLINE_MATH_REGEX.captures(remaining) {
2086 let math = caps.get(1).map_or("", |m| m.as_str());
2087 elements.push(Element::InlineMath(math.to_string()));
2088 remaining = &remaining[match_end..];
2089 } else {
2090 elements.push(Element::Text("$".to_string()));
2091 remaining = &remaining[1..];
2092 }
2093 }
2094 "emoji" => {
2095 if let Some(caps) = EMOJI_SHORTCODE_REGEX.captures(remaining) {
2096 let emoji = caps.get(1).map_or("", |m| m.as_str());
2097 elements.push(Element::EmojiShortcode(emoji.to_string()));
2098 remaining = &remaining[match_end..];
2099 } else {
2100 elements.push(Element::Text(":".to_string()));
2101 remaining = &remaining[1..];
2102 }
2103 }
2104 "html_entity" => {
2105 elements.push(Element::HtmlEntity(remaining[pos..match_end].to_string()));
2107 remaining = &remaining[match_end..];
2108 }
2109 "hugo_shortcode" => {
2110 elements.push(Element::HugoShortcode(remaining[pos..match_end].to_string()));
2112 remaining = &remaining[match_end..];
2113 }
2114 "html_tag" => {
2115 elements.push(Element::HtmlTag(remaining[pos..match_end].to_string()));
2117 remaining = &remaining[match_end..];
2118 }
2119 _ => unreachable!("unknown pattern type: {}", pattern_type),
2120 }
2121 } else {
2122 if next_special > 0 && next_special < remaining.len() {
2126 elements.push(Element::Text(remaining[..next_special].to_string()));
2127 remaining = &remaining[next_special..];
2128 }
2129
2130 match special_type {
2132 "pulldown_code" => {
2133 let span = next_code_span.unwrap();
2134 let span_len = span.end - span.start;
2135 let code_raw = &remaining[..span_len];
2136 if let Some((content, marker)) = decompose_code_span(code_raw) {
2137 elements.push(Element::Code {
2138 content: content.to_string(),
2139 marker: marker.to_string(),
2140 });
2141 } else {
2142 elements.push(Element::Text(code_raw.to_string()));
2143 }
2144 remaining = &remaining[span_len..];
2145 }
2146 "attr_list" => {
2147 elements.push(Element::AttrList(remaining[..attr_list_len].to_string()));
2148 remaining = &remaining[attr_list_len..];
2149 }
2150 "myst_role" => {
2151 elements.push(Element::MystRole(remaining[..myst_role_len].to_string()));
2152 remaining = &remaining[myst_role_len..];
2153 }
2154 "pulldown_emphasis" => {
2155 let span = pulldown_emphasis.expect("pulldown_emphasis must be set");
2157 let span_len = span.end - span.start;
2158 if span.is_strikethrough {
2159 elements.push(Element::Strikethrough {
2160 content: span.content.clone(),
2161 double: span.strikethrough_double,
2162 });
2163 } else if span.is_strong {
2164 elements.push(Element::Bold {
2165 content: span.content.clone(),
2166 underscore: span.uses_underscore,
2167 });
2168 } else {
2169 elements.push(Element::Italic {
2170 content: span.content.clone(),
2171 underscore: span.uses_underscore,
2172 });
2173 }
2174 remaining = &remaining[span_len..];
2175 }
2176 _ => {
2177 elements.push(Element::Text(remaining.to_string()));
2179 break;
2180 }
2181 }
2182 }
2183 }
2184
2185 let mut merged_elements = Vec::new();
2187 for el in elements {
2188 match el {
2189 Element::Text(s) => {
2190 if let Some(Element::Text(last_s)) = merged_elements.last_mut() {
2191 last_s.push_str(&s);
2192 } else {
2193 merged_elements.push(Element::Text(s));
2194 }
2195 }
2196 other => merged_elements.push(other),
2197 }
2198 }
2199 merged_elements
2200}
2201
2202fn source_gap_before(elements: &[Element], idx: usize) -> &str {
2216 let Some(Element::Text(previous)) = idx.checked_sub(1).map(|prev| &elements[prev]) else {
2217 return "";
2218 };
2219
2220 let gap = &previous[previous.trim_end_matches(char::is_whitespace).len()..];
2221 if gap.is_empty() {
2222 ""
2223 } else if gap.contains(is_non_breaking_space) {
2224 gap
2225 } else {
2226 " "
2227 }
2228}
2229
2230fn push_source_gap(current_line: &mut String, gap: &str) {
2233 if !gap.is_empty() && !current_line.is_empty() && !current_line.ends_with(char::is_whitespace) {
2234 current_line.push_str(gap);
2235 }
2236}
2237
2238fn is_setext_or_thematic(text: &str) -> bool {
2244 let mut marker = 0u8;
2245 let mut count = 0usize;
2246 let mut has_space = false;
2247 for &b in text.as_bytes() {
2248 match b {
2249 b' ' | b'\t' => has_space = true,
2250 b'-' | b'=' | b'*' | b'_' => {
2251 if marker == 0 {
2252 marker = b;
2253 } else if b != marker {
2254 return false;
2255 }
2256 count += 1;
2257 }
2258 _ => return false,
2259 }
2260 }
2261 match marker {
2262 b'=' => !has_space,
2263 b'-' => !has_space || count >= 3,
2264 b'*' | b'_' => count >= 3,
2265 _ => false,
2266 }
2267}
2268
2269fn starts_block_construct(text: &str) -> bool {
2281 let text = text.trim_start();
2282 let bytes = text.as_bytes();
2283 let Some(&first) = bytes.first() else {
2284 return false;
2285 };
2286 let marker_then_boundary = |len: usize| bytes.len() == len || bytes[len] == b' ' || bytes[len] == b'\t';
2287 match first {
2288 b'>' => true,
2290 b'-' | b'*' | b'+' => marker_then_boundary(1) || is_setext_or_thematic(text),
2291 b'_' | b'=' => is_setext_or_thematic(text),
2292 b':' => is_definition_list_item(text) || text.starts_with(":::"),
2293 b'|' => true,
2294 b'#' => {
2295 let hashes = bytes.iter().take_while(|&&b| b == b'#').count();
2296 hashes <= 6 && marker_then_boundary(hashes)
2297 }
2298 b'`' => bytes.iter().take_while(|&&b| b == b'`').count() >= 3,
2299 b'~' => bytes.iter().take_while(|&&b| b == b'~').count() >= 3,
2300 b'0'..=b'9' => {
2307 let digits = bytes.iter().take_while(|b| b.is_ascii_digit()).count();
2308 digits <= 9
2309 && text[..digits].trim_start_matches('0') == "1"
2310 && bytes.len() > digits + 1
2311 && (bytes[digits] == b'.' || bytes[digits] == b')')
2312 && (bytes[digits + 1] == b' ' || bytes[digits + 1] == b'\t')
2313 }
2314 b'[' => {
2322 let mut escaped = false;
2323 let mut label_close = None;
2324 for (i, &b) in bytes.iter().enumerate().skip(1) {
2325 if escaped {
2326 escaped = false;
2327 } else if b == b'\\' {
2328 escaped = true;
2329 } else if b == b']' {
2330 label_close = Some(i);
2331 break;
2332 }
2333 }
2334 label_close.is_some_and(|i| bytes.get(i + 1) == Some(&b':'))
2335 }
2336 b'<' => crate::utils::html_block::parse_html_block_start(text).is_some(),
2339 _ => false,
2340 }
2341}
2342
2343fn merge_block_construct_continuations(lines: Vec<String>) -> Vec<String> {
2352 let mut merged: Vec<String> = Vec::with_capacity(lines.len());
2353 for line in lines {
2354 merged.push(line);
2355 while merged.len() > 1 && starts_block_construct(merged.last().expect("non-empty")) {
2359 let last = merged.pop().expect("non-empty");
2360 let prev = merged.last_mut().expect("len > 1");
2361 prev.push(' ');
2362 prev.push_str(last.trim_start());
2363 }
2364 }
2365 merged
2366}
2367
2368fn reflow_elements_sentence_per_line(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
2370 let abbreviations = get_abbreviations(&options.abbreviations);
2371 let require_sentence_capital = options.require_sentence_capital;
2372 let mut lines = Vec::new();
2373 let mut current_line = String::new();
2374
2375 for (idx, element) in elements.iter().enumerate() {
2376 let is_span = matches!(
2382 element,
2383 Element::Italic { .. } | Element::Bold { .. } | Element::Strikethrough { .. }
2384 );
2385 let piece = match element {
2386 Element::Text(text) => Some(text.clone()),
2388 Element::Italic { content, underscore } => Some(wrap_emphasis(
2389 content,
2390 if *underscore { "_" } else { "*" },
2391 &mut current_line,
2392 source_gap_before(elements, idx),
2393 )),
2394 Element::Bold { content, underscore } => Some(wrap_emphasis(
2395 content,
2396 if *underscore { "__" } else { "**" },
2397 &mut current_line,
2398 source_gap_before(elements, idx),
2399 )),
2400 Element::Strikethrough { content, double } => Some(wrap_emphasis(
2401 content,
2402 if *double { "~~" } else { "~" },
2403 &mut current_line,
2404 source_gap_before(elements, idx),
2405 )),
2406 _ => None,
2407 };
2408
2409 if let Some(piece) = piece {
2410 let appended_span_start = is_span.then_some(current_line.len());
2414 let combined = format!("{current_line}{piece}");
2415 let sentences = split_into_sentences_with_set(
2417 &combined,
2418 &abbreviations,
2419 require_sentence_capital,
2420 appended_span_start,
2421 options.defined_references.as_ref(),
2422 );
2423
2424 let next_bracketed = elements
2433 .get(idx + 1)
2434 .filter(|next| next.opens_with_bracket())
2435 .map(|next| (source_gap_before(elements, idx + 1), next.to_string()));
2436 let closes_before_next = |sentence: &str| -> bool {
2437 let Some((gap, next_str)) = &next_bracketed else {
2438 return true;
2439 };
2440 let mut probe = sentence.to_string();
2441 push_source_gap(&mut probe, gap);
2442 probe.push_str(next_str);
2443 let probe_sentences = split_into_sentences_with_set(
2444 &probe,
2445 &abbreviations,
2446 require_sentence_capital,
2447 None,
2448 options.defined_references.as_ref(),
2449 );
2450 probe_sentences.last().is_some_and(|last| last == next_str)
2451 };
2452
2453 if sentences.len() > 1 {
2454 let mut pending = String::new();
2458 let last = sentences.len() - 1;
2459 for (i, sentence) in sentences.iter().enumerate() {
2460 if !pending.is_empty() {
2461 pending.push(' ');
2462 }
2463 pending.push_str(sentence);
2464
2465 let closed = i < last || (ends_with_sentence_punct(&pending) && closes_before_next(&pending));
2470 if closed && !text_ends_with_abbreviation(&pending, &abbreviations) {
2471 lines.push(std::mem::take(&mut pending));
2472 }
2473 }
2474 current_line = pending;
2475 } else {
2476 let trimmed = combined.trim();
2478
2479 if trimmed.is_empty() {
2483 continue;
2484 }
2485
2486 let ends_with_sentence_punct = ends_with_sentence_punct(trimmed);
2487
2488 if ends_with_sentence_punct
2489 && !text_ends_with_abbreviation(trimmed, &abbreviations)
2490 && closes_before_next(trimmed)
2491 {
2492 lines.push(combined.trim_matches(is_breakable_whitespace).to_string());
2495 current_line.clear();
2496 } else {
2497 current_line = combined;
2499 }
2500 }
2501 } else {
2502 let element_str = format!("{element}");
2504 push_source_gap(&mut current_line, source_gap_before(elements, idx));
2505 current_line.push_str(&element_str);
2506 }
2507 }
2508
2509 if !current_line.is_empty() {
2511 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
2512 }
2513 lines
2514}
2515
2516fn wrap_emphasis(content: &str, marker: &str, current_line: &mut String, gap: &str) -> String {
2520 push_source_gap(current_line, gap);
2521 format!("{marker}{content}{marker}")
2522}
2523
2524const BREAK_WORDS: &[&str] = &[
2528 "and",
2529 "or",
2530 "but",
2531 "nor",
2532 "yet",
2533 "so",
2534 "for",
2535 "which",
2536 "that",
2537 "because",
2538 "when",
2539 "if",
2540 "while",
2541 "where",
2542 "although",
2543 "though",
2544 "unless",
2545 "since",
2546 "after",
2547 "before",
2548 "until",
2549 "as",
2550 "once",
2551 "whether",
2552 "however",
2553 "therefore",
2554 "moreover",
2555 "furthermore",
2556 "nevertheless",
2557 "whereas",
2558];
2559
2560fn is_clause_punctuation(c: char) -> bool {
2562 matches!(c, ',' | ';' | ':' | '\u{2014}') }
2564
2565fn clause_break_allowed_after(chars: &[char], i: usize) -> bool {
2575 match chars.get(i + 1) {
2576 None => true,
2577 Some(next) => is_breakable_whitespace(*next),
2578 }
2579}
2580
2581fn paren_group_end<'a>(slice: &'a str, element_spans: &[ElementSpan], offset: usize) -> Option<(usize, &'a str)> {
2595 debug_assert!(slice.starts_with('('));
2596 let mut depth: i32 = 0;
2597 for (local_byte, c) in slice.char_indices() {
2598 let global_byte = offset + local_byte;
2599 if depth > 0 && is_inside_element(global_byte, element_spans) {
2604 continue;
2605 }
2606 match c {
2607 '(' => depth += 1,
2608 ')' => {
2609 depth -= 1;
2610 if depth == 0 {
2611 let end = local_byte + 1;
2612 let inner = &slice[1..local_byte];
2613 return Some((end, inner));
2614 }
2615 }
2616 _ => {}
2617 }
2618 }
2619 None
2620}
2621
2622fn split_at_parenthetical(
2639 text: &str,
2640 line_length: usize,
2641 element_spans: &[ElementSpan],
2642 length_mode: ReflowLengthMode,
2643) -> Option<(String, String)> {
2644 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
2645
2646 if text.starts_with('(')
2648 && let Some((end_local, inner)) = paren_group_end(text, element_spans, 0)
2649 && inner.contains(' ')
2650 {
2651 let mut first_end = end_local;
2658 loop {
2659 first_end += text[first_end..]
2660 .char_indices()
2661 .take_while(|(_, c)| !is_breakable_whitespace(*c))
2662 .last()
2663 .map_or(0, |(idx, c)| idx + c.len_utf8());
2664 match element_containing(first_end, element_spans) {
2665 Some(span) => first_end = span.end,
2666 None => break,
2667 }
2668 }
2669 let rest_start = first_end;
2670 let first = &text[..first_end];
2671 if measure(first, 0, element_spans, length_mode).fits(line_length) {
2674 let rest = text[rest_start..].trim_start();
2675 if !rest.is_empty() {
2676 return Some((first.to_string(), rest.to_string()));
2677 }
2678 }
2679 }
2680
2681 let mut best_open_byte: Option<usize> = None;
2683 let mut pos = 0usize;
2684 while pos < text.len() {
2685 if text.as_bytes()[pos] != b'(' {
2687 let c = text[pos..].chars().next().unwrap();
2688 pos += c.len_utf8();
2689 continue;
2690 }
2691 if is_inside_element(pos, element_spans) {
2693 pos += 1;
2694 continue;
2695 }
2696 if let Some((end_local, inner)) = paren_group_end(&text[pos..], element_spans, pos) {
2697 let first = text[..pos].trim_end_matches(is_breakable_whitespace);
2698 let first_len = measure(first, 0, element_spans, length_mode).effective();
2699 if first.len() < pos
2702 && !first.is_empty()
2703 && first_len >= min_first_len
2704 && first_len <= line_length
2705 && inner.contains(' ')
2706 && best_open_byte.is_none_or(|prev| pos > prev)
2707 {
2708 best_open_byte = Some(pos);
2709 }
2710 pos += end_local;
2711 } else {
2712 pos += 1;
2713 }
2714 }
2715
2716 let open_byte = best_open_byte?;
2717 let first = text[..open_byte].trim_end_matches(is_breakable_whitespace).to_string();
2718 let rest = text[open_byte..].to_string();
2719 if first.is_empty() || rest.trim().is_empty() {
2720 return None;
2721 }
2722 Some((first, rest))
2723}
2724
2725#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2733struct ElementSpan {
2734 start: usize,
2735 end: usize,
2736 full: usize,
2737 link_saving: usize,
2740 code_saving: usize,
2742 is_hard: bool,
2744}
2745
2746impl ElementSpan {
2747 fn new(start: usize, len: usize, full: usize, width: LineWidth, is_hard: bool) -> Self {
2750 Self {
2751 start,
2752 end: start + len,
2753 full,
2754 link_saving: full - width.link_exempt,
2755 code_saving: full - width.code_exempt,
2756 is_hard,
2757 }
2758 }
2759
2760 fn contains(&self, pos: usize) -> bool {
2761 pos > self.start && pos < self.end
2762 }
2763
2764 fn within(&self, start: usize, end: usize) -> bool {
2765 self.start >= start && self.end <= end
2766 }
2767
2768 fn exempt_width(&self) -> LineWidth {
2769 LineWidth {
2770 link_exempt: self.full - self.link_saving,
2771 code_exempt: self.full - self.code_saving,
2772 }
2773 }
2774}
2775
2776fn compute_element_spans(
2782 elements: &[Element],
2783 mode: ReflowLengthMode,
2784 exemptions: LengthExemptions,
2785) -> Vec<ElementSpan> {
2786 let mut spans = Vec::new();
2787 let mut offset = 0;
2788 for element in elements {
2789 let len = element.display_len(ReflowLengthMode::Bytes);
2790 if !matches!(element, Element::Text(_)) {
2791 let full = element.display_len(mode);
2792 let width = element.exempt_width(mode, exemptions);
2793 let is_hard = match element {
2794 Element::Bold { content, .. }
2795 | Element::Italic { content, .. }
2796 | Element::Strikethrough { content, .. } => content.contains(['[', '`', '<', '$', '{']),
2797 _ => true,
2798 };
2799 spans.push(ElementSpan::new(offset, len, full, width, is_hard));
2800 }
2801 offset += len;
2802 }
2803 spans
2804}
2805
2806fn measure(text: &str, offset: usize, spans: &[ElementSpan], mode: ReflowLengthMode) -> LineWidth {
2814 let full = display_len(text, mode);
2815 let end = offset + text.len();
2816 let mut width = LineWidth::plain(full);
2817 for span in spans.iter().filter(|span| span.within(offset, end)) {
2818 width.link_exempt -= span.link_saving;
2819 width.code_exempt -= span.code_saving;
2820 }
2821 width
2822}
2823
2824fn line_width_components(line: &str, options: &ReflowOptions) -> LineWidth {
2829 let raw = display_len(line, options.length_mode);
2830 if !options.length_exemptions.any() {
2831 return LineWidth::plain(raw);
2832 }
2833 let elements = parse_markdown_elements_inner(
2834 line,
2835 options.attr_lists,
2836 options.myst_roles,
2837 options.defined_references.as_ref(),
2838 );
2839 let spans = compute_element_spans(&elements, options.length_mode, options.length_exemptions);
2840 measure(line, 0, &spans, options.length_mode)
2841}
2842
2843fn line_width(line: &str, options: &ReflowOptions) -> usize {
2845 line_width_components(line, options).effective()
2846}
2847
2848fn line_fits(line: &str, options: &ReflowOptions) -> bool {
2854 display_len(line, options.length_mode) <= options.line_length || line_width(line, options) <= options.line_length
2855}
2856
2857fn element_containing(pos: usize, spans: &[ElementSpan]) -> Option<ElementSpan> {
2859 spans.iter().copied().find(|span| span.contains(pos))
2860}
2861
2862fn is_inside_element(pos: usize, spans: &[ElementSpan]) -> bool {
2864 element_containing(pos, spans).is_some()
2865}
2866
2867const MIN_SPLIT_RATIO: f64 = 0.3;
2870
2871fn split_at_clause_punctuation(
2875 text: &str,
2876 line_length: usize,
2877 element_spans: &[ElementSpan],
2878 length_mode: ReflowLengthMode,
2879) -> Option<(String, String)> {
2880 let chars: Vec<char> = text.chars().collect();
2881 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
2882
2883 let mut width_acc = LineWidth::default();
2889 let mut search_end_char = 0;
2890 let mut byte = 0usize;
2891 let mut idx = 0usize;
2892 while idx < chars.len() {
2893 let (advance_chars, advance_bytes, width) = match element_spans.iter().find(|s| s.start == byte) {
2894 Some(span) => {
2895 let source = &text[span.start..span.end];
2896 (
2897 source.chars().count(),
2898 source.len(),
2899 measure(source, span.start, element_spans, length_mode),
2900 )
2901 }
2902 None => {
2903 let c = chars[idx];
2904 (
2905 1,
2906 c.len_utf8(),
2907 LineWidth::plain(display_len(&c.to_string(), length_mode)),
2908 )
2909 }
2910 };
2911 if !(width_acc + width).fits(line_length) {
2912 break;
2913 }
2914 width_acc += width;
2915 byte += advance_bytes;
2916 idx += advance_chars;
2917 search_end_char = idx;
2918 }
2919
2920 let mut paren_depth: i32 = 0;
2927 let mut best_pos = None;
2928 for i in (0..search_end_char).rev() {
2929 let byte_start: usize = chars[..i].iter().map(|c| c.len_utf8()).sum();
2931 let byte_after: usize = byte_start + chars[i].len_utf8();
2933
2934 if !is_inside_element(byte_start, element_spans) {
2935 match chars[i] {
2936 ')' => paren_depth += 1,
2937 '(' => paren_depth = paren_depth.saturating_sub(1),
2938 _ => {}
2939 }
2940 }
2941
2942 if paren_depth == 0
2943 && is_clause_punctuation(chars[i])
2944 && clause_break_allowed_after(&chars, i)
2945 && !is_inside_element(byte_after, element_spans)
2946 {
2947 best_pos = Some(i);
2948 break;
2949 }
2950 }
2951
2952 let pos = best_pos?;
2953
2954 let first: String = chars[..=pos].iter().collect();
2956 if measure(&first, 0, element_spans, length_mode).effective() < min_first_len {
2957 return None;
2958 }
2959
2960 let rest: String = chars[pos + 1..].iter().collect();
2962 let rest = rest.trim_start().to_string();
2963
2964 if rest.is_empty() {
2965 return None;
2966 }
2967
2968 Some((first, rest))
2969}
2970
2971fn paren_depth_map(text: &str, element_spans: &[ElementSpan]) -> Vec<i32> {
2978 let mut map = vec![0i32; text.len()];
2979 let mut depth = 0i32;
2980 for (byte, c) in text.char_indices() {
2981 if !is_inside_element(byte, element_spans) {
2982 match c {
2983 '(' => depth += 1,
2984 ')' => depth = depth.saturating_sub(1),
2985 _ => {}
2986 }
2987 }
2988 let end = (byte + c.len_utf8()).min(map.len());
2990 for slot in &mut map[byte..end] {
2991 *slot = depth;
2992 }
2993 }
2994 map
2995}
2996
2997fn is_standalone_parenthetical(line: &str) -> bool {
3006 let trimmed = line.trim();
3007 if !trimmed.starts_with('(') {
3008 return false;
3009 }
3010 let Some(close) = trimmed.rfind(')') else {
3013 return false;
3014 };
3015 if trimmed[close + 1..].contains(char::is_whitespace) {
3016 return false;
3017 }
3018 let core = &trimmed[..=close];
3019 let inner = &core[1..core.len() - 1];
3021 if !inner.contains(' ') {
3022 return false;
3023 }
3024 let mut depth = 0i32;
3026 for c in core.chars() {
3027 match c {
3028 '(' => depth += 1,
3029 ')' => depth -= 1,
3030 _ => {}
3031 }
3032 if depth < 0 {
3033 return false;
3034 }
3035 }
3036 depth == 0
3037}
3038
3039fn split_at_break_word(
3043 text: &str,
3044 line_length: usize,
3045 element_spans: &[ElementSpan],
3046 length_mode: ReflowLengthMode,
3047) -> Option<(String, String)> {
3048 let lower = text.to_lowercase();
3049 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
3050 let mut best_split: Option<(usize, usize)> = None; let depth_map = paren_depth_map(text, element_spans);
3055
3056 for &word in BREAK_WORDS {
3057 let mut search_start = 0;
3058 while let Some(pos) = lower[search_start..].find(word) {
3059 let abs_pos = search_start + pos;
3060
3061 let preceded_by_space = abs_pos == 0 || text.as_bytes().get(abs_pos - 1) == Some(&b' ');
3063 let followed_by_space = text.as_bytes().get(abs_pos + word.len()) == Some(&b' ');
3064
3065 if preceded_by_space && followed_by_space {
3066 let first_part = text[..abs_pos].trim_end();
3068 let first_part_len = measure(first_part, 0, element_spans, length_mode).effective();
3069
3070 let inside_paren = depth_map.get(abs_pos).is_some_and(|&d| d > 0);
3072
3073 if first_part_len >= min_first_len
3074 && first_part_len <= line_length
3075 && !is_inside_element(abs_pos, element_spans)
3076 && !inside_paren
3077 {
3078 if best_split.is_none_or(|(prev_pos, _)| abs_pos > prev_pos) {
3080 best_split = Some((abs_pos, word.len()));
3081 }
3082 }
3083 }
3084
3085 search_start = abs_pos + word.len();
3086 }
3087 }
3088
3089 let (byte_start, _word_len) = best_split?;
3090
3091 let first = text[..byte_start].trim_end().to_string();
3092 let rest = text[byte_start..].to_string();
3093
3094 if first.is_empty() || rest.trim().is_empty() {
3095 return None;
3096 }
3097
3098 Some((first, rest))
3099}
3100
3101fn replaces_whitespace(text: &str, first: &str, rest: &str, element_spans: &[ElementSpan]) -> bool {
3112 if !text.starts_with(first) || !text.ends_with(rest) {
3113 return false;
3114 }
3115 let gap_end = text.len() - rest.len();
3116 gap_end > first.len()
3117 && text[first.len()..gap_end].chars().all(is_breakable_whitespace)
3118 && !element_spans
3119 .iter()
3120 .any(|span| first.len() < span.end && span.start < gap_end)
3121}
3122
3123fn cascade_split_line(text: &str, options: &ReflowOptions) -> Vec<String> {
3134 let line_length = options.line_length;
3135 let length_mode = options.length_mode;
3136 let attr_lists = options.attr_lists;
3137 let myst_roles = options.myst_roles;
3138 let defined_references = options.defined_references.as_ref();
3139 if line_length == 0 || display_len(text, length_mode) <= line_length {
3140 return vec![text.to_string()];
3141 }
3142
3143 let elements = parse_markdown_elements_inner(text, attr_lists, myst_roles, defined_references);
3144 let element_spans = compute_element_spans(&elements, length_mode, options.length_exemptions);
3145
3146 if measure(text, 0, &element_spans, length_mode).fits(line_length) {
3149 return vec![text.to_string()];
3150 }
3151
3152 let rebased_spans = |start: usize| -> Vec<ElementSpan> {
3156 if start == 0 {
3157 return element_spans.clone();
3158 }
3159 element_spans
3160 .iter()
3161 .filter(|span| span.end > start)
3162 .map(|span| ElementSpan {
3163 start: span.start.saturating_sub(start),
3164 end: span.end.saturating_sub(start),
3165 ..*span
3166 })
3167 .collect()
3168 };
3169
3170 let mut result = Vec::new();
3171 let mut start = 0usize;
3172
3173 loop {
3174 let remaining = &text[start..];
3175 let spans = rebased_spans(start);
3176 if measure(remaining, 0, &spans, length_mode).fits(line_length) {
3177 result.push(remaining.to_string());
3178 return result;
3179 }
3180
3181 let at_whitespace = |candidate: Option<(String, String)>| {
3190 candidate.filter(|(first, rest)| replaces_whitespace(remaining, first, rest, &spans))
3191 };
3192 let split = at_whitespace(split_at_parenthetical(remaining, line_length, &spans, length_mode))
3193 .or_else(|| at_whitespace(split_at_clause_punctuation(remaining, line_length, &spans, length_mode)))
3194 .or_else(|| at_whitespace(split_at_break_word(remaining, line_length, &spans, length_mode)));
3195
3196 if let Some((first, rest)) = split {
3197 let consumed = remaining.len().saturating_sub(rest.len());
3198 if consumed == 0 {
3201 break;
3202 }
3203 result.push(first);
3204 start += consumed;
3205 continue;
3206 }
3207
3208 break;
3210 }
3211
3212 let mut fallback_options = options.clone();
3214 fallback_options.break_on_sentences = false;
3215 fallback_options.preserve_breaks = false;
3216 fallback_options.sentence_per_line = false;
3217 fallback_options.semantic_line_breaks = false;
3218 fallback_options.require_sentence_capital = true;
3219 fallback_options.max_list_continuation_indent = None;
3220 fallback_options.defined_references = None;
3221 let remaining = &text[start..];
3222 let tail_elements = if start == 0 {
3223 elements
3224 } else {
3225 parse_markdown_elements_inner(remaining, attr_lists, myst_roles, defined_references)
3226 };
3227 result.extend(reflow_elements(&tail_elements, &fallback_options));
3228 result
3229}
3230
3231fn reflow_elements_semantic(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
3235 let sentence_lines = reflow_elements_sentence_per_line(elements, options);
3237
3238 if options.line_length == 0 {
3241 return sentence_lines;
3242 }
3243
3244 let mut result = Vec::new();
3245 for line in sentence_lines {
3246 if line_fits(&line, options) {
3247 result.push(line);
3248 } else {
3249 result.extend(cascade_split_line(&line, options));
3250 }
3251 }
3252
3253 let min_line_len = ((options.line_length as f64) * MIN_SPLIT_RATIO) as usize;
3256 let mut merged: Vec<String> = Vec::with_capacity(result.len());
3257 for line in result {
3258 if !merged.is_empty() && line_width(&line, options) < min_line_len && !line.trim().is_empty() {
3259 if is_standalone_parenthetical(&line) {
3262 merged.push(line);
3263 continue;
3264 }
3265
3266 let prev_ends_at_sentence = {
3268 let trimmed = merged.last().unwrap().trim_end();
3269 trimmed
3270 .chars()
3271 .rev()
3272 .find(|c| !matches!(c, '"' | '\'' | '\u{201D}' | '\u{2019}' | ')' | ']'))
3273 .is_some_and(|c| matches!(c, '.' | '!' | '?'))
3274 };
3275
3276 if !prev_ends_at_sentence {
3277 let prev = merged.last_mut().unwrap();
3278 let combined = format!("{prev} {line}");
3279 if line_fits(&combined, options) {
3281 *prev = combined;
3282 continue;
3283 }
3284 }
3285 }
3286 merged.push(line);
3287 }
3288 merged
3289}
3290
3291fn rfind_safe_space(
3301 line: &str,
3302 element_spans: &[ElementSpan],
3303 options: &ReflowOptions,
3304 relax_soft_spans: bool,
3305) -> Option<usize> {
3306 line.char_indices().rev().map(|(pos, _)| pos).find(|&pos| {
3307 line.as_bytes()[pos] == b' '
3308 && !is_inside_element_filtered(pos, element_spans, options, relax_soft_spans)
3309 && !starts_block_construct(&line[pos + 1..])
3310 })
3311}
3312
3313fn is_inside_element_filtered(
3314 pos: usize,
3315 spans: &[ElementSpan],
3316 options: &ReflowOptions,
3317 relax_soft_spans: bool,
3318) -> bool {
3319 spans.iter().any(|span| {
3320 span.contains(pos)
3321 && (!relax_soft_spans
3322 || span.is_hard
3323 || (options.atomic_spans && span.exempt_width().fits(options.line_length)))
3324 })
3325}
3326
3327#[derive(Clone, Copy)]
3332struct Attached<'a> {
3333 text: &'a str,
3334 width: LineWidth,
3335 separator: &'a str,
3336}
3337
3338fn break_before_attached(
3355 lines: &mut Vec<String>,
3356 current_line: &mut String,
3357 current_width: &mut LineWidth,
3358 element_spans: &mut Vec<ElementSpan>,
3359 attach: Attached<'_>,
3360 options: &ReflowOptions,
3361) -> Option<usize> {
3362 let length_mode = options.length_mode;
3363 let last_space = rfind_safe_space(current_line, element_spans, options, false)
3364 .or_else(|| rfind_safe_space(current_line, element_spans, options, true))?;
3365 let before = current_line[..last_space]
3366 .trim_end_matches(is_breakable_whitespace)
3367 .to_string();
3368 let after = current_line[last_space + 1..].to_string();
3369 let after_width = measure(&after, last_space + 1, element_spans, length_mode);
3370 lines.push(before);
3371 let carried = after.len();
3372 let Attached { text, width, separator } = attach;
3373 *current_line = format!("{after}{separator}{text}");
3374 *current_width = after_width + LineWidth::plain(display_len(separator, length_mode)) + width;
3375 rebase_spans_after_break(element_spans, last_space + 1);
3376 Some(carried)
3377}
3378
3379fn rebase_spans_after_break(element_spans: &mut Vec<ElementSpan>, carried_start: usize) {
3388 element_spans.retain(|span| span.end > carried_start);
3389 for span in element_spans.iter_mut() {
3390 span.start = span.start.saturating_sub(carried_start);
3391 span.end -= carried_start;
3392 }
3393}
3394
3395fn reflow_elements(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
3397 let mut lines = Vec::new();
3398 let mut current_line = String::new();
3399 let mut current_width = LineWidth::default();
3402 let mut current_line_element_spans: Vec<ElementSpan> = Vec::new();
3404 let length_mode = options.length_mode;
3405 let exemptions = options.length_exemptions;
3406
3407 for (idx, element) in elements.iter().enumerate() {
3408 let element_len = element.display_len(length_mode);
3409 let element_width = element.exempt_width(length_mode, exemptions);
3410 let is_hard = match element {
3411 Element::Bold { content, .. }
3412 | Element::Italic { content, .. }
3413 | Element::Strikethrough { content, .. } => content.contains(['[', '`', '<', '$', '{']),
3414 _ => true,
3415 };
3416
3417 let is_adjacent_to_prev = if idx > 0 {
3426 match (&elements[idx - 1], element) {
3427 (Element::Text(t), _) => !t.is_empty() && !t.ends_with(is_breakable_whitespace),
3428 (_, Element::Text(t)) => !t.is_empty() && !t.starts_with(is_breakable_whitespace),
3429 _ => true,
3430 }
3431 } else {
3432 false
3433 };
3434
3435 if let Element::Text(text) = element {
3437 let has_leading_space = text.starts_with(is_breakable_whitespace);
3439 let words: Vec<&str> = split_breakable_words(text).collect();
3441
3442 for (i, word) in words.iter().enumerate() {
3443 let word_width = LineWidth::plain(display_len(word, length_mode));
3445 let is_trailing_punct = word.chars().all(|c| {
3451 matches!(c, ',' | '.' | ':' | ';' | '!' | '?' | ')' | ']' | '}') || is_non_breaking_space(c)
3452 });
3453
3454 let is_first_adjacent = i == 0 && is_adjacent_to_prev;
3457
3458 if is_first_adjacent {
3459 if !(current_width + word_width).fits(options.line_length)
3461 && !current_width.is_empty()
3462 && break_before_attached(
3463 &mut lines,
3464 &mut current_line,
3465 &mut current_width,
3466 &mut current_line_element_spans,
3467 Attached {
3468 text: word,
3469 width: word_width,
3470 separator: "",
3471 },
3472 options,
3473 )
3474 .is_some()
3475 {
3476 } else {
3481 current_line.push_str(word);
3482 current_width += word_width;
3483 }
3484 } else if !current_width.is_empty()
3485 && !(current_width + LineWidth::plain(1) + word_width).fits(options.line_length)
3486 {
3487 if is_trailing_punct {
3488 if break_before_attached(
3495 &mut lines,
3496 &mut current_line,
3497 &mut current_width,
3498 &mut current_line_element_spans,
3499 Attached {
3500 text: word,
3501 width: word_width,
3502 separator: " ",
3503 },
3504 options,
3505 )
3506 .is_none()
3507 {
3508 current_line.push(' ');
3509 current_line.push_str(word);
3510 current_width += LineWidth::plain(1) + word_width;
3511 }
3512 } else if !starts_block_construct(word) {
3513 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
3515 current_line = word.to_string();
3516 current_width = word_width;
3517 current_line_element_spans.clear();
3518 } else if break_before_attached(
3519 &mut lines,
3520 &mut current_line,
3521 &mut current_width,
3522 &mut current_line_element_spans,
3523 Attached {
3524 text: word,
3525 width: word_width,
3526 separator: " ",
3527 },
3528 options,
3529 )
3530 .is_some()
3531 {
3532 } else {
3537 if i > 0 || has_leading_space {
3540 current_line.push(' ');
3541 current_width += LineWidth::plain(1);
3542 }
3543 current_line.push_str(word);
3544 current_width += word_width;
3545 }
3546 } else {
3547 let add_space = !current_width.is_empty() && (i > 0 || has_leading_space);
3559 if add_space {
3560 current_line.push(' ');
3561 current_width += LineWidth::plain(1);
3562 }
3563 current_line.push_str(word);
3564 current_width += word_width;
3565 }
3566 }
3567 } else {
3568 let span_info = match element {
3569 Element::Italic { content, underscore } => {
3570 Some((content.as_str(), if *underscore { "_" } else { "*" }, false))
3571 }
3572 Element::Bold { content, underscore } => {
3573 Some((content.as_str(), if *underscore { "__" } else { "**" }, false))
3574 }
3575 Element::Strikethrough { content, double } => {
3576 Some((content.as_str(), if *double { "~~" } else { "~" }, false))
3577 }
3578 Element::Code { content, marker } => Some((content.as_str(), marker.as_str(), true)),
3579 _ => None,
3580 };
3581
3582 let breakable: Option<Vec<&str>> = match span_info {
3586 Some((content, _, is_code)) => {
3587 if is_code {
3588 (!options.atomic_spans && code_span_wraps_losslessly(content))
3589 .then(|| split_breakable_words(content).collect())
3590 } else {
3591 (!options.atomic_spans || element_len > options.line_length)
3592 .then(|| breakable_units(content, options.defined_references.as_ref(), options.attr_lists))
3593 .flatten()
3594 }
3595 }
3596 None => None,
3597 };
3598
3599 if let Some(words) = breakable {
3600 let (_, marker, is_code) = span_info.expect("breakable implies a span");
3601 let n = words.len();
3602 if n == 0 {
3603 let full = format!("{marker}{marker}");
3605 let full_width = LineWidth::plain(display_len(&full, length_mode));
3606 if !is_adjacent_to_prev && !current_width.is_empty() {
3607 current_line.push(' ');
3608 current_width += LineWidth::plain(1);
3609 }
3610 current_line.push_str(&full);
3611 current_width += full_width;
3612 } else {
3613 for (i, word) in words.iter().enumerate() {
3614 let is_first = i == 0;
3615 let is_last = i == n - 1;
3616
3617 let space_start = if is_first && is_code && word.starts_with('`') {
3618 " "
3619 } else {
3620 ""
3621 };
3622 let space_end = if is_last && is_code && word.ends_with('`') {
3623 " "
3624 } else {
3625 ""
3626 };
3627
3628 let word_str: String = match (is_first, is_last) {
3629 (true, true) => format!("{marker}{space_start}{word}{space_end}{marker}"),
3630 (true, false) => format!("{marker}{space_start}{word}"),
3631 (false, true) => format!("{word}{space_end}{marker}"),
3632 (false, false) => word.to_string(),
3633 };
3634 let word_elements = parse_elements(&word_str, options);
3635 let word_spans = compute_element_spans(&word_elements, length_mode, exemptions);
3636 let word_width = measure(&word_str, 0, &word_spans, length_mode);
3637
3638 let needs_space = if is_first {
3639 !is_adjacent_to_prev && !current_width.is_empty()
3640 } else {
3641 !current_width.is_empty()
3642 };
3643
3644 if needs_space
3645 && !(current_width + LineWidth::plain(1) + word_width).fits(options.line_length)
3646 && !starts_block_construct(&word_str)
3647 {
3648 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
3649 current_line = word_str;
3650 current_width = word_width;
3651 current_line_element_spans.clear();
3652 for span in word_spans {
3653 current_line_element_spans.push(span);
3654 }
3655 } else {
3656 let mut start_pos = current_line.len();
3657 if needs_space {
3658 current_line.push(' ');
3659 current_width += LineWidth::plain(1);
3660 start_pos += 1;
3661 }
3662 current_line.push_str(&word_str);
3663 current_width += word_width;
3664 for mut span in word_spans {
3665 span.start += start_pos;
3666 span.end += start_pos;
3667 current_line_element_spans.push(span);
3668 }
3669 }
3670 }
3671 }
3672 } else {
3673 let element_str = format!("{element}");
3676
3677 if is_adjacent_to_prev {
3678 if !(current_width + element_width).fits(options.line_length)
3680 && let Some(carried) = break_before_attached(
3681 &mut lines,
3682 &mut current_line,
3683 &mut current_width,
3684 &mut current_line_element_spans,
3685 Attached {
3686 text: &element_str,
3687 width: element_width,
3688 separator: "",
3689 },
3690 options,
3691 )
3692 {
3693 current_line_element_spans.push(ElementSpan::new(
3697 carried,
3698 element_str.len(),
3699 element_len,
3700 element_width,
3701 is_hard,
3702 ));
3703 } else {
3704 let start = current_line.len();
3705 current_line.push_str(&element_str);
3706 current_width += element_width;
3707 current_line_element_spans.push(ElementSpan::new(
3708 start,
3709 element_str.len(),
3710 element_len,
3711 element_width,
3712 is_hard,
3713 ));
3714 }
3715 } else if !current_width.is_empty()
3716 && !(current_width + LineWidth::plain(1) + element_width).fits(options.line_length)
3717 {
3718 if !starts_block_construct(&element_str) {
3719 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
3721 current_line.clone_from(&element_str);
3722 current_width = element_width;
3723 current_line_element_spans.clear();
3724 current_line_element_spans.push(ElementSpan::new(
3725 0,
3726 element_str.len(),
3727 element_len,
3728 element_width,
3729 is_hard,
3730 ));
3731 } else if let Some(carried) = break_before_attached(
3732 &mut lines,
3733 &mut current_line,
3734 &mut current_width,
3735 &mut current_line_element_spans,
3736 Attached {
3737 text: &element_str,
3738 width: element_width,
3739 separator: " ",
3740 },
3741 options,
3742 ) {
3743 let start = carried + 1;
3747 current_line_element_spans.push(ElementSpan::new(
3748 start,
3749 element_str.len(),
3750 element_len,
3751 element_width,
3752 is_hard,
3753 ));
3754 } else {
3755 let ends_with_opener =
3758 current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
3759 if !ends_with_opener {
3760 current_line.push(' ');
3761 current_width += LineWidth::plain(1);
3762 }
3763 let start = current_line.len();
3764 current_line.push_str(&element_str);
3765 current_width += element_width;
3766 current_line_element_spans.push(ElementSpan::new(
3767 start,
3768 element_str.len(),
3769 element_len,
3770 element_width,
3771 is_hard,
3772 ));
3773 }
3774 } else {
3775 let ends_with_opener =
3777 current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
3778 if !current_width.is_empty() && !ends_with_opener {
3779 current_line.push(' ');
3780 current_width += LineWidth::plain(1);
3781 }
3782 let start = current_line.len();
3783 current_line.push_str(&element_str);
3784 current_width += element_width;
3785 current_line_element_spans.push(ElementSpan::new(
3786 start,
3787 element_str.len(),
3788 element_len,
3789 element_width,
3790 is_hard,
3791 ));
3792 }
3793 }
3794 }
3795 }
3796
3797 if !current_line.is_empty() {
3799 lines.push(current_line.trim_end_matches(is_breakable_whitespace).to_string());
3800 }
3801
3802 lines
3803}
3804
3805pub fn reflow_markdown(content: &str, options: &ReflowOptions) -> String {
3807 let lines: Vec<&str> = content.lines().collect();
3808 let mut result = Vec::new();
3809 let mut i = 0;
3810
3811 while i < lines.len() {
3812 let line = lines[i];
3813 let trimmed = line.trim();
3814
3815 if trimmed.is_empty() {
3817 result.push(String::new());
3818 i += 1;
3819 continue;
3820 }
3821
3822 if trimmed.starts_with('#') {
3824 result.push(line.to_string());
3825 i += 1;
3826 continue;
3827 }
3828
3829 if trimmed.starts_with(":::") {
3831 result.push(line.to_string());
3832 i += 1;
3833 continue;
3834 }
3835
3836 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
3838 result.push(line.to_string());
3839 i += 1;
3840 while i < lines.len() {
3842 result.push(lines[i].to_string());
3843 if lines[i].trim().starts_with("```") || lines[i].trim().starts_with("~~~") {
3844 i += 1;
3845 break;
3846 }
3847 i += 1;
3848 }
3849 continue;
3850 }
3851
3852 if calculate_indentation_width_default(line) >= 4 {
3854 result.push(line.to_string());
3856 i += 1;
3857 while i < lines.len() {
3858 let next_line = lines[i];
3859 if calculate_indentation_width_default(next_line) >= 4 || next_line.trim().is_empty() {
3861 result.push(next_line.to_string());
3862 i += 1;
3863 } else {
3864 break;
3865 }
3866 }
3867 continue;
3868 }
3869
3870 if trimmed.starts_with('>') {
3872 let gt_pos = line.find('>').expect("'>' must exist since trimmed.starts_with('>')");
3875 let quote_prefix = line[0..=gt_pos].to_string();
3876 let quote_content = &line[quote_prefix.len()..].trim_start();
3877
3878 let reflowed = reflow_line(quote_content, options);
3879 for reflowed_line in &reflowed {
3880 result.push(format!("{quote_prefix} {reflowed_line}"));
3881 }
3882 i += 1;
3883 continue;
3884 }
3885
3886 if is_horizontal_rule(trimmed) {
3888 result.push(line.to_string());
3889 i += 1;
3890 continue;
3891 }
3892
3893 if is_unordered_list_marker(trimmed) || is_numbered_list_item(trimmed) {
3895 let indent = line.len() - line.trim_start().len();
3897 let indent_str = " ".repeat(indent);
3898
3899 let mut marker_end = indent;
3902 let mut content_start = indent;
3903
3904 if trimmed.chars().next().is_some_and(char::is_numeric) {
3905 if let Some(period_pos) = line[indent..].find('.') {
3907 marker_end = indent + period_pos + 1; content_start = marker_end;
3909 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
3913 content_start += 1;
3914 }
3915 }
3916 } else {
3917 marker_end = indent + 1; content_start = marker_end;
3920 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
3924 content_start += 1;
3925 }
3926 }
3927
3928 let min_continuation_indent = content_start;
3930
3931 let rest = &line[content_start..];
3934 if rest.starts_with("[ ] ") || rest.starts_with("[x] ") || rest.starts_with("[X] ") {
3935 marker_end = content_start + 3; content_start += 4; }
3938
3939 let marker = &line[indent..marker_end];
3940
3941 let mut list_content = vec![trim_preserving_hard_break(&line[content_start..])];
3944 i += 1;
3945
3946 while i < lines.len() {
3950 let next_line = lines[i];
3951 let next_trimmed = next_line.trim();
3952
3953 if is_block_boundary(next_trimmed) {
3955 break;
3956 }
3957
3958 let next_indent = next_line.len() - next_line.trim_start().len();
3960 if next_indent >= min_continuation_indent {
3961 let trimmed_start = next_line.trim_start();
3964 list_content.push(trim_preserving_hard_break(trimmed_start));
3965 i += 1;
3966 } else {
3967 break;
3969 }
3970 }
3971
3972 let combined_content = if options.preserve_breaks {
3975 list_content[0].clone()
3976 } else {
3977 let has_hard_breaks = list_content.iter().any(|line| has_hard_break(line));
3979 if has_hard_breaks {
3980 list_content.join("\n")
3982 } else {
3983 list_content.join(" ")
3985 }
3986 };
3987
3988 let trimmed_marker = marker;
3990 let continuation_spaces = if let Some(max_indent) = options.max_list_continuation_indent {
3991 indent + (content_start - indent).min(max_indent)
3994 } else {
3995 content_start
3996 };
3997
3998 let prefix_length = indent + trimmed_marker.len() + 1;
4000
4001 let adjusted_options = ReflowOptions {
4003 line_length: options.line_length.saturating_sub(prefix_length),
4004 ..options.clone()
4005 };
4006
4007 let reflowed = reflow_line(&combined_content, &adjusted_options);
4008 for (j, reflowed_line) in reflowed.iter().enumerate() {
4009 if j == 0 {
4010 result.push(format!("{indent_str}{trimmed_marker} {reflowed_line}"));
4011 } else {
4012 let continuation_indent = " ".repeat(continuation_spaces);
4014 result.push(format!("{continuation_indent}{reflowed_line}"));
4015 }
4016 }
4017 continue;
4018 }
4019
4020 if crate::utils::table_utils::TableUtils::is_potential_table_row(line) {
4022 result.push(line.to_string());
4023 i += 1;
4024 continue;
4025 }
4026
4027 if trimmed.starts_with('[') && line.contains("]:") {
4029 result.push(line.to_string());
4030 i += 1;
4031 continue;
4032 }
4033
4034 if is_definition_list_item(trimmed) {
4036 result.push(line.to_string());
4037 i += 1;
4038 continue;
4039 }
4040
4041 let mut is_single_line_paragraph = true;
4043 if i + 1 < lines.len() {
4044 let next_trimmed = lines[i + 1].trim();
4045 if !is_block_boundary(next_trimmed) {
4047 is_single_line_paragraph = false;
4048 }
4049 }
4050
4051 if is_single_line_paragraph && line_fits(line, options) {
4053 result.push(line.to_string());
4054 i += 1;
4055 continue;
4056 }
4057
4058 let mut paragraph_parts = Vec::new();
4060 let mut current_part = vec![line];
4061 i += 1;
4062
4063 if options.preserve_breaks {
4065 let hard_break_type = if line.strip_suffix('\r').unwrap_or(line).ends_with('\\') {
4067 Some("\\")
4068 } else if line.ends_with(" ") {
4069 Some(" ")
4070 } else {
4071 None
4072 };
4073 let reflowed = reflow_line(line, options);
4074
4075 if let Some(break_marker) = hard_break_type {
4077 if !reflowed.is_empty() {
4078 let mut reflowed_with_break = reflowed;
4079 let last_idx = reflowed_with_break.len() - 1;
4080 if !has_hard_break(&reflowed_with_break[last_idx]) {
4081 reflowed_with_break[last_idx].push_str(break_marker);
4082 }
4083 result.extend(reflowed_with_break);
4084 }
4085 } else {
4086 result.extend(reflowed);
4087 }
4088 } else {
4089 while i < lines.len() {
4091 let prev_line = if !current_part.is_empty() {
4092 current_part.last().unwrap()
4093 } else {
4094 ""
4095 };
4096 let next_line = lines[i];
4097 let next_trimmed = next_line.trim();
4098
4099 if is_block_boundary(next_trimmed) {
4101 break;
4102 }
4103
4104 let prev_trimmed = prev_line.trim();
4107 let abbreviations = get_abbreviations(&options.abbreviations);
4108 let ends_with_sentence = (prev_trimmed.ends_with('.')
4109 || prev_trimmed.ends_with('!')
4110 || prev_trimmed.ends_with('?')
4111 || prev_trimmed.ends_with(".*")
4112 || prev_trimmed.ends_with("!*")
4113 || prev_trimmed.ends_with("?*")
4114 || prev_trimmed.ends_with("._")
4115 || prev_trimmed.ends_with("!_")
4116 || prev_trimmed.ends_with("?_")
4117 || prev_trimmed.ends_with(".\"")
4119 || prev_trimmed.ends_with("!\"")
4120 || prev_trimmed.ends_with("?\"")
4121 || prev_trimmed.ends_with(".'")
4122 || prev_trimmed.ends_with("!'")
4123 || prev_trimmed.ends_with("?'")
4124 || prev_trimmed.ends_with(".\u{201D}")
4125 || prev_trimmed.ends_with("!\u{201D}")
4126 || prev_trimmed.ends_with("?\u{201D}")
4127 || prev_trimmed.ends_with(".\u{2019}")
4128 || prev_trimmed.ends_with("!\u{2019}")
4129 || prev_trimmed.ends_with("?\u{2019}"))
4130 && !text_ends_with_abbreviation(
4131 prev_trimmed.trim_end_matches(['*', '_', '"', '\'', '\u{201D}', '\u{2019}']),
4132 &abbreviations,
4133 );
4134
4135 if has_hard_break(prev_line) || (options.sentence_per_line && ends_with_sentence) {
4136 paragraph_parts.push(current_part.join(" "));
4138 current_part = vec![next_line];
4139 } else {
4140 current_part.push(next_line);
4141 }
4142 i += 1;
4143 }
4144
4145 if !current_part.is_empty() {
4147 if current_part.len() == 1 {
4148 paragraph_parts.push(current_part[0].to_string());
4150 } else {
4151 paragraph_parts.push(current_part.join(" "));
4152 }
4153 }
4154
4155 for (j, part) in paragraph_parts.iter().enumerate() {
4157 let reflowed = reflow_line(part, options);
4158 result.extend(reflowed);
4159
4160 if j < paragraph_parts.len() - 1 && !result.is_empty() && !options.sentence_per_line {
4164 let last_idx = result.len() - 1;
4165 if !has_hard_break(&result[last_idx]) {
4166 result[last_idx].push_str(" ");
4167 }
4168 }
4169 }
4170 }
4171 }
4172
4173 let result_text = result.join("\n");
4175 if content.ends_with('\n') && !result_text.ends_with('\n') {
4176 format!("{result_text}\n")
4177 } else {
4178 result_text
4179 }
4180}
4181
4182#[derive(Debug, Clone)]
4184pub struct ParagraphReflow {
4185 pub start_byte: usize,
4187 pub end_byte: usize,
4189 pub reflowed_text: String,
4191}
4192
4193#[derive(Debug, Clone)]
4199pub struct BlockquoteLineData {
4200 pub(crate) content: String,
4202 pub(crate) is_explicit: bool,
4204 pub(crate) prefix: Option<String>,
4206}
4207
4208impl BlockquoteLineData {
4209 pub fn explicit(content: String, prefix: String) -> Self {
4211 Self {
4212 content,
4213 is_explicit: true,
4214 prefix: Some(prefix),
4215 }
4216 }
4217
4218 pub fn lazy(content: String) -> Self {
4220 Self {
4221 content,
4222 is_explicit: false,
4223 prefix: None,
4224 }
4225 }
4226}
4227
4228#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4230pub enum BlockquoteContinuationStyle {
4231 Explicit,
4232 Lazy,
4233}
4234
4235pub fn blockquote_continuation_style(lines: &[BlockquoteLineData]) -> BlockquoteContinuationStyle {
4243 let mut explicit_count = 0usize;
4244 let mut lazy_count = 0usize;
4245
4246 for line in lines.iter().skip(1) {
4247 if line.is_explicit {
4248 explicit_count += 1;
4249 } else {
4250 lazy_count += 1;
4251 }
4252 }
4253
4254 if explicit_count > 0 && lazy_count == 0 {
4255 BlockquoteContinuationStyle::Explicit
4256 } else if lazy_count > 0 && explicit_count == 0 {
4257 BlockquoteContinuationStyle::Lazy
4258 } else if explicit_count >= lazy_count {
4259 BlockquoteContinuationStyle::Explicit
4260 } else {
4261 BlockquoteContinuationStyle::Lazy
4262 }
4263}
4264
4265pub fn dominant_blockquote_prefix(lines: &[BlockquoteLineData], fallback: &str) -> String {
4270 let mut counts: std::collections::HashMap<String, (usize, usize)> = std::collections::HashMap::new();
4271
4272 for (idx, line) in lines.iter().enumerate() {
4273 let Some(prefix) = line.prefix.as_ref() else {
4274 continue;
4275 };
4276 counts
4277 .entry(prefix.clone())
4278 .and_modify(|entry| entry.0 += 1)
4279 .or_insert((1, idx));
4280 }
4281
4282 counts
4283 .into_iter()
4284 .max_by(|(_, (count_a, first_idx_a)), (_, (count_b, first_idx_b))| {
4285 count_a.cmp(count_b).then_with(|| first_idx_b.cmp(first_idx_a))
4286 })
4287 .map_or_else(|| fallback.to_string(), |(prefix, _)| prefix)
4288}
4289
4290pub(crate) fn should_force_explicit_blockquote_line(content_line: &str) -> bool {
4295 let trimmed = content_line.trim_start();
4296 trimmed.starts_with('>')
4297 || trimmed.starts_with('#')
4298 || trimmed.starts_with("```")
4299 || trimmed.starts_with("~~~")
4300 || is_unordered_list_marker(trimmed)
4301 || is_numbered_list_item(trimmed)
4302 || is_horizontal_rule(trimmed)
4303 || is_definition_list_item(trimmed)
4304 || (trimmed.starts_with('[') && trimmed.contains("]:"))
4305 || trimmed.starts_with(":::")
4306 || (trimmed.starts_with('<')
4307 && !trimmed.starts_with("<http")
4308 && !trimmed.starts_with("<https")
4309 && !trimmed.starts_with("<mailto:"))
4310}
4311
4312pub fn reflow_blockquote_content(
4321 lines: &[BlockquoteLineData],
4322 explicit_prefix: &str,
4323 continuation_style: BlockquoteContinuationStyle,
4324 options: &ReflowOptions,
4325) -> Vec<String> {
4326 let content_strs: Vec<&str> = lines.iter().map(|l| l.content.as_str()).collect();
4327 let segments = split_into_segments_strs(&content_strs);
4328 let mut reflowed_content_lines: Vec<String> = Vec::new();
4329
4330 for segment in segments {
4331 let hard_break_type = segment.last().and_then(|&line| {
4332 let line = line.strip_suffix('\r').unwrap_or(line);
4333 if line.ends_with('\\') {
4334 Some("\\")
4335 } else if line.ends_with(" ") {
4336 Some(" ")
4337 } else {
4338 None
4339 }
4340 });
4341
4342 let pieces: Vec<&str> = segment
4343 .iter()
4344 .map(|&line| {
4345 if let Some(l) = line.strip_suffix('\\') {
4346 l.trim_end()
4347 } else if let Some(l) = line.strip_suffix(" ") {
4348 l.trim_end()
4349 } else {
4350 line.trim_end()
4351 }
4352 })
4353 .collect();
4354
4355 let segment_text = pieces.join(" ");
4356 let segment_text = segment_text.trim();
4357 if segment_text.is_empty() {
4358 continue;
4359 }
4360
4361 let mut reflowed = reflow_line(segment_text, options);
4362 if let Some(break_marker) = hard_break_type
4363 && !reflowed.is_empty()
4364 {
4365 let last_idx = reflowed.len() - 1;
4366 if !has_hard_break(&reflowed[last_idx]) {
4367 reflowed[last_idx].push_str(break_marker);
4368 }
4369 }
4370 reflowed_content_lines.extend(reflowed);
4371 }
4372
4373 let mut styled_lines: Vec<String> = Vec::new();
4374 for (idx, line) in reflowed_content_lines.iter().enumerate() {
4375 let force_explicit = idx == 0
4376 || continuation_style == BlockquoteContinuationStyle::Explicit
4377 || should_force_explicit_blockquote_line(line);
4378 if force_explicit {
4379 styled_lines.push(format!("{explicit_prefix}{line}"));
4380 } else {
4381 styled_lines.push(line.clone());
4382 }
4383 }
4384
4385 styled_lines
4386}
4387
4388fn is_blockquote_content_boundary(content: &str) -> bool {
4389 let trimmed = content.trim();
4390 trimmed.is_empty()
4391 || is_block_boundary(trimmed)
4392 || crate::utils::table_utils::TableUtils::is_potential_table_row(content)
4393 || trimmed.starts_with(":::")
4394 || crate::utils::is_template_directive_only(content)
4395 || is_standalone_attr_list(content)
4396 || is_snippet_block_delimiter(content)
4397}
4398
4399fn split_into_segments_strs<'a>(lines: &[&'a str]) -> Vec<Vec<&'a str>> {
4400 let mut segments = Vec::new();
4401 let mut current = Vec::new();
4402
4403 for &line in lines {
4404 current.push(line);
4405 if has_hard_break(line) {
4406 segments.push(current);
4407 current = Vec::new();
4408 }
4409 }
4410
4411 if !current.is_empty() {
4412 segments.push(current);
4413 }
4414
4415 segments
4416}
4417
4418fn reflow_blockquote_paragraph_at_line(
4419 content: &str,
4420 lines: &[&str],
4421 target_idx: usize,
4422 options: &ReflowOptions,
4423) -> Option<ParagraphReflow> {
4424 let mut anchor_idx = target_idx;
4425 let mut target_level = if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[target_idx]) {
4426 parsed.nesting_level
4427 } else {
4428 let mut found = None;
4429 let mut idx = target_idx;
4430 loop {
4431 if lines[idx].trim().is_empty() {
4432 break;
4433 }
4434 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[idx]) {
4435 found = Some((idx, parsed.nesting_level));
4436 break;
4437 }
4438 if idx == 0 {
4439 break;
4440 }
4441 idx -= 1;
4442 }
4443 let (idx, level) = found?;
4444 anchor_idx = idx;
4445 level
4446 };
4447
4448 let mut para_start = anchor_idx;
4450 while para_start > 0 {
4451 let prev_idx = para_start - 1;
4452 let prev_line = lines[prev_idx];
4453
4454 if prev_line.trim().is_empty() {
4455 break;
4456 }
4457
4458 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(prev_line) {
4459 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
4460 break;
4461 }
4462 para_start = prev_idx;
4463 continue;
4464 }
4465
4466 let prev_lazy = prev_line.trim_start();
4467 if is_blockquote_content_boundary(prev_lazy) {
4468 break;
4469 }
4470 para_start = prev_idx;
4471 }
4472
4473 while para_start < lines.len() {
4475 let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[para_start]) else {
4476 para_start += 1;
4477 continue;
4478 };
4479 target_level = parsed.nesting_level;
4480 break;
4481 }
4482
4483 if para_start >= lines.len() || para_start > target_idx {
4484 return None;
4485 }
4486
4487 let mut collected: Vec<(usize, BlockquoteLineData)> = Vec::new();
4490 let mut idx = para_start;
4491 while idx < lines.len() {
4492 if !collected.is_empty() && has_hard_break(&collected[collected.len() - 1].1.content) {
4493 break;
4494 }
4495
4496 let line = lines[idx];
4497 if line.trim().is_empty() {
4498 break;
4499 }
4500
4501 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(line) {
4502 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
4503 break;
4504 }
4505 collected.push((
4506 idx,
4507 BlockquoteLineData::explicit(trim_preserving_hard_break(parsed.content), parsed.prefix.to_string()),
4508 ));
4509 idx += 1;
4510 continue;
4511 }
4512
4513 let lazy_content = line.trim_start();
4514 if is_blockquote_content_boundary(lazy_content) {
4515 break;
4516 }
4517
4518 collected.push((idx, BlockquoteLineData::lazy(trim_preserving_hard_break(lazy_content))));
4519 idx += 1;
4520 }
4521
4522 if collected.is_empty() {
4523 return None;
4524 }
4525
4526 let para_end = collected[collected.len() - 1].0;
4527 if target_idx < para_start || target_idx > para_end {
4528 return None;
4529 }
4530
4531 let line_data: Vec<BlockquoteLineData> = collected.iter().map(|(_, d)| d.clone()).collect();
4532
4533 let fallback_prefix = line_data
4534 .iter()
4535 .find_map(|d| d.prefix.clone())
4536 .unwrap_or_else(|| "> ".to_string());
4537 let explicit_prefix = dominant_blockquote_prefix(&line_data, &fallback_prefix);
4538 let continuation_style = blockquote_continuation_style(&line_data);
4539
4540 let adjusted_line_length = options
4541 .line_length
4542 .saturating_sub(display_len(&explicit_prefix, options.length_mode))
4543 .max(1);
4544
4545 let adjusted_options = ReflowOptions {
4546 line_length: adjusted_line_length,
4547 ..options.clone()
4548 };
4549
4550 let styled_lines = reflow_blockquote_content(&line_data, &explicit_prefix, continuation_style, &adjusted_options);
4551
4552 if styled_lines.is_empty() {
4553 return None;
4554 }
4555
4556 let mut start_byte = 0;
4558 for line in lines.iter().take(para_start) {
4559 start_byte += line.len() + 1;
4560 }
4561
4562 let mut end_byte = start_byte;
4563 for line in lines.iter().take(para_end + 1).skip(para_start) {
4564 end_byte += line.len() + 1;
4565 }
4566
4567 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
4568 if !includes_trailing_newline {
4569 end_byte -= 1;
4570 }
4571
4572 let reflowed_joined = styled_lines.join("\n");
4573 let reflowed_text = if includes_trailing_newline {
4574 if reflowed_joined.ends_with('\n') {
4575 reflowed_joined
4576 } else {
4577 format!("{reflowed_joined}\n")
4578 }
4579 } else if reflowed_joined.ends_with('\n') {
4580 reflowed_joined.trim_end_matches('\n').to_string()
4581 } else {
4582 reflowed_joined
4583 };
4584
4585 Some(ParagraphReflow {
4586 start_byte,
4587 end_byte,
4588 reflowed_text,
4589 })
4590}
4591
4592pub fn reflow_paragraph_at_line(content: &str, line_number: usize, line_length: usize) -> Option<ParagraphReflow> {
4610 reflow_paragraph_at_line_with_mode(content, line_number, line_length, ReflowLengthMode::default())
4611}
4612
4613pub fn reflow_paragraph_at_line_with_mode(
4615 content: &str,
4616 line_number: usize,
4617 line_length: usize,
4618 length_mode: ReflowLengthMode,
4619) -> Option<ParagraphReflow> {
4620 let options = ReflowOptions {
4621 line_length,
4622 length_mode,
4623 ..Default::default()
4624 };
4625 reflow_paragraph_at_line_with_options(content, line_number, &options)
4626}
4627
4628pub fn reflow_paragraph_at_line_with_options(
4639 content: &str,
4640 line_number: usize,
4641 options: &ReflowOptions,
4642) -> Option<ParagraphReflow> {
4643 if line_number == 0 {
4644 return None;
4645 }
4646
4647 let lines: Vec<&str> = content.lines().collect();
4648
4649 if line_number > lines.len() {
4651 return None;
4652 }
4653
4654 let target_idx = line_number - 1; let target_line = lines[target_idx];
4656 let trimmed = target_line.trim();
4657
4658 if let Some(blockquote_reflow) = reflow_blockquote_paragraph_at_line(content, &lines, target_idx, options) {
4661 return Some(blockquote_reflow);
4662 }
4663
4664 if is_paragraph_boundary(trimmed, target_line) {
4666 return None;
4667 }
4668
4669 let mut para_start = target_idx;
4671 while para_start > 0 {
4672 let prev_idx = para_start - 1;
4673 let prev_line = lines[prev_idx];
4674 let prev_trimmed = prev_line.trim();
4675
4676 if is_paragraph_boundary(prev_trimmed, prev_line) {
4678 break;
4679 }
4680
4681 para_start = prev_idx;
4682 }
4683
4684 let mut para_end = target_idx;
4686 while para_end + 1 < lines.len() {
4687 let next_idx = para_end + 1;
4688 let next_line = lines[next_idx];
4689 let next_trimmed = next_line.trim();
4690
4691 if is_paragraph_boundary(next_trimmed, next_line) {
4693 break;
4694 }
4695
4696 para_end = next_idx;
4697 }
4698
4699 let paragraph_lines = &lines[para_start..=para_end];
4701
4702 let mut start_byte = 0;
4704 for line in lines.iter().take(para_start) {
4705 start_byte += line.len() + 1; }
4707
4708 let mut end_byte = start_byte;
4709 for line in paragraph_lines {
4710 end_byte += line.len() + 1; }
4712
4713 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
4716
4717 if !includes_trailing_newline {
4719 end_byte -= 1;
4720 }
4721
4722 let paragraph_text = paragraph_lines.join("\n");
4724
4725 let reflowed = reflow_markdown(¶graph_text, options);
4727
4728 let reflowed_text = if includes_trailing_newline {
4732 if reflowed.ends_with('\n') {
4734 reflowed
4735 } else {
4736 format!("{reflowed}\n")
4737 }
4738 } else {
4739 if reflowed.ends_with('\n') {
4741 reflowed.trim_end_matches('\n').to_string()
4742 } else {
4743 reflowed
4744 }
4745 };
4746
4747 Some(ParagraphReflow {
4748 start_byte,
4749 end_byte,
4750 reflowed_text,
4751 })
4752}
4753fn decompose_code_span(raw: &str) -> Option<(&str, &str)> {
4759 let marker_len = raw.bytes().take_while(|&b| b == b'`').count();
4760 if marker_len == 0 {
4761 return None;
4762 }
4763 let marker = &raw[..marker_len];
4764 if raw.len() < marker_len * 2 {
4765 return None;
4766 }
4767 let content = &raw[marker_len..raw.len() - marker_len];
4768 Some((content, marker))
4769}
4770
4771#[cfg(test)]
4772mod tests {
4773 use super::*;
4774
4775 #[test]
4779 fn preserves_content_accepts_whitespace_changes_and_rejects_the_rest() {
4780 let accepted: &[(&str, &[&str])] = &[
4781 ("one two three", &["one two three"]),
4782 ("one two three", &["one two", "three"]),
4783 ("one two three", &["one", "two", "three"]),
4784 ("one two ", &["one two"]),
4786 ("日本語のテキスト", &["日本語の", "テキスト"]),
4788 ("_First. Second._", &["_First.", "Second._"]),
4790 ];
4791 for (original, reflowed) in accepted {
4792 let reflowed: Vec<String> = reflowed.iter().map(ToString::to_string).collect();
4793 assert!(
4794 preserves_content(original, &reflowed),
4795 "{original:?} -> {reflowed:?} only moves whitespace"
4796 );
4797 }
4798
4799 let rejected: &[(&str, &[&str])] = &[
4800 ("one two three", &["one two"]),
4802 ("one two", &["one two three"]),
4804 ("one two", &["two one"]),
4806 ("_First. Second._", &["_First._", "_Second._"]),
4808 ("alpha and beta", &["alpha", "andbeta"]),
4810 ("mot suivant : autre", &["mot suivant: autre"]),
4812 ];
4813 for (original, reflowed) in rejected {
4814 let reflowed: Vec<String> = reflowed.iter().map(ToString::to_string).collect();
4815 assert!(
4816 !preserves_content(original, &reflowed),
4817 "{original:?} -> {reflowed:?} changes the text, not just its line breaks"
4818 );
4819 }
4820 }
4821
4822 #[test]
4824 fn reflow_line_falls_back_to_the_input_when_content_would_change() {
4825 let options = ReflowOptions {
4826 line_length: 40,
4827 ..Default::default()
4828 };
4829 let line = "one two three four five six seven eight nine ten";
4830
4831 assert!(preserves_content(line, &reflow_line(line, &options)));
4832 assert_eq!(reflow_line(line, &options), reflow_line_unchecked(line, &options));
4833 }
4834
4835 #[test]
4836 fn cascade_split_line_handles_a_very_long_line_without_overflowing() {
4837 let words: Vec<String> = (0..4000).map(|i| format!("word{i}")).collect();
4843 let line = words.join(" ");
4844
4845 let options = ReflowOptions {
4846 line_length: 80,
4847 length_mode: ReflowLengthMode::Chars,
4848 ..Default::default()
4849 };
4850 let out = cascade_split_line(&line, &options);
4851
4852 assert!(out.len() > 1, "a very long line should split into many lines");
4853 for segment in &out {
4854 assert!(
4855 display_len(segment, ReflowLengthMode::Chars) <= 80 || !segment.contains(' '),
4856 "each wrapped line should fit the width (or be a single unbreakable token)"
4857 );
4858 }
4859 let rejoined = out.join(" ");
4861 let original_words: Vec<&str> = line.split(' ').collect();
4862 let result_words: Vec<&str> = rejoined.split_whitespace().collect();
4863 assert_eq!(original_words, result_words, "reflow must preserve all words in order");
4864 }
4865
4866 #[test]
4871 fn test_helper_function_text_ends_with_abbreviation() {
4872 let abbreviations = get_abbreviations(&None);
4874
4875 assert!(text_ends_with_abbreviation("Dr.", &abbreviations));
4877 assert!(text_ends_with_abbreviation("word Dr.", &abbreviations));
4878 assert!(text_ends_with_abbreviation("e.g.", &abbreviations));
4879 assert!(text_ends_with_abbreviation("i.e.", &abbreviations));
4880 assert!(text_ends_with_abbreviation("Mr.", &abbreviations));
4881 assert!(text_ends_with_abbreviation("Mrs.", &abbreviations));
4882 assert!(text_ends_with_abbreviation("Ms.", &abbreviations));
4883 assert!(text_ends_with_abbreviation("Prof.", &abbreviations));
4884
4885 assert!(!text_ends_with_abbreviation("etc.", &abbreviations));
4887 assert!(!text_ends_with_abbreviation("paradigms.", &abbreviations));
4888 assert!(!text_ends_with_abbreviation("programs.", &abbreviations));
4889 assert!(!text_ends_with_abbreviation("items.", &abbreviations));
4890 assert!(!text_ends_with_abbreviation("systems.", &abbreviations));
4891 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)); }
4897
4898 #[test]
4899 fn test_footnote_after_period_splits_sentence() {
4900 let text = "First sentence.[^1] Second sentence.";
4904 let sentences = split_into_sentences(text, None);
4905 assert_eq!(
4906 sentences,
4907 vec!["First sentence.[^1]".to_string(), "Second sentence.".to_string()],
4908 "footnote glued to the period should keep the boundary and stay attached to the first sentence"
4909 );
4910 }
4911
4912 #[test]
4913 fn test_multiple_consecutive_footnotes_after_period_splits_sentence() {
4914 let text = "Notes here.[^1][^2] Second sentence.";
4916 let sentences = split_into_sentences(text, None);
4917 assert_eq!(
4918 sentences,
4919 vec!["Notes here.[^1][^2]".to_string(), "Second sentence.".to_string()]
4920 );
4921 }
4922
4923 #[test]
4924 fn test_footnote_before_period_still_splits_sentence() {
4925 let text = "Annotation here[^1]. Second sentence.";
4929 let sentences = split_into_sentences(text, None);
4930 assert_eq!(
4931 sentences,
4932 vec!["Annotation here[^1].".to_string(), "Second sentence.".to_string()]
4933 );
4934 }
4935
4936 #[test]
4937 fn test_mid_sentence_footnote_does_not_split() {
4938 let text = "The system word[^1] more words. Next sentence.";
4941 let sentences = split_into_sentences(text, None);
4942 assert_eq!(
4943 sentences,
4944 vec![
4945 "The system word[^1] more words.".to_string(),
4946 "Next sentence.".to_string()
4947 ]
4948 );
4949 }
4950
4951 #[test]
4952 fn test_bare_numeric_bracket_after_period_does_not_split() {
4953 let text = "Citation here.[1] Second sentence.";
4956 let sentences = split_into_sentences(text, None);
4957 assert_eq!(
4958 sentences,
4959 vec![text.to_string()],
4960 "a bare numeric bracket must not be treated as a sentence boundary"
4961 );
4962 }
4963
4964 #[test]
4965 fn test_footnote_glued_to_following_word_does_not_split() {
4966 let text = "First sentence.[^1]Continued glued text.";
4969 let sentences = split_into_sentences(text, None);
4970 assert_eq!(sentences, vec![text.to_string()]);
4971 }
4972
4973 #[test]
4974 fn test_footnote_at_end_of_text_is_preserved() {
4975 let text = "Sentence.[^1]";
4978 let sentences = split_into_sentences(text, None);
4979 assert_eq!(sentences, vec![text.to_string()]);
4980 }
4981
4982 #[test]
4983 fn test_abbreviation_before_footnote_does_not_split() {
4984 let text = "See the notes, e.g.[^1] this one.";
4987 let sentences = split_into_sentences(text, None);
4988 assert_eq!(
4989 sentences,
4990 vec![text.to_string()],
4991 "e.g. is an abbreviation, not a sentence boundary"
4992 );
4993 }
4994
4995 #[test]
4996 fn sentence_boundary_never_falls_inside_an_atomic_construct() {
4997 let cases = [
5003 "Prefix [link. Still link](https://example.com) tail. Next sentence.",
5004 "Prefix [target](<https://example.com/First. Second>) tail. Next sentence.",
5005 "Prefix [text](url \"Title. More\") tail. Next sentence.",
5006 "Prefix  tail. Next sentence.",
5007 "Prefix [ref text. More][ref] tail. Next sentence.",
5008 "Prefix [collapsed. More][] tail. Next sentence.",
5009 "Prefix [[Page name. Title]] tail. Next sentence.",
5010 "Prefix $x. Y$ tail. Next sentence.",
5011 "Prefix $$x. Y$$ tail. Next sentence.",
5012 "Prefix <span title=\"A. B\">x</span> tail. Next sentence.",
5013 "Prefix `code. Still code` tail. Next sentence.",
5014 ];
5015 for text in cases {
5016 let sentences = split_into_sentences(text, None);
5017 let (head, tail) = text.rsplit_once(" tail. ").expect("case has a tail");
5018 assert_eq!(
5019 sentences,
5020 vec![format!("{head} tail."), tail.to_string()],
5021 "input {text:?}"
5022 );
5023 }
5024
5025 let text = "Prefix [shortcut. More] tail. Next sentence.";
5029 let whole = vec![
5030 "Prefix [shortcut. More] tail.".to_string(),
5031 "Next sentence.".to_string(),
5032 ];
5033 let defined = HashSet::from(["shortcut. more".to_string()]);
5034 assert_eq!(split_into_sentences(text, Some(&defined)), whole);
5035 assert_eq!(split_into_sentences(text, None), whole);
5036 assert_eq!(
5037 split_into_sentences(text, Some(&HashSet::new())),
5038 vec!["Prefix [shortcut.", "More] tail.", "Next sentence."]
5039 );
5040 }
5041
5042 #[test]
5043 fn a_sentence_may_open_with_a_link_or_image() {
5044 for text in [
5049 "Opening sentence. [First. Second](https://example.com)",
5050 "Opening sentence. ",
5051 "Opening sentence. [[First. Second]]",
5052 "Opening sentence. [[first-note|First. Second]]",
5053 "Opening sentence. [Ref link][ref]",
5054 "Opening sentence. [](url) continues.",
5057 "Opening sentence. [][ref] continues.",
5058 "Opening sentence. [![First image][img]](url) continues.",
5061 "Opening sentence. [![First image][]](url) continues.",
5062 "Opening sentence. [![First image][img]][ref] continues.",
5063 ] {
5064 let (head, tail) = text.split_once(". ").expect("case has a boundary");
5065 assert_eq!(
5066 split_into_sentences(text, None),
5067 vec![format!("{head}."), tail.to_string()],
5068 "input {text:?}"
5069 );
5070 }
5071 let text = "Opening sentence. [![First image]](url) continues.";
5074 let defined = HashSet::from(["first image".to_string()]);
5075 assert_eq!(
5076 split_into_sentences(text, Some(&defined)),
5077 vec!["Opening sentence.", "[![First image]](url) continues."]
5078 );
5079 assert_eq!(
5080 split_into_sentences(text, Some(&HashSet::new())),
5081 vec![text.to_string()],
5082 "an undefined shortcut is bracketed text, and `!` opens no sentence"
5083 );
5084 assert_eq!(
5087 split_into_sentences("Opening sentence. [](url) continues.", None),
5088 vec](url) continues."]
5089 );
5090 let defined = HashSet::from(["smith 2020".to_string()]);
5093 assert_eq!(
5094 split_into_sentences("Claim ends here. [Smith 2020] more text.", Some(&defined)),
5095 vec!["Claim ends here.", "[Smith 2020] more text."]
5096 );
5097 let none_defined = HashSet::new();
5103 for text in [
5104 "Opening sentence. [first link](https://example.com) continues.",
5105 "Opening sentence. [[first note]] continues.",
5106 "Opening sentence. [[First Note|first note]] continues.",
5107 "Opening sentence. [[Page continues.",
5108 "Opening sentence. [[First] stray]] continues.",
5109 "Opening sentence.  continues.",
5110 "Opening sentence. [1] is the citation.",
5111 "Opening sentence. [First](unterminated",
5112 "Opening sentence. [First][unterminated",
5113 "Opening sentence. [First] (aside) continues.",
5114 "Claim ends here. [Smith 2020]",
5115 "Claim ends here. [Smith 2020] more text.",
5116 "See the RFC. [RFC] More text.",
5117 "Claim ends here. [^Note] more text.",
5118 ] {
5119 assert_eq!(
5120 split_into_sentences(text, Some(&none_defined)),
5121 vec![text.to_string()],
5122 "input {text:?}"
5123 );
5124 }
5125 }
5126
5127 #[test]
5128 fn link_opener_is_read_off_the_parse() {
5129 let len = |text: &str, defs: Option<&HashSet<String>>| {
5132 let chars: Vec<char> = text.chars().collect();
5133 let char_offsets = char_byte_offsets(&chars);
5134 let NestedStructure { links, .. } = sentence_structure(text, defs);
5135 let st = SentenceText {
5136 text,
5137 chars: &chars,
5138 char_offsets: &char_offsets,
5139 links: &links,
5140 };
5141 st.link_end_at(0).map_or(0, |end| link_opener_len(&chars, 0, end))
5142 };
5143 let none = HashSet::new();
5144 assert_eq!(len("[text](url)", Some(&none)), 1);
5145 assert_eq!(
5146 len("[text][ref]", Some(&none)),
5147 1,
5148 "a full reference is a link whether or not defined"
5149 );
5150 assert_eq!(len("[text][]", Some(&none)), 1);
5151 assert_eq!(len("", Some(&none)), 2);
5152 assert_eq!(len("[[wiki]]", Some(&none)), 2);
5153 assert_eq!(
5154 len("[[wiki|shown]]", Some(&none)),
5155 7,
5156 "the displayed text starts after the alias pipe"
5157 );
5158 assert_eq!(len("![[img.png|100]]", Some(&none)), 11);
5159 assert_eq!(len("[[wiki|a|b]]", Some(&none)), 7, "the first pipe starts the alias");
5160 assert_eq!(
5161 len("[[wiki|shown]] [[a|b]]", Some(&none)),
5162 7,
5163 "a pipe past the closing `]]` is not this alias"
5164 );
5165 assert_eq!(
5166 len("[a \\] b](url)", Some(&none)),
5167 1,
5168 "an escaped bracket does not close the text"
5169 );
5170 assert_eq!(
5171 len("[](url)", Some(&none)),
5172 1,
5173 "the outer opener is skipped first"
5174 );
5175 for text in [
5179 "[^1]",
5180 "[text](unterminated",
5181 "[text][unterminated",
5182 "[text] (url)",
5183 "[[wiki",
5184 "[[wiki]",
5185 "[[First] stray]]",
5186 "[Smith 2020]",
5187 "[Smith 2020] (see also)",
5188 "[unclosed",
5189 "!bang",
5190 "text",
5191 ] {
5192 assert_eq!(len(text, Some(&none)), 0, "input {text:?}");
5193 }
5194 let smith = HashSet::from(["smith 2020".to_string()]);
5197 assert_eq!(len("[Smith 2020]", Some(&smith)), 1);
5198 assert_eq!(len("[Smith 2020]", None), 1);
5199 }
5200
5201 #[test]
5202 fn sentence_per_line_reflow_breaks_before_a_bracket_only_where_the_check_counts() {
5203 let defined = HashSet::from(["spec".to_string()]);
5211 let options = ReflowOptions {
5212 line_length: 120,
5213 sentence_per_line: true,
5214 defined_references: Some(defined.clone()),
5215 ..Default::default()
5216 };
5217 for (text, expected) in [
5218 (
5219 "Claim ends here. [Smith](https://example.com) more text. Second sentence.",
5220 vec more text.",
5223 "Second sentence.",
5224 ],
5225 ),
5226 (
5227 "Wow! [smith](https://example.com) more text. Second sentence.",
5228 vec more text.", "Second sentence."],
5229 ),
5230 (
5231 "Claim ends here. [smith](https://example.com) more text. Second sentence.",
5232 vec more text.",
5234 "Second sentence.",
5235 ],
5236 ),
5237 (
5238 "Claim ends here. [smith][ref] more text. Second sentence.",
5239 vec!["Claim ends here. [smith][ref] more text.", "Second sentence."],
5240 ),
5241 (
5242 "Claim ends here.  more text. Second sentence.",
5243 vec more text.", "Second sentence."],
5244 ),
5245 (
5246 "Claim ends here.[Link](https://example.com) more text. Second sentence.",
5247 vec more text.",
5249 "Second sentence.",
5250 ],
5251 ),
5252 (
5253 "See the RFC. [RFC] More text. Second sentence.",
5254 vec!["See the RFC. [RFC] More text.", "Second sentence."],
5255 ),
5256 (
5257 "See the spec. [Spec] More text. Second sentence.",
5258 vec!["See the spec.", "[Spec] More text.", "Second sentence."],
5259 ),
5260 (
5261 "See the spec. [spec] more text. Second sentence.",
5262 vec!["See the spec. [spec] more text.", "Second sentence."],
5263 ),
5264 (
5265 "Claim ends here. [[page|Second sentence]] continues. Third sentence.",
5266 vec![
5267 "Claim ends here.",
5268 "[[page|Second sentence]] continues.",
5269 "Third sentence.",
5270 ],
5271 ),
5272 (
5273 "Claim ends here. [[Page|second sentence]] continues. Third sentence.",
5274 vec![
5275 "Claim ends here. [[Page|second sentence]] continues.",
5276 "Third sentence.",
5277 ],
5278 ),
5279 ] {
5280 let lines = reflow_line(text, &options);
5281 assert_eq!(lines, expected, "input {text:?}");
5282 assert_eq!(
5285 split_into_sentences(text, Some(&defined)).len(),
5286 expected.len(),
5287 "check count for {text:?}"
5288 );
5289 for line in &lines {
5290 assert_eq!(
5291 split_into_sentences(line, Some(&defined)).len(),
5292 1,
5293 "line {line:?} of {text:?}"
5294 );
5295 }
5296 }
5297 }
5298
5299 #[test]
5300 fn sentence_per_line_reflow_holds_atomic_constructs_whole() {
5301 let options = ReflowOptions {
5305 line_length: 80,
5306 sentence_per_line: true,
5307 ..Default::default()
5308 };
5309 let lines = reflow_line(
5310 "Prefix `code. Still code` and [link. Still link](https://example.com) tail. Next sentence.",
5311 &options,
5312 );
5313 assert_eq!(
5314 lines,
5315 vec tail.".to_string(),
5317 "Next sentence.".to_string(),
5318 ]
5319 );
5320
5321 let lines = reflow_line(
5322 "Prefix  and [target](<https://example.com/First. Second>) tail. Next sentence.",
5323 &options,
5324 );
5325 assert_eq!(
5326 lines,
5327 vec and [target](<https://example.com/First. Second>) tail.".to_string(),
5329 "Next sentence.".to_string(),
5330 ]
5331 );
5332
5333 let lines = reflow_line("First one. Then [link](url) second. Third one.", &options);
5336 assert_eq!(
5337 lines,
5338 vec second.".to_string(),
5341 "Third one.".to_string(),
5342 ]
5343 );
5344 }
5345
5346 #[test]
5347 fn test_is_unordered_list_marker() {
5348 assert!(is_unordered_list_marker("- item"));
5350 assert!(is_unordered_list_marker("* item"));
5351 assert!(is_unordered_list_marker("+ item"));
5352 assert!(is_unordered_list_marker("-")); assert!(is_unordered_list_marker("*"));
5354 assert!(is_unordered_list_marker("+"));
5355
5356 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")); }
5367
5368 #[test]
5369 fn test_is_block_boundary() {
5370 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"));
5392 assert!(!is_block_boundary("*emphasis*")); assert!(!is_block_boundary("[link](url)")); assert!(!is_block_boundary("some words here"));
5395 }
5396
5397 #[test]
5398 fn test_definition_list_boundary_in_single_line_paragraph() {
5399 let options = ReflowOptions {
5402 line_length: 80,
5403 ..Default::default()
5404 };
5405 let input = "Term\n: Definition of the term";
5406 let result = reflow_markdown(input, &options);
5407 assert!(
5409 result.contains(": Definition"),
5410 "Definition list item should not be merged into previous line. Got: {result:?}"
5411 );
5412 let lines: Vec<&str> = result.lines().collect();
5413 assert_eq!(lines.len(), 2, "Should remain two separate lines. Got: {lines:?}");
5414 assert_eq!(lines[0], "Term");
5415 assert_eq!(lines[1], ": Definition of the term");
5416 }
5417
5418 #[test]
5419 fn test_is_paragraph_boundary() {
5420 assert!(is_paragraph_boundary("# Heading", "# Heading"));
5422 assert!(is_paragraph_boundary("- item", "- item"));
5423 assert!(is_paragraph_boundary(":::", ":::"));
5424 assert!(is_paragraph_boundary(": definition", ": definition"));
5425
5426 assert!(is_paragraph_boundary("code", " code"));
5428 assert!(is_paragraph_boundary("code", "\tcode"));
5429
5430 assert!(is_paragraph_boundary("| a | b |", "| a | b |"));
5432 assert!(is_paragraph_boundary("a | b", "a | b")); assert!(!is_paragraph_boundary("regular text", "regular text"));
5436 assert!(!is_paragraph_boundary("text", " text")); }
5438
5439 #[test]
5440 fn test_div_marker_boundary_in_reflow_paragraph_at_line() {
5441 let content = "Some paragraph text here.\n\n::: {.callout-note}\nThis is a callout.\n:::\n";
5444 let result = reflow_paragraph_at_line(content, 3, 80);
5446 assert!(result.is_none(), "Div marker line should not be reflowed");
5447 }
5448
5449 #[test]
5450 fn starts_block_construct_detects_block_openers() {
5451 for case in ["- item", "-", "* item", "*", "+ item", "+", "-\titem"] {
5453 assert!(starts_block_construct(case), "bullet: {case:?}");
5454 }
5455 for case in ["1. item", "1) item", "01. x", "000000001. x", "1.\titem"] {
5458 assert!(starts_block_construct(case), "ordered: {case:?}");
5459 }
5460 for case in ["> quote", ">quote", ">"] {
5462 assert!(starts_block_construct(case), "blockquote: {case:?}");
5463 }
5464 for case in ["# heading", "###### h6", "#", "##"] {
5466 assert!(starts_block_construct(case), "heading: {case:?}");
5467 }
5468 for case in ["```", "```rust", "````", "~~~", "~~~text"] {
5470 assert!(starts_block_construct(case), "fence: {case:?}");
5471 }
5472 for case in ["---", "--", "===", "=", "***", "___", "_ _ _", "- - -"] {
5474 assert!(starts_block_construct(case), "setext/thematic: {case:?}");
5475 }
5476 for case in [
5479 "[^1]: text",
5480 "[^note]:",
5481 "[ref]: http://example.com",
5482 "[wat]: url follows",
5483 ] {
5484 assert!(starts_block_construct(case), "definition: {case:?}");
5485 }
5486 for case in ["<div>content", "</div>", "<p>text", "<table>", "<pre>code", "<h1>x"] {
5488 assert!(starts_block_construct(case), "html block: {case:?}");
5489 }
5490 }
5491
5492 #[test]
5493 fn starts_block_construct_allows_ordinary_prose() {
5494 for case in [
5495 "",
5496 "word",
5497 "-5 degrees",
5498 "--flag",
5499 "-item",
5500 "#hashtag",
5501 "####### seven hashes is not a heading",
5502 "1.5 million",
5503 "1234567890. ten digits is not a list marker",
5504 "0000000001. ten digits is not a list marker either",
5505 "2. item",
5508 "7. item",
5509 "0. item",
5510 "42) x",
5511 "123456. item",
5512 "1.",
5513 "1)",
5514 "123456.",
5515 "123456)",
5516 "1.item",
5517 "1:30 pm",
5518 "*emphasis*",
5519 "**bold** text",
5520 "__bold__ text",
5521 "_emphasis_ text",
5522 "`code` span",
5523 "`` double backtick span ``",
5524 "~~strikethrough~~",
5525 "=x",
5526 "== ==",
5527 "(parenthetical)",
5528 "[link](url)",
5529 "[text][ref] more",
5530 "[bracketed] aside",
5531 "[a](b) [ref]: first bracket is a link, not a label",
5532 "[esc\\]: not a close] text",
5533 "<span>inline</span>",
5534 "<b>bold</b>",
5535 "<https://example.com> autolink",
5536 "<mailto:a@b.com>",
5537 "<notarealtag>",
5538 ] {
5539 assert!(!starts_block_construct(case), "prose: {case:?}");
5540 }
5541 }
5542
5543 #[test]
5544 fn merge_block_construct_continuations_merges_marker_led_lines() {
5545 let lines = vec![
5546 "First sentence?".to_string(),
5547 "- looks like a list item".to_string(),
5548 "Second sentence.".to_string(),
5549 ];
5550 assert_eq!(
5551 merge_block_construct_continuations(lines),
5552 vec![
5553 "First sentence? - looks like a list item".to_string(),
5554 "Second sentence.".to_string(),
5555 ]
5556 );
5557
5558 let lines = vec!["- real list content".to_string(), "continuation".to_string()];
5561 assert_eq!(
5562 merge_block_construct_continuations(lines.clone()),
5563 lines,
5564 "first line must never be merged"
5565 );
5566
5567 let lines = vec!["prose".to_string(), "1.".to_string(), "[ref]:".to_string()];
5570 assert_eq!(
5571 merge_block_construct_continuations(lines),
5572 vec!["prose 1. [ref]:".to_string()],
5573 "a merge that creates an opener must fold again"
5574 );
5575 }
5576
5577 #[test]
5578 fn wrap_never_starts_a_line_with_a_block_marker() {
5579 let options = ReflowOptions {
5580 line_length: 25,
5581 ..Default::default()
5582 };
5583 let lines = reflow_line(
5586 "Some words here and then - a dash clause that wraps around the limit.",
5587 &options,
5588 );
5589 assert_eq!(
5590 lines,
5591 vec![
5592 "Some words here and",
5593 "then - a dash clause that",
5594 "wraps around the limit."
5595 ]
5596 );
5597
5598 for input in [
5600 "Alpha beta gamma delta epsilon - dash clause here to wrap",
5601 "Alpha beta gamma delta epsilon > quote lookalike here to wrap",
5602 "Alpha beta gamma delta epsilon # heading lookalike here to wrap",
5603 "Alpha beta gamma delta epsilon 1. ordered lookalike here to wrap",
5604 "Alpha beta gamma delta epsilon * star clause here to wrap",
5605 "Alpha beta gamma delta epsilon + plus clause here to wrap",
5606 ] {
5607 for width in 10..40 {
5608 let options = ReflowOptions {
5609 line_length: width,
5610 ..Default::default()
5611 };
5612 for line in reflow_line(input, &options) {
5613 assert!(
5614 !starts_block_construct(&line),
5615 "width {width}: wrapped line opens a block construct: {line:?} (input {input:?})"
5616 );
5617 }
5618 }
5619 }
5620 }
5621
5622 #[test]
5623 fn sentence_per_line_keeps_block_markers_mid_line() {
5624 let options = ReflowOptions {
5625 line_length: 80,
5626 sentence_per_line: true,
5627 ..Default::default()
5628 };
5629 let lines = reflow_line(
5632 "Google Calendar (Can't we get rid of this dependency? - I don't really see the need)",
5633 &options,
5634 );
5635 assert_eq!(
5636 lines,
5637 vec!["Google Calendar (Can't we get rid of this dependency? - I don't really see the need)".to_string()]
5638 );
5639
5640 let lines = reflow_line("See section 4? # is the marker we use. Fine.", &options);
5642 assert_eq!(lines, vec!["See section 4? # is the marker we use.", "Fine."]);
5643
5644 let lines = reflow_line("Is this a problem? > I quote someone here.", &options);
5645 assert_eq!(lines, vec!["Is this a problem? > I quote someone here."]);
5646
5647 let lines = reflow_line("Another case! 1. Not a list. More text follows here.", &options);
5648 for line in &lines {
5649 assert!(
5650 !starts_block_construct(line),
5651 "sentence-per-line output opens a block construct: {line:?}"
5652 );
5653 }
5654 }
5655
5656 #[test]
5657 fn inline_math_directly_after_display_math_stays_atomic() {
5658 let options = ReflowOptions {
5666 line_length: 8,
5667 ..Default::default()
5668 };
5669 let lines = reflow_line("$$a$$$bb cc dd$ x", &options);
5670 assert_eq!(lines, vec!["$$a$$$bb cc dd$".to_string(), "x".to_string()]);
5671 }
5672
5673 #[test]
5674 fn test_code_span_parsing() {
5675 let elements = parse_markdown_elements_inner("`code`", false, false, None);
5677 assert_eq!(elements.len(), 1);
5678 assert!(matches!(&elements[0], Element::Code { content, marker } if content == "code" && marker == "`"));
5679
5680 let elements = parse_markdown_elements_inner("``code``", false, false, None);
5682 assert_eq!(elements.len(), 1);
5683 assert!(matches!(&elements[0], Element::Code { content, marker } if content == "code" && marker == "``"));
5684
5685 let elements = parse_markdown_elements_inner("``code`inside``", false, false, None);
5687 assert_eq!(elements.len(), 1);
5688 assert!(
5689 matches!(&elements[0], Element::Code { content, marker } if content == "code`inside" && marker == "``")
5690 );
5691
5692 let elements = parse_markdown_elements_inner("`` code ``", false, false, None);
5694 assert_eq!(elements.len(), 1);
5695 assert!(matches!(&elements[0], Element::Code { content, marker } if content == " code " && marker == "``"));
5696
5697 let elements = parse_markdown_elements_inner("`unclosed", false, false, None);
5699 assert_eq!(elements.len(), 1);
5700 assert!(matches!(&elements[0], Element::Text(s) if s == "`unclosed"));
5701
5702 let elements = parse_markdown_elements_inner("`unclosed [link](url)", false, false, None);
5704 assert_eq!(elements.len(), 2);
5706 assert!(matches!(&elements[0], Element::Text(s) if s == "`unclosed "));
5707 assert!(matches!(&elements[1], Element::Link(s) if s == "[link](url)"));
5708 }
5709
5710 #[test]
5711 fn test_reflow_performance_long_input() {
5712 let mut text = String::new();
5715 for i in 1..400 {
5716 let backticks = "`".repeat(i);
5717 text.push_str(&backticks);
5718 text.push(' ');
5719 }
5720
5721 let start = std::time::Instant::now();
5722 let elements = parse_markdown_elements_inner(&text, false, false, None);
5723 let duration = start.elapsed();
5724
5725 assert!(duration.as_millis() < 100, "Parsing took too long: {duration:?}");
5727 assert!(!elements.is_empty());
5728 }
5729
5730 #[test]
5731 fn test_reflow_performance_display_math_heavy() {
5732 let text = "$$a$$".repeat(4000);
5737
5738 let start = std::time::Instant::now();
5739 let elements = parse_markdown_elements_inner(&text, false, false, None);
5740 let duration = start.elapsed();
5741
5742 assert!(duration.as_millis() < 100, "Parsing took too long: {duration:?}");
5743 assert_eq!(elements.len(), 4000);
5744 }
5745
5746 #[test]
5747 fn inline_math_len_at_start_matches_regex_at_slice_start() {
5748 let alphabet = ['$', 'a', ' '];
5753 let mut inputs: Vec<String> = vec![String::new()];
5754 let mut frontier: Vec<String> = vec![String::new()];
5755 for _ in 0..6 {
5756 let mut longer = Vec::new();
5757 for prefix in &frontier {
5758 for ch in alphabet {
5759 let mut s = prefix.clone();
5760 s.push(ch);
5761 longer.push(s);
5762 }
5763 }
5764 inputs.extend(longer.iter().cloned());
5765 frontier = longer;
5766 }
5767 inputs.push("$αβ$x".to_string());
5769 inputs.push("$α$$".to_string());
5770
5771 for s in &inputs {
5772 let expected = INLINE_MATH_REGEX
5773 .find(s)
5774 .ok()
5775 .flatten()
5776 .filter(|m| m.start() == 0)
5777 .map(|m| m.end());
5778 assert_eq!(inline_math_len_at_start(s), expected, "input: {s:?}");
5779 }
5780 }
5781
5782 #[test]
5783 fn inline_math_probe_after_dollar_matches_uncached_parse() {
5784 let cases = [
5790 ("$$a$$$b c$ x", r#"[DisplayMath("a"), InlineMath("b c"), Text(" x")]"#),
5791 (
5792 "$$a$$$b$ $$a$$$b$",
5793 r#"[DisplayMath("a"), InlineMath("b"), Text(" "), DisplayMath("a"), InlineMath("b")]"#,
5794 ),
5795 (
5797 "$$a$$$ x $y z$",
5798 r#"[DisplayMath("a"), InlineMath(" x "), Text("y z$")]"#,
5799 ),
5800 ("$$a$$$$ x", r#"[DisplayMath("a"), Text("$$ x")]"#),
5802 ("$$a$$$$b$$ x", r#"[DisplayMath("a"), DisplayMath("b"), Text(" x")]"#),
5803 (
5805 "$a$$b$$c$$d$ tail",
5806 r#"[Text("$a"), DisplayMath("b"), Text("c$$d$ tail")]"#,
5807 ),
5808 ];
5809 for (input, expected) in cases {
5810 let elements = parse_markdown_elements_inner(input, false, false, None);
5811 assert_eq!(format!("{elements:?}"), expected, "input: {input:?}");
5812 }
5813 }
5814
5815 #[test]
5816 fn test_atomic_spans() {
5817 let text_emphasis = "hello **word1 word2**";
5819
5820 let options_disabled = ReflowOptions {
5821 line_length: 18,
5822 atomic_spans: true,
5823 ..Default::default()
5824 };
5825 let lines_disabled = reflow_line(text_emphasis, &options_disabled);
5826 assert_eq!(lines_disabled, vec!["hello", "**word1 word2**"]);
5827
5828 let options_enabled = ReflowOptions {
5829 line_length: 18,
5830 atomic_spans: false,
5831 ..Default::default()
5832 };
5833 let lines_enabled = reflow_line(text_emphasis, &options_enabled);
5834 assert_eq!(lines_enabled, vec!["hello **word1", "word2**"]);
5835
5836 let text_code = "hello `word1 word2`";
5838
5839 let lines_code_disabled = reflow_line(text_code, &options_disabled);
5840 assert_eq!(lines_code_disabled, vec!["hello", "`word1 word2`"]);
5841
5842 let lines_code_enabled = reflow_line(text_code, &options_enabled);
5843 assert_eq!(lines_code_enabled, vec!["hello `word1", "word2`"]);
5844
5845 let text_code_padding = "hello `` `word1` `word2` ``";
5847 let lines_padding_enabled = reflow_line(text_code_padding, &options_enabled);
5848 assert_eq!(lines_padding_enabled, vec![r#"hello `` `word1`"#, r#"`word2` ``"#]);
5849
5850 let text_attached = "**one two**,"; let options_11 = ReflowOptions {
5855 line_length: 11,
5856 atomic_spans: true,
5857 ..Default::default()
5858 };
5859 assert_eq!(reflow_line(text_attached, &options_11), vec!["**one two**,"]);
5860
5861 let options_10 = ReflowOptions {
5863 line_length: 10,
5864 atomic_spans: true,
5865 ..Default::default()
5866 };
5867 assert_eq!(reflow_line(text_attached, &options_10), vec!["**one", "two**,"]);
5868 }
5869
5870 #[test]
5871 fn test_emphasis_containing_markers_is_not_split() {
5872 let options = ReflowOptions {
5873 line_length: 5,
5874 atomic_spans: false,
5875 ..Default::default()
5876 };
5877 let lines = reflow_line(r#"*foo \*bar*"#, &options);
5879 assert_eq!(lines, vec![r#"*foo \*bar*"#.to_string()]);
5880 }
5881
5882 fn semantic_shape(markdown: &str) -> String {
5887 let mut options = Options::empty();
5888 options.insert(Options::ENABLE_STRIKETHROUGH);
5889 let mut out = String::new();
5890 let push_prose = |out: &mut String, text: &str| {
5891 for c in text.chars() {
5892 if c.is_whitespace() {
5893 if !out.ends_with(char::is_whitespace) {
5894 out.push(' ');
5895 }
5896 } else {
5897 out.push(c);
5898 }
5899 }
5900 };
5901 for event in Parser::new_ext(markdown, options) {
5902 match event {
5903 Event::Text(text) => push_prose(&mut out, &text),
5904 Event::SoftBreak | Event::HardBreak => push_prose(&mut out, " "),
5905 Event::Code(code) => out.push_str(&format!("<code>{code}</code>")),
5907 Event::Start(tag) => out.push_str(&format!("<{tag:?}>")),
5908 Event::End(tag) => out.push_str(&format!("</{tag:?}>")),
5909 other => out.push_str(&format!("{other:?}")),
5910 }
5911 }
5912 out.trim().to_string()
5913 }
5914
5915 #[test]
5916 fn test_wrapping_a_span_never_changes_what_it_parses_to() {
5917 let corpus = [
5921 "_This is a very, very, very, very, very long line with some `code` inside._",
5922 "_alpha beta gamma delta epsilon `a b` zeta eta theta iota kappa lambda_",
5923 "**strong text with `code` and more words than fit on one single line**",
5924 "~~struck text with `code` and more words than fit on one single line~~",
5925 "_emphasis with **nested strong that is quite long** and trailing words_",
5926 "***A doubly nested bold italic span with more words than fit on a line***",
5929 "___Another doubly nested span with more words than fit on a single line___",
5930 "**_mixed strong then emphasis with more words than fit on a single line_**",
5931 "*__mixed emphasis then strong with more words than fit on a single line__*",
5932 "**~~strong strikethrough with more words than fit on a single line here~~**",
5933 "**a * b with a stray marker and plenty more words to pass the budget**",
5936 "_foo `a` bar `b` baz qux quux corge grault garply waldo fred plugh xyzzy_",
5937 "text before _a long emphasis with `code` inside of it here_ and after",
5938 "(_a parenthesized long emphasis with `code` inside of it right here_)",
5939 r#"*foo \*bar baz qux quux corge grault garply waldo fred plugh xyzzy*"#,
5940 "_tab\tseparated `a\tb` words spread out over quite a long emphasis span_",
5941 "_This has [text](<a b c d e f g h i j k>) and trailing words to wrap._",
5944 "_This has [`code`](<a b c d e f g h i j k>) and trailing words to wrap._",
5945 r#"_See [x](https://example.com "a long link title here") and `code` too._"#,
5946 "_A [link with a long label](https://example.com/path) and `code` here._",
5947 "_An image  plus `code` and more text_",
5948 ];
5949 for text in corpus {
5950 let expected = semantic_shape(text);
5951 for line_length in [20, 30, 40, 80] {
5952 for atomic_spans in [true, false] {
5953 let options = ReflowOptions {
5954 line_length,
5955 atomic_spans,
5956 ..Default::default()
5957 };
5958 let wrapped = reflow_line(text, &options).join("\n");
5959 assert_eq!(
5960 semantic_shape(&wrapped),
5961 expected,
5962 "reflow changed the parse of {text:?} at line_length={line_length} \
5963 atomic_spans={atomic_spans}\n wrapped: {wrapped:?}"
5964 );
5965 }
5966 }
5967 }
5968 }
5969
5970 #[test]
5971 fn test_wrapping_a_span_keeps_whole_the_constructs_pulldown_cannot_see() {
5972 let cases = [
5976 (
5977 "_alpha beta gamma [[a wiki link]] delta epsilon zeta eta_",
5978 "[[a wiki link]]",
5979 ),
5980 (
5981 "_alpha beta gamma {{< foo bar >}} delta epsilon zeta eta_",
5982 "{{< foo bar >}}",
5983 ),
5984 ("_alpha beta gamma $a + b$ delta epsilon zeta eta theta_", "$a + b$"),
5985 ("_alpha beta gamma $$a + b$$ delta epsilon zeta eta theta_", "$$a + b$$"),
5986 ];
5987 for (text, construct) in cases {
5988 for line_length in [12, 20, 30] {
5989 for atomic_spans in [true, false] {
5990 let options = ReflowOptions {
5991 line_length,
5992 atomic_spans,
5993 ..Default::default()
5994 };
5995 let wrapped = reflow_line(text, &options).join("\n");
5996 assert!(
5997 wrapped.contains(construct),
5998 "{construct} was broken at line_length={line_length} \
5999 atomic_spans={atomic_spans}: {wrapped:?}"
6000 );
6001 }
6002 }
6003 }
6004 }
6005
6006 #[test]
6007 fn test_overlong_emphasis_with_nested_code_span_wraps() {
6008 let options = ReflowOptions {
6012 line_length: 80,
6013 atomic_spans: true,
6014 ..Default::default()
6015 };
6016 let text = "_This is a very, very, very, very, very, very, very long line that exceeds 80 characters with some `code` inside._";
6017 let lines = reflow_line(text, &options);
6018 assert_eq!(
6019 lines,
6020 vec![
6021 "_This is a very, very, very, very, very, very, very long line that exceeds 80",
6022 "characters with some `code` inside._",
6023 ]
6024 );
6025 }
6026
6027 #[test]
6028 fn test_overlong_emphasis_with_nested_strong_wraps() {
6029 let options = ReflowOptions {
6031 line_length: 80,
6032 atomic_spans: true,
6033 ..Default::default()
6034 };
6035 let text = "_This is a very, very, very, very, very, very, very long line that exceeds 80 characters with some **bold** inside._";
6036 let lines = reflow_line(text, &options);
6037 assert_eq!(
6038 lines,
6039 vec![
6040 "_This is a very, very, very, very, very, very, very long line that exceeds 80",
6041 "characters with some **bold** inside._",
6042 ]
6043 );
6044 }
6045
6046 #[test]
6047 fn test_overlong_doubly_nested_span_wraps() {
6048 let options = ReflowOptions {
6053 line_length: 80,
6054 atomic_spans: true,
6055 ..Default::default()
6056 };
6057 let body = "This is a very, very, very, very, very, very, very, very, very, very long line that is emphasised.";
6058 for (open, close) in [
6059 ("***", "***"),
6060 ("___", "___"),
6061 ("**_", "_**"),
6062 ("*__", "__*"),
6063 ("**~~", "~~**"),
6064 ] {
6065 let text = format!("{open}{body}{close}");
6066 assert!(text.len() > options.line_length, "case must start over budget");
6067 let lines = reflow_line(&text, &options);
6068 assert!(
6069 lines.len() > 1,
6070 "{open}...{close} should wrap but stayed on one line: {lines:?}"
6071 );
6072 assert!(
6073 lines.iter().all(|line| line.len() <= options.line_length),
6074 "{open}...{close} left a line over the budget: {lines:?}"
6075 );
6076 assert_eq!(
6077 lines.join(" "),
6078 text,
6079 "{open}...{close} wrapping must only replace a space with a newline"
6080 );
6081 }
6082 }
6083
6084 #[test]
6085 fn test_overlong_span_with_stray_marker_stays_whole() {
6086 let options = ReflowOptions {
6090 line_length: 40,
6091 atomic_spans: true,
6092 ..Default::default()
6093 };
6094 let text = "**alpha * beta gamma delta epsilon zeta eta theta iota kappa**";
6095 let lines = reflow_line(text, &options);
6096 assert_eq!(lines, vec![text], "stray marker must keep the span whole");
6097 }
6098
6099 #[test]
6100 fn test_overlong_span_never_breaks_inside_a_nested_reference_link() {
6101 let options = ReflowOptions {
6107 line_length: 30,
6108 atomic_spans: true,
6109 defined_references: Some(HashSet::from([
6110 "ref".to_string(),
6111 "one two three four five six seven".to_string(),
6113 ])),
6114 ..Default::default()
6115 };
6116 for (text, link) in [
6117 (
6118 "_**alpha [one two three four five six seven][ref] beta gamma delta**_",
6119 "[one two three four five six seven][ref]",
6120 ),
6121 (
6122 "**alpha [one two three four five six seven][ref] beta gamma delta**",
6123 "[one two three four five six seven][ref]",
6124 ),
6125 (
6126 "_**alpha ![one two three four five six seven][ref] beta gamma delta**_",
6127 "![one two three four five six seven][ref]",
6128 ),
6129 (
6130 "_**alpha [one two three four five six seven][] beta gamma delta**_",
6131 "[one two three four five six seven][]",
6132 ),
6133 (
6134 "_**alpha [one two three four five six seven] beta gamma delta**_",
6135 "[one two three four five six seven]",
6136 ),
6137 ] {
6138 let lines = reflow_line(text, &options);
6139 assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
6140 assert!(
6141 lines.iter().any(|line| line.contains(link)),
6142 "{link} must stay on one line: {lines:?}"
6143 );
6144 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
6145 }
6146 }
6147
6148 #[test]
6149 fn test_overlong_span_breaks_inside_an_undefined_shortcut_reference() {
6150 let options = ReflowOptions {
6154 line_length: 30,
6155 atomic_spans: true,
6156 defined_references: Some(HashSet::new()),
6157 ..Default::default()
6158 };
6159 let text = "_**alpha [one two three four five six seven] beta gamma delta**_";
6160 let lines = reflow_line(text, &options);
6161 assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
6162 assert!(
6163 !lines
6164 .iter()
6165 .any(|line| line.contains("[one two three four five six seven]")),
6166 "an undefined shortcut is prose and should break: {lines:?}"
6167 );
6168 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
6169 }
6170
6171 #[test]
6172 fn test_overlong_span_never_breaks_inside_a_nested_attr_list() {
6173 let attr = "{.highlight key=\"a b c\"}";
6177 let text = format!("_**alpha beta gamma delta epsilon zeta{attr} eta theta iota kappa**_");
6178 let options = ReflowOptions {
6179 line_length: 20,
6180 atomic_spans: true,
6181 attr_lists: true,
6182 ..Default::default()
6183 };
6184 let lines = reflow_line(&text, &options);
6185 assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
6186 assert!(
6187 lines.iter().any(|line| line.contains(attr)),
6188 "attr list must stay on one line: {lines:?}"
6189 );
6190 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
6191
6192 let plain = ReflowOptions {
6195 attr_lists: false,
6196 ..options
6197 };
6198 let lines = reflow_line(&text, &plain);
6199 assert!(
6200 !lines.iter().any(|line| line.contains(attr)),
6201 "without the flavor the braces are prose and should break: {lines:?}"
6202 );
6203 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
6204 }
6205
6206 #[test]
6207 fn test_overlong_emphasis_never_breaks_inside_nested_code_span() {
6208 let options = ReflowOptions {
6212 line_length: 30,
6213 atomic_spans: true,
6214 ..Default::default()
6215 };
6216 let text = "_alpha beta gamma delta epsilon `a b` zeta eta theta iota kappa_";
6217 let lines = reflow_line(text, &options);
6218 assert!(lines.len() > 1, "over-long emphasis should wrap: {lines:?}");
6219 assert!(
6220 lines.iter().any(|line| line.contains("`a b`")),
6221 "nested code span must stay whole with its interior spaces: {lines:?}"
6222 );
6223 for line in &lines {
6224 assert_eq!(
6225 line.matches('`').count() % 2,
6226 0,
6227 "no line may contain half a code span: {line:?}"
6228 );
6229 }
6230 }
6231
6232 #[test]
6233 fn test_definition_list_marker_does_not_start_line() {
6234 let options = ReflowOptions {
6235 line_length: 20,
6236 ..Default::default()
6237 };
6238 let lines = reflow_line("This is a term and : definition here.", &options);
6240 for line in &lines {
6241 assert!(
6242 !line.trim_start().starts_with(": "),
6243 "Wrapped line should not start with definition marker: {line}"
6244 );
6245 }
6246 }
6247
6248 #[test]
6249 fn test_div_marker_does_not_start_line() {
6250 let options = ReflowOptions {
6251 line_length: 20,
6252 ..Default::default()
6253 };
6254 let lines = reflow_line("This is some text with ::: class marker.", &options);
6256 for line in &lines {
6257 assert!(
6258 !line.trim_start().starts_with(":::"),
6259 "Wrapped line should not start with div marker: {line}"
6260 );
6261 }
6262 }
6263}