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
43#[derive(Clone)]
45pub struct ReflowOptions {
46 pub line_length: usize,
48 pub break_on_sentences: bool,
50 pub preserve_breaks: bool,
52 pub sentence_per_line: bool,
54 pub semantic_line_breaks: bool,
56 pub abbreviations: Option<Vec<String>>,
60 pub length_mode: ReflowLengthMode,
62 pub attr_lists: bool,
65 pub myst_roles: bool,
69 pub require_sentence_capital: bool,
74 pub max_list_continuation_indent: Option<usize>,
78 pub defined_references: Option<HashSet<String>>,
92}
93
94impl Default for ReflowOptions {
95 fn default() -> Self {
96 Self {
97 line_length: 80,
98 break_on_sentences: true,
99 preserve_breaks: false,
100 sentence_per_line: false,
101 semantic_line_breaks: false,
102 abbreviations: None,
103 length_mode: ReflowLengthMode::default(),
104 attr_lists: false,
105 myst_roles: false,
106 require_sentence_capital: true,
107 max_list_continuation_indent: None,
108 defined_references: None,
109 }
110 }
111}
112
113pub fn normalize_reference_label(label: &str) -> String {
120 label.split_whitespace().collect::<Vec<_>>().join(" ").to_lowercase()
121}
122
123fn compute_inline_code_mask(text: &str) -> Vec<bool> {
126 let code_spans = extract_code_spans(text);
127 let chars: Vec<char> = text.chars().collect();
128 let mut mask = vec![false; chars.len()];
129 let mut span_it = code_spans.iter().peekable();
130 let mut byte_idx = 0;
131 for (char_idx, ch) in chars.iter().enumerate() {
135 let next_byte_idx = byte_idx + ch.len_utf8();
136 while let Some(span) = span_it.peek() {
137 if span.end <= byte_idx {
138 span_it.next();
139 } else {
140 break;
141 }
142 }
143 if let Some(span) = span_it.peek()
144 && byte_idx >= span.start
145 && byte_idx < span.end
146 {
147 mask[char_idx] = true;
148 }
149 byte_idx = next_byte_idx;
150 }
151 mask
152}
153
154fn footnote_refs_end(chars: &[char], start: usize) -> Option<usize> {
160 let mut pos = start;
161 let mut found = false;
162
163 loop {
164 if chars.get(pos) != Some(&'[') || chars.get(pos + 1) != Some(&'^') {
165 break;
166 }
167 let label_start = pos + 2;
168 let mut label_end = label_start;
169 while matches!(chars.get(label_end), Some(c) if c.is_ascii_alphanumeric() || *c == '_' || *c == '-') {
170 label_end += 1;
171 }
172 if label_end == label_start || chars.get(label_end) != Some(&']') {
173 break;
174 }
175 pos = label_end + 1;
176 found = true;
177 }
178
179 found.then_some(pos)
180}
181
182fn is_sentence_boundary(
186 text: &str,
187 chars: &[char],
188 pos: usize,
189 abbreviations: &HashSet<String>,
190 require_sentence_capital: bool,
191) -> bool {
192 if pos + 1 >= chars.len() {
193 return false;
194 }
195
196 let c = chars[pos];
197 let next_char = chars[pos + 1];
198
199 if is_cjk_sentence_ending(c) {
202 let mut after_punct_pos = pos + 1;
204 while after_punct_pos < chars.len()
205 && (chars[after_punct_pos] == '*' || chars[after_punct_pos] == '_' || chars[after_punct_pos] == '~')
206 {
207 after_punct_pos += 1;
208 }
209
210 while after_punct_pos < chars.len() && chars[after_punct_pos].is_whitespace() {
212 after_punct_pos += 1;
213 }
214
215 if after_punct_pos >= chars.len() {
217 return false;
218 }
219
220 while after_punct_pos < chars.len()
222 && (chars[after_punct_pos] == '*' || chars[after_punct_pos] == '_' || chars[after_punct_pos] == '~')
223 {
224 after_punct_pos += 1;
225 }
226
227 if after_punct_pos >= chars.len() {
228 return false;
229 }
230
231 return true;
234 }
235
236 if c != '.' && c != '!' && c != '?' {
238 return false;
239 }
240
241 let (_space_pos, after_space_pos) = if next_char == ' ' {
243 (pos + 1, pos + 2)
245 } else if is_closing_quote(next_char) && pos + 2 < chars.len() {
246 if chars[pos + 2] == ' ' {
248 (pos + 2, pos + 3)
250 } else if (chars[pos + 2] == '*' || chars[pos + 2] == '_') && pos + 3 < chars.len() && chars[pos + 3] == ' ' {
251 (pos + 3, pos + 4)
253 } else if (chars[pos + 2] == '*' || chars[pos + 2] == '_')
254 && pos + 4 < chars.len()
255 && chars[pos + 3] == chars[pos + 2]
256 && chars[pos + 4] == ' '
257 {
258 (pos + 4, pos + 5)
260 } else {
261 return false;
262 }
263 } else if (next_char == '*' || next_char == '_') && pos + 2 < chars.len() && chars[pos + 2] == ' ' {
264 (pos + 2, pos + 3)
266 } else if (next_char == '*' || next_char == '_')
267 && pos + 3 < chars.len()
268 && chars[pos + 2] == next_char
269 && chars[pos + 3] == ' '
270 {
271 (pos + 3, pos + 4)
273 } else if next_char == '~' && pos + 3 < chars.len() && chars[pos + 2] == '~' && chars[pos + 3] == ' ' {
274 (pos + 3, pos + 4)
276 } else if next_char == '[' {
277 match footnote_refs_end(chars, pos + 1) {
283 Some(end_pos) if chars.get(end_pos) == Some(&' ') => (end_pos, end_pos + 1),
284 _ => return false,
285 }
286 } else {
287 return false;
288 };
289
290 let mut next_char_pos = after_space_pos;
292 while next_char_pos < chars.len() && chars[next_char_pos].is_whitespace() {
293 next_char_pos += 1;
294 }
295
296 if next_char_pos >= chars.len() {
298 return false;
299 }
300
301 let mut first_letter_pos = next_char_pos;
303 while first_letter_pos < chars.len()
304 && (chars[first_letter_pos] == '*'
305 || chars[first_letter_pos] == '_'
306 || chars[first_letter_pos] == '~'
307 || is_opening_quote(chars[first_letter_pos]))
308 {
309 first_letter_pos += 1;
310 }
311
312 if first_letter_pos >= chars.len() {
314 return false;
315 }
316
317 let first_char = chars[first_letter_pos];
318
319 if c == '!' || c == '?' {
321 return true;
322 }
323
324 if pos > 0 {
328 let byte_offset: usize = chars[..=pos].iter().map(|ch| ch.len_utf8()).sum();
330 if text_ends_with_abbreviation(&text[..byte_offset], abbreviations) {
331 return false;
332 }
333
334 if chars[pos - 1].is_numeric() && first_char.is_ascii_digit() {
336 return false;
337 }
338
339 if chars[pos - 1].is_ascii_uppercase() && (pos == 1 || (pos >= 2 && chars[pos - 2].is_whitespace())) {
343 return false;
344 }
345 }
346
347 if require_sentence_capital && !first_char.is_uppercase() && !is_cjk_char(first_char) {
350 return false;
351 }
352
353 true
354}
355
356pub fn split_into_sentences(text: &str) -> Vec<String> {
358 split_into_sentences_custom(text, &None)
359}
360
361pub fn split_into_sentences_custom(text: &str, custom_abbreviations: &Option<Vec<String>>) -> Vec<String> {
363 let abbreviations = get_abbreviations(custom_abbreviations);
364 split_into_sentences_with_set(text, &abbreviations, true)
365}
366
367fn split_into_sentences_with_set(
370 text: &str,
371 abbreviations: &HashSet<String>,
372 require_sentence_capital: bool,
373) -> Vec<String> {
374 let in_code = compute_inline_code_mask(text);
376 let char_vec: Vec<char> = text.chars().collect();
379
380 let mut sentences = Vec::new();
381 let mut current_sentence = String::new();
382 let mut chars = text.chars().peekable();
383 let mut pos = 0;
384
385 while let Some(c) = chars.next() {
386 current_sentence.push(c);
387
388 if !in_code[pos] && is_sentence_boundary(text, &char_vec, pos, abbreviations, require_sentence_capital) {
389 if let Some(end_pos) = footnote_refs_end(&char_vec, pos + 1) {
394 while pos + 1 < end_pos {
395 current_sentence.push(chars.next().unwrap());
396 pos += 1;
397 }
398 }
399
400 while let Some(&next) = chars.peek() {
402 if next == '*' || next == '_' || next == '~' || is_closing_quote(next) {
403 current_sentence.push(chars.next().unwrap());
404 pos += 1;
405 } else {
406 break;
407 }
408 }
409
410 if chars.peek() == Some(&' ') {
412 chars.next();
413 pos += 1;
414 }
415
416 sentences.push(current_sentence.trim().to_string());
417 current_sentence.clear();
418 }
419
420 pos += 1;
421 }
422
423 if !current_sentence.trim().is_empty() {
425 sentences.push(current_sentence.trim().to_string());
426 }
427 sentences
428}
429
430fn is_horizontal_rule(line: &str) -> bool {
432 if line.len() < 3 {
433 return false;
434 }
435
436 let mut chars = line.chars();
439 let Some(first_char) = chars.next() else {
440 return false;
441 };
442 if first_char != '-' && first_char != '_' && first_char != '*' {
443 return false;
444 }
445
446 let mut non_space_count = 1usize; for c in chars {
448 if c == ' ' {
449 continue;
450 }
451 if c != first_char {
452 return false;
453 }
454 non_space_count += 1;
455 }
456 non_space_count >= 3
457}
458
459fn is_numbered_list_item(line: &str) -> bool {
461 let mut chars = line.chars();
462
463 if !chars.next().is_some_and(char::is_numeric) {
465 return false;
466 }
467
468 while let Some(c) = chars.next() {
470 if c == '.' {
471 return chars.next() == Some(' ');
474 }
475 if !c.is_numeric() {
476 return false;
477 }
478 }
479
480 false
481}
482
483fn is_unordered_list_marker(s: &str) -> bool {
485 matches!(s.as_bytes().first(), Some(b'-' | b'*' | b'+'))
486 && !is_horizontal_rule(s)
487 && (s.len() == 1 || s.as_bytes().get(1) == Some(&b' '))
488}
489
490fn is_block_boundary_core(trimmed: &str) -> bool {
493 trimmed.is_empty()
494 || trimmed.starts_with('#')
495 || trimmed.starts_with("```")
496 || trimmed.starts_with("~~~")
497 || trimmed.starts_with('>')
498 || (trimmed.starts_with('[') && trimmed.contains("]:"))
499 || is_horizontal_rule(trimmed)
500 || is_unordered_list_marker(trimmed)
501 || is_numbered_list_item(trimmed)
502 || is_definition_list_item(trimmed)
503 || trimmed.starts_with(":::")
504}
505
506fn is_block_boundary(trimmed: &str) -> bool {
509 is_block_boundary_core(trimmed) || trimmed.starts_with('|')
510}
511
512fn is_paragraph_boundary(trimmed: &str, line: &str) -> bool {
516 is_block_boundary_core(trimmed)
517 || calculate_indentation_width_default(line) >= 4
518 || crate::utils::table_utils::TableUtils::is_potential_table_row(line)
519}
520
521fn has_hard_break(line: &str) -> bool {
527 let line = line.strip_suffix('\r').unwrap_or(line);
528 line.ends_with(" ") || line.ends_with('\\')
529}
530
531fn ends_with_sentence_punct(text: &str) -> bool {
533 text.ends_with('.') || text.ends_with('!') || text.ends_with('?')
534}
535
536fn trim_preserving_hard_break(s: &str) -> String {
542 let s = s.strip_suffix('\r').unwrap_or(s);
544
545 if s.ends_with('\\') {
547 return s.to_string();
549 }
550
551 if s.ends_with(" ") {
553 let content_end = s.trim_end().len();
555 if content_end == 0 {
556 return String::new();
558 }
559 format!("{} ", &s[..content_end])
561 } else {
562 s.trim_end().to_string()
564 }
565}
566
567fn parse_elements(text: &str, options: &ReflowOptions) -> Vec<Element> {
569 parse_markdown_elements_inner(
570 text,
571 options.attr_lists,
572 options.myst_roles,
573 options.defined_references.as_ref(),
574 )
575}
576
577pub fn reflow_line(line: &str, options: &ReflowOptions) -> Vec<String> {
578 if options.sentence_per_line {
580 let elements = parse_elements(line, options);
581 return reflow_elements_sentence_per_line(&elements, &options.abbreviations, options.require_sentence_capital);
582 }
583
584 if options.semantic_line_breaks {
586 let elements = parse_elements(line, options);
587 return reflow_elements_semantic(&elements, options);
588 }
589
590 if options.line_length == 0 || display_len(line, options.length_mode) <= options.line_length {
593 return vec![line.to_string()];
594 }
595
596 let elements = parse_elements(line, options);
598
599 reflow_elements(&elements, options)
601}
602
603#[derive(Debug, Clone)]
605enum Element {
606 Text(String),
608 Link(String),
610 ReferenceLink(String),
612 EmptyReferenceLink(String),
614 ShortcutReference(String),
616 InlineImage(String),
618 ReferenceImage(String),
620 EmptyReferenceImage(String),
622 LinkedImage(String),
624 FootnoteReference(String),
626 Strikethrough {
628 content: String,
629 double: bool,
631 },
632 WikiLink(String),
634 InlineMath(String),
636 DisplayMath(String),
638 EmojiShortcode(String),
640 Autolink(String),
642 HtmlTag(String),
644 HtmlEntity(String),
646 HugoShortcode(String),
648 AttrList(String),
650 MystRole(String),
654 Code(String),
656 Bold {
658 content: String,
659 underscore: bool,
661 },
662 Italic {
664 content: String,
665 underscore: bool,
667 },
668}
669
670impl std::fmt::Display for Element {
671 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
672 match self {
673 Element::Text(s) => write!(f, "{s}"),
674 Element::Link(s) => write!(f, "{s}"),
675 Element::ReferenceLink(s) => write!(f, "{s}"),
676 Element::EmptyReferenceLink(s) => write!(f, "{s}"),
677 Element::ShortcutReference(s) => write!(f, "{s}"),
678 Element::InlineImage(s) => write!(f, "{s}"),
679 Element::ReferenceImage(s) => write!(f, "{s}"),
680 Element::EmptyReferenceImage(s) => write!(f, "{s}"),
681 Element::LinkedImage(s) => write!(f, "{s}"),
682 Element::FootnoteReference(s) => write!(f, "{s}"),
683 Element::Strikethrough { content, double } => {
684 let marker = if *double { "~~" } else { "~" };
685 write!(f, "{marker}{content}{marker}")
686 }
687 Element::WikiLink(s) => write!(f, "[[{s}]]"),
688 Element::InlineMath(s) => write!(f, "${s}$"),
689 Element::DisplayMath(s) => write!(f, "$${s}$$"),
690 Element::EmojiShortcode(s) => write!(f, ":{s}:"),
691 Element::Autolink(s) => write!(f, "{s}"),
692 Element::HtmlTag(s) => write!(f, "{s}"),
693 Element::HtmlEntity(s) => write!(f, "{s}"),
694 Element::HugoShortcode(s) => write!(f, "{s}"),
695 Element::AttrList(s) => write!(f, "{s}"),
696 Element::MystRole(s) => write!(f, "{s}"),
697 Element::Code(s) => write!(f, "`{s}`"),
698 Element::Bold { content, underscore } => {
699 if *underscore {
700 write!(f, "__{content}__")
701 } else {
702 write!(f, "**{content}**")
703 }
704 }
705 Element::Italic { content, underscore } => {
706 if *underscore {
707 write!(f, "_{content}_")
708 } else {
709 write!(f, "*{content}*")
710 }
711 }
712 }
713 }
714}
715
716#[derive(Debug, Clone)]
718struct EmphasisSpan {
719 start: usize,
721 end: usize,
723 content: String,
725 is_strong: bool,
727 is_strikethrough: bool,
729 uses_underscore: bool,
731 strikethrough_double: bool,
734}
735
736fn extract_emphasis_spans(text: &str) -> Vec<EmphasisSpan> {
746 if !text.contains(['*', '_', '~']) {
749 return Vec::new();
750 }
751
752 let mut spans = Vec::new();
753 let mut options = Options::empty();
754 options.insert(Options::ENABLE_STRIKETHROUGH);
755
756 let mut emphasis_stack: Vec<(usize, bool)> = Vec::new(); let mut strong_stack: Vec<(usize, bool)> = Vec::new();
759 let mut strikethrough_stack: Vec<usize> = Vec::new();
760
761 let parser = Parser::new_ext(text, options).into_offset_iter();
762
763 for (event, range) in parser {
764 match event {
765 Event::Start(Tag::Emphasis) => {
766 let uses_underscore = text.get(range.start..range.start + 1) == Some("_");
768 emphasis_stack.push((range.start, uses_underscore));
769 }
770 Event::End(TagEnd::Emphasis) => {
771 if let Some((start_byte, uses_underscore)) = emphasis_stack.pop() {
772 let content_start = start_byte + 1;
774 let content_end = range.end - 1;
775 if content_end > content_start
776 && let Some(content) = text.get(content_start..content_end)
777 {
778 spans.push(EmphasisSpan {
779 start: start_byte,
780 end: range.end,
781 content: content.to_string(),
782 is_strong: false,
783 is_strikethrough: false,
784 uses_underscore,
785 strikethrough_double: false,
786 });
787 }
788 }
789 }
790 Event::Start(Tag::Strong) => {
791 let uses_underscore = text.get(range.start..range.start + 2) == Some("__");
793 strong_stack.push((range.start, uses_underscore));
794 }
795 Event::End(TagEnd::Strong) => {
796 if let Some((start_byte, uses_underscore)) = strong_stack.pop() {
797 let content_start = start_byte + 2;
799 let content_end = range.end - 2;
800 if content_end > content_start
801 && let Some(content) = text.get(content_start..content_end)
802 {
803 spans.push(EmphasisSpan {
804 start: start_byte,
805 end: range.end,
806 content: content.to_string(),
807 is_strong: true,
808 is_strikethrough: false,
809 uses_underscore,
810 strikethrough_double: false,
811 });
812 }
813 }
814 }
815 Event::Start(Tag::Strikethrough) => {
816 strikethrough_stack.push(range.start);
817 }
818 Event::End(TagEnd::Strikethrough) => {
819 if let Some(start_byte) = strikethrough_stack.pop() {
820 let double = text.get(start_byte..start_byte + 2) == Some("~~");
824 let marker_len = if double { 2 } else { 1 };
825 let content_start = start_byte + marker_len;
826 let content_end = range.end - marker_len;
827 if content_end > content_start
828 && let Some(content) = text.get(content_start..content_end)
829 {
830 spans.push(EmphasisSpan {
831 start: start_byte,
832 end: range.end,
833 content: content.to_string(),
834 is_strong: false,
835 is_strikethrough: true,
836 uses_underscore: false,
837 strikethrough_double: double,
838 });
839 }
840 }
841 }
842 _ => {}
843 }
844 }
845
846 spans.sort_by_key(|s| s.start);
848 spans
849}
850
851#[derive(Debug, Clone)]
852struct CodeSpan {
853 start: usize,
854 end: usize,
855}
856
857fn extract_code_spans(text: &str) -> Vec<CodeSpan> {
858 if !text.contains('`') {
860 return Vec::new();
861 }
862
863 let mut spans = Vec::new();
864 let parser = Parser::new(text).into_offset_iter();
865 for (event, range) in parser {
866 if let Event::Code(_) = event {
867 spans.push(CodeSpan {
868 start: range.start,
869 end: range.end,
870 });
871 }
872 }
873 spans
874}
875
876#[derive(Debug, Clone)]
877struct LinkSpan {
878 start: usize,
879 end: usize,
880 link_type: Option<LinkType>,
881 is_image: bool,
882 is_footnote: bool,
883}
884
885fn extract_link_spans(text: &str, defined_references: Option<&HashSet<String>>) -> Vec<LinkSpan> {
886 if !text.contains('[') {
889 return Vec::new();
890 }
891
892 let mut spans = Vec::new();
893 let mut options = Options::empty();
894 options.insert(Options::ENABLE_FOOTNOTES);
895
896 let resolve = move |link: BrokenLink<'_>| -> Option<(CowStr<'_>, CowStr<'_>)> {
913 let atomic = match link.link_type {
918 LinkType::Shortcut | LinkType::ShortcutUnknown => match defined_references {
919 Some(defs) => defs.contains(&normalize_reference_label(link.reference.as_ref())),
920 None => true,
921 },
922 _ => true,
923 };
924 atomic.then_some((CowStr::Borrowed(""), CowStr::Borrowed("")))
925 };
926 let parser = Parser::new_with_broken_link_callback(text, options, Some(resolve)).into_offset_iter();
927 let mut stack = Vec::new();
928
929 for (event, range) in parser {
930 match event {
931 Event::Start(Tag::Link { link_type, .. }) => {
932 stack.push((range.start, Some(link_type), false));
933 }
934 Event::Start(Tag::Image { link_type, .. }) => {
935 stack.push((range.start, Some(link_type), true));
936 }
937 Event::End(TagEnd::Link) => {
938 if let Some((start_byte, link_type, is_image)) = stack.pop()
939 && stack.is_empty()
940 {
941 spans.push(LinkSpan {
942 start: start_byte,
943 end: range.end,
944 link_type,
945 is_image,
946 is_footnote: false,
947 });
948 }
949 }
950 Event::End(TagEnd::Image) => {
951 if let Some((start_byte, link_type, is_image)) = stack.pop()
952 && stack.is_empty()
953 {
954 spans.push(LinkSpan {
955 start: start_byte,
956 end: range.end,
957 link_type,
958 is_image,
959 is_footnote: false,
960 });
961 }
962 }
963 Event::FootnoteReference(_) if stack.is_empty() => {
964 spans.push(LinkSpan {
965 start: range.start,
966 end: range.end,
967 link_type: None,
968 is_image: false,
969 is_footnote: true,
970 });
971 }
972 _ => {}
973 }
974 }
975
976 spans.sort_by_key(|s| s.start);
977 spans
978}
979
980fn myst_role_len_at(text: &str) -> Option<usize> {
988 let bytes = text.as_bytes();
989 if bytes.first() != Some(&b'{') {
990 return None;
991 }
992
993 let mut j = 1;
995 match bytes.get(j) {
996 Some(&b) if b.is_ascii_alphabetic() || b == b'_' => {}
997 _ => return None,
998 }
999 while let Some(&b) = bytes.get(j) {
1000 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b':' | b'.') {
1001 j += 1;
1002 } else {
1003 break;
1004 }
1005 }
1006 if bytes.get(j) != Some(&b'}') {
1007 return None;
1008 }
1009 j += 1; if bytes.get(j) != Some(&b'`') {
1013 return None;
1014 }
1015 let backtick_start = j;
1016 while bytes.get(j) == Some(&b'`') {
1017 j += 1;
1018 }
1019 let backtick_count = j - backtick_start;
1020
1021 while j + backtick_count <= bytes.len() {
1023 if bytes[j] == b'`' {
1024 let close_count = bytes[j..].iter().take_while(|&&b| b == b'`').count();
1025 if close_count == backtick_count {
1026 return Some(j + close_count);
1027 }
1028 j += close_count;
1029 } else {
1030 j += 1;
1031 }
1032 }
1033
1034 None
1035}
1036
1037fn parse_markdown_elements_inner(
1048 text: &str,
1049 attr_lists: bool,
1050 myst_roles: bool,
1051 defined_references: Option<&HashSet<String>>,
1052) -> Vec<Element> {
1053 let mut elements = Vec::new();
1054 let mut remaining = text;
1055
1056 let emphasis_spans = extract_emphasis_spans(text);
1064 let link_spans = extract_link_spans(text, defined_references);
1065
1066 while !remaining.is_empty() {
1067 let current_offset = text.len() - remaining.len();
1069 let mut earliest_match: Option<(usize, usize, &str)> = None;
1072
1073 let mut next_link: Option<&LinkSpan> = None;
1075 for span in &link_spans {
1076 if span.start >= current_offset {
1077 next_link = Some(span);
1078 break;
1079 }
1080 }
1081
1082 if let Some(span) = next_link {
1083 let pos_in_remaining = span.start - current_offset;
1084 if earliest_match
1085 .as_ref()
1086 .is_none_or(|(start, _, _)| pos_in_remaining < *start)
1087 {
1088 let match_end = span.end - current_offset;
1089 earliest_match = Some((pos_in_remaining, match_end, "link_span"));
1090 }
1091 }
1092
1093 if let Some(m) = WIKI_LINK_REGEX.find(remaining)
1095 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
1096 {
1097 earliest_match = Some((m.start(), m.end(), "wiki_link"));
1098 }
1099
1100 if let Some(m) = DISPLAY_MATH_REGEX.find(remaining)
1102 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
1103 {
1104 earliest_match = Some((m.start(), m.end(), "display_math"));
1105 }
1106
1107 if let Ok(Some(m)) = INLINE_MATH_REGEX.find(remaining)
1109 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
1110 {
1111 earliest_match = Some((m.start(), m.end(), "inline_math"));
1112 }
1113
1114 if let Some(m) = EMOJI_SHORTCODE_REGEX.find(remaining)
1116 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
1117 {
1118 earliest_match = Some((m.start(), m.end(), "emoji"));
1119 }
1120
1121 if let Some(m) = HTML_ENTITY_REGEX.find(remaining)
1123 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
1124 {
1125 earliest_match = Some((m.start(), m.end(), "html_entity"));
1126 }
1127
1128 if let Some(m) = HUGO_SHORTCODE_REGEX.find(remaining)
1131 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
1132 {
1133 earliest_match = Some((m.start(), m.end(), "hugo_shortcode"));
1134 }
1135
1136 if let Some(m) = HTML_TAG_PATTERN.find(remaining)
1139 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
1140 {
1141 let matched_text = &remaining[m.start()..m.end()];
1143 let is_url_autolink = matched_text.starts_with("<http://")
1144 || matched_text.starts_with("<https://")
1145 || matched_text.starts_with("<mailto:")
1146 || matched_text.starts_with("<ftp://")
1147 || matched_text.starts_with("<ftps://");
1148
1149 let is_email_autolink = {
1152 let content = matched_text.trim_start_matches('<').trim_end_matches('>');
1153 EMAIL_PATTERN.is_match(content)
1154 };
1155
1156 if is_url_autolink || is_email_autolink {
1157 } else {
1159 earliest_match = Some((m.start(), m.end(), "html_tag"));
1160 }
1161 }
1162
1163 let mut next_special = remaining.len();
1165 let mut special_type = "";
1166 let mut pulldown_emphasis: Option<&EmphasisSpan> = None;
1167 let mut attr_list_len: usize = 0;
1168 let mut myst_role_len: usize = 0;
1169
1170 if let Some(pos) = remaining.find('`')
1172 && pos < next_special
1173 {
1174 next_special = pos;
1175 special_type = "code";
1176 }
1177
1178 if myst_roles
1183 && let Some(pos) = remaining.find('{')
1184 && pos < next_special
1185 && let Some(role_len) = myst_role_len_at(&remaining[pos..])
1186 {
1187 next_special = pos;
1188 special_type = "myst_role";
1189 myst_role_len = role_len;
1190 }
1191
1192 if attr_lists
1194 && let Some(pos) = remaining.find('{')
1195 && pos < next_special
1196 && let Some(m) = ATTR_LIST_PATTERN.find(&remaining[pos..])
1197 && m.start() == 0
1198 {
1199 next_special = pos;
1200 special_type = "attr_list";
1201 attr_list_len = m.end();
1202 }
1203
1204 for span in &emphasis_spans {
1207 if span.start >= current_offset && span.start < current_offset + remaining.len() {
1208 let pos_in_remaining = span.start - current_offset;
1209 if pos_in_remaining < next_special {
1210 next_special = pos_in_remaining;
1211 special_type = "pulldown_emphasis";
1212 pulldown_emphasis = Some(span);
1213 }
1214 break; }
1216 }
1217
1218 let should_process_markdown_link = if let Some((pos, _, _)) = earliest_match {
1220 pos < next_special
1221 } else {
1222 false
1223 };
1224
1225 if should_process_markdown_link {
1226 let (pos, match_end, pattern_type) = earliest_match.unwrap();
1227
1228 if pos > 0 {
1230 elements.push(Element::Text(remaining[..pos].to_string()));
1231 }
1232
1233 match pattern_type {
1235 "link_span" => {
1236 let span = next_link.unwrap();
1237 let raw_text = remaining[pos..match_end].to_string();
1238 if span.is_footnote {
1239 elements.push(Element::FootnoteReference(raw_text));
1240 } else if span.is_image {
1241 match span.link_type {
1242 Some(LinkType::Inline) => elements.push(Element::InlineImage(raw_text)),
1243 Some(LinkType::Reference)
1246 | Some(LinkType::ReferenceUnknown)
1247 | Some(LinkType::Shortcut)
1248 | Some(LinkType::ShortcutUnknown) => elements.push(Element::ReferenceImage(raw_text)),
1249 Some(LinkType::Collapsed) | Some(LinkType::CollapsedUnknown) => {
1250 elements.push(Element::EmptyReferenceImage(raw_text))
1251 }
1252 _ => elements.push(Element::InlineImage(raw_text)),
1253 }
1254 } else {
1255 match span.link_type {
1256 Some(LinkType::Inline) => {
1257 if raw_text.starts_with('[') && raw_text.contains("![") {
1258 elements.push(Element::LinkedImage(raw_text));
1259 } else {
1260 elements.push(Element::Link(raw_text));
1261 }
1262 }
1263 Some(LinkType::Reference) | Some(LinkType::ReferenceUnknown) => {
1266 elements.push(Element::ReferenceLink(raw_text))
1267 }
1268 Some(LinkType::Collapsed) | Some(LinkType::CollapsedUnknown) => {
1269 elements.push(Element::EmptyReferenceLink(raw_text))
1270 }
1271 Some(LinkType::Shortcut) | Some(LinkType::ShortcutUnknown) => {
1272 elements.push(Element::ShortcutReference(raw_text))
1273 }
1274 Some(LinkType::Autolink) | Some(LinkType::Email) => {
1275 elements.push(Element::Autolink(raw_text))
1276 }
1277 _ => elements.push(Element::Link(raw_text)),
1278 }
1279 }
1280 remaining = &remaining[match_end..];
1281 }
1282 "wiki_link" => {
1283 if let Some(caps) = WIKI_LINK_REGEX.captures(remaining) {
1284 let content = caps.get(1).map_or("", |m| m.as_str());
1285 elements.push(Element::WikiLink(content.to_string()));
1286 remaining = &remaining[match_end..];
1287 } else {
1288 elements.push(Element::Text("[[".to_string()));
1289 remaining = &remaining[2..];
1290 }
1291 }
1292 "display_math" => {
1293 if let Some(caps) = DISPLAY_MATH_REGEX.captures(remaining) {
1294 let math = caps.get(1).map_or("", |m| m.as_str());
1295 elements.push(Element::DisplayMath(math.to_string()));
1296 remaining = &remaining[match_end..];
1297 } else {
1298 elements.push(Element::Text("$$".to_string()));
1299 remaining = &remaining[2..];
1300 }
1301 }
1302 "inline_math" => {
1303 if let Ok(Some(caps)) = INLINE_MATH_REGEX.captures(remaining) {
1304 let math = caps.get(1).map_or("", |m| m.as_str());
1305 elements.push(Element::InlineMath(math.to_string()));
1306 remaining = &remaining[match_end..];
1307 } else {
1308 elements.push(Element::Text("$".to_string()));
1309 remaining = &remaining[1..];
1310 }
1311 }
1312 "emoji" => {
1313 if let Some(caps) = EMOJI_SHORTCODE_REGEX.captures(remaining) {
1314 let emoji = caps.get(1).map_or("", |m| m.as_str());
1315 elements.push(Element::EmojiShortcode(emoji.to_string()));
1316 remaining = &remaining[match_end..];
1317 } else {
1318 elements.push(Element::Text(":".to_string()));
1319 remaining = &remaining[1..];
1320 }
1321 }
1322 "html_entity" => {
1323 elements.push(Element::HtmlEntity(remaining[pos..match_end].to_string()));
1325 remaining = &remaining[match_end..];
1326 }
1327 "hugo_shortcode" => {
1328 elements.push(Element::HugoShortcode(remaining[pos..match_end].to_string()));
1330 remaining = &remaining[match_end..];
1331 }
1332 "html_tag" => {
1333 elements.push(Element::HtmlTag(remaining[pos..match_end].to_string()));
1335 remaining = &remaining[match_end..];
1336 }
1337 _ => {
1338 elements.push(Element::Text("[".to_string()));
1340 remaining = &remaining[1..];
1341 }
1342 }
1343 } else {
1344 if next_special > 0 && next_special < remaining.len() {
1348 elements.push(Element::Text(remaining[..next_special].to_string()));
1349 remaining = &remaining[next_special..];
1350 }
1351
1352 match special_type {
1354 "code" => {
1355 if let Some(code_end) = remaining[1..].find('`') {
1357 let code = &remaining[1..=code_end];
1358 elements.push(Element::Code(code.to_string()));
1359 remaining = &remaining[1 + code_end + 1..];
1360 } else {
1361 elements.push(Element::Text(remaining.to_string()));
1363 break;
1364 }
1365 }
1366 "attr_list" => {
1367 elements.push(Element::AttrList(remaining[..attr_list_len].to_string()));
1368 remaining = &remaining[attr_list_len..];
1369 }
1370 "myst_role" => {
1371 elements.push(Element::MystRole(remaining[..myst_role_len].to_string()));
1372 remaining = &remaining[myst_role_len..];
1373 }
1374 "pulldown_emphasis" => {
1375 if let Some(span) = pulldown_emphasis {
1377 let span_len = span.end - span.start;
1378 if span.is_strikethrough {
1379 elements.push(Element::Strikethrough {
1380 content: span.content.clone(),
1381 double: span.strikethrough_double,
1382 });
1383 } else if span.is_strong {
1384 elements.push(Element::Bold {
1385 content: span.content.clone(),
1386 underscore: span.uses_underscore,
1387 });
1388 } else {
1389 elements.push(Element::Italic {
1390 content: span.content.clone(),
1391 underscore: span.uses_underscore,
1392 });
1393 }
1394 remaining = &remaining[span_len..];
1395 } else {
1396 elements.push(Element::Text(remaining[..1].to_string()));
1398 remaining = &remaining[1..];
1399 }
1400 }
1401 _ => {
1402 elements.push(Element::Text(remaining.to_string()));
1404 break;
1405 }
1406 }
1407 }
1408 }
1409
1410 elements
1411}
1412
1413fn should_insert_space_before_join(current: &str) -> bool {
1414 !current.is_empty()
1415 && !current.ends_with(' ')
1416 && !current.ends_with('(')
1417 && !current.ends_with('[')
1418 && !current.ends_with('-')
1419}
1420
1421fn reflow_elements_sentence_per_line(
1423 elements: &[Element],
1424 custom_abbreviations: &Option<Vec<String>>,
1425 require_sentence_capital: bool,
1426) -> Vec<String> {
1427 let abbreviations = get_abbreviations(custom_abbreviations);
1428 let mut lines = Vec::new();
1429 let mut current_line = String::new();
1430
1431 for (idx, element) in elements.iter().enumerate() {
1432 let element_str = format!("{element}");
1433
1434 if let Element::Text(text) = element {
1436 let combined = format!("{current_line}{text}");
1438 let sentences = split_into_sentences_with_set(&combined, &abbreviations, require_sentence_capital);
1440
1441 if sentences.len() > 1 {
1442 for (i, sentence) in sentences.iter().enumerate() {
1444 if i == 0 {
1445 let trimmed = sentence.trim();
1448
1449 if text_ends_with_abbreviation(trimmed, &abbreviations) {
1450 current_line.clone_from(sentence);
1452 } else {
1453 lines.push(sentence.clone());
1455 current_line.clear();
1456 }
1457 } else if i == sentences.len() - 1 {
1458 let trimmed = sentence.trim();
1460 let ends_with_sentence_punct = ends_with_sentence_punct(trimmed);
1461
1462 if ends_with_sentence_punct && !text_ends_with_abbreviation(trimmed, &abbreviations) {
1463 lines.push(sentence.clone());
1465 current_line.clear();
1466 } else {
1467 current_line.clone_from(sentence);
1469 }
1470 } else {
1471 lines.push(sentence.clone());
1473 }
1474 }
1475 } else {
1476 let trimmed = combined.trim();
1478
1479 if trimmed.is_empty() {
1483 continue;
1484 }
1485
1486 let ends_with_sentence_punct = ends_with_sentence_punct(trimmed);
1487
1488 if ends_with_sentence_punct && !text_ends_with_abbreviation(trimmed, &abbreviations) {
1489 lines.push(trimmed.to_string());
1491 current_line.clear();
1492 } else {
1493 current_line = combined;
1495 }
1496 }
1497 } else if let Element::Italic { content, underscore } = element {
1498 let marker = if *underscore { "_" } else { "*" };
1500 handle_emphasis_sentence_split(
1501 content,
1502 marker,
1503 &abbreviations,
1504 require_sentence_capital,
1505 &mut current_line,
1506 &mut lines,
1507 );
1508 } else if let Element::Bold { content, underscore } = element {
1509 let marker = if *underscore { "__" } else { "**" };
1511 handle_emphasis_sentence_split(
1512 content,
1513 marker,
1514 &abbreviations,
1515 require_sentence_capital,
1516 &mut current_line,
1517 &mut lines,
1518 );
1519 } else if let Element::Strikethrough { content, double } = element {
1520 handle_emphasis_sentence_split(
1522 content,
1523 if *double { "~~" } else { "~" },
1524 &abbreviations,
1525 require_sentence_capital,
1526 &mut current_line,
1527 &mut lines,
1528 );
1529 } else {
1530 let is_adjacent = if idx > 0 {
1533 match &elements[idx - 1] {
1534 Element::Text(t) => !t.is_empty() && !t.ends_with(char::is_whitespace),
1535 _ => true,
1536 }
1537 } else {
1538 false
1539 };
1540
1541 if !is_adjacent && should_insert_space_before_join(¤t_line) {
1543 current_line.push(' ');
1544 }
1545 current_line.push_str(&element_str);
1546 }
1547 }
1548
1549 if !current_line.is_empty() {
1551 lines.push(current_line.trim().to_string());
1552 }
1553 lines
1554}
1555
1556fn handle_emphasis_sentence_split(
1558 content: &str,
1559 marker: &str,
1560 abbreviations: &HashSet<String>,
1561 require_sentence_capital: bool,
1562 current_line: &mut String,
1563 lines: &mut Vec<String>,
1564) {
1565 let sentences = split_into_sentences_with_set(content, abbreviations, require_sentence_capital);
1567
1568 if sentences.len() <= 1 {
1569 if should_insert_space_before_join(current_line) {
1571 current_line.push(' ');
1572 }
1573 current_line.push_str(marker);
1574 current_line.push_str(content);
1575 current_line.push_str(marker);
1576
1577 let trimmed = content.trim();
1579 let ends_with_punct = ends_with_sentence_punct(trimmed);
1580 if ends_with_punct && !text_ends_with_abbreviation(trimmed, abbreviations) {
1581 lines.push(current_line.clone());
1582 current_line.clear();
1583 }
1584 } else {
1585 for (i, sentence) in sentences.iter().enumerate() {
1587 let trimmed = sentence.trim();
1588 if trimmed.is_empty() {
1589 continue;
1590 }
1591
1592 if i == 0 {
1593 if should_insert_space_before_join(current_line) {
1595 current_line.push(' ');
1596 }
1597 current_line.push_str(marker);
1598 current_line.push_str(trimmed);
1599 current_line.push_str(marker);
1600
1601 let ends_with_punct = ends_with_sentence_punct(trimmed);
1603 if ends_with_punct && !text_ends_with_abbreviation(trimmed, abbreviations) {
1604 lines.push(current_line.clone());
1605 current_line.clear();
1606 }
1607 } else if i == sentences.len() - 1 {
1608 let ends_with_punct = ends_with_sentence_punct(trimmed);
1610
1611 let mut line = String::new();
1612 line.push_str(marker);
1613 line.push_str(trimmed);
1614 line.push_str(marker);
1615
1616 if ends_with_punct && !text_ends_with_abbreviation(trimmed, abbreviations) {
1617 lines.push(line);
1618 } else {
1619 *current_line = line;
1621 }
1622 } else {
1623 let mut line = String::new();
1625 line.push_str(marker);
1626 line.push_str(trimmed);
1627 line.push_str(marker);
1628 lines.push(line);
1629 }
1630 }
1631 }
1632}
1633
1634const BREAK_WORDS: &[&str] = &[
1638 "and",
1639 "or",
1640 "but",
1641 "nor",
1642 "yet",
1643 "so",
1644 "for",
1645 "which",
1646 "that",
1647 "because",
1648 "when",
1649 "if",
1650 "while",
1651 "where",
1652 "although",
1653 "though",
1654 "unless",
1655 "since",
1656 "after",
1657 "before",
1658 "until",
1659 "as",
1660 "once",
1661 "whether",
1662 "however",
1663 "therefore",
1664 "moreover",
1665 "furthermore",
1666 "nevertheless",
1667 "whereas",
1668];
1669
1670fn is_clause_punctuation(c: char) -> bool {
1672 matches!(c, ',' | ';' | ':' | '\u{2014}') }
1674
1675fn clause_break_allowed_after(chars: &[char], i: usize) -> bool {
1683 if chars[i] == '\u{2014}' {
1684 return true;
1685 }
1686 match chars.get(i + 1) {
1687 None => true,
1688 Some(next) => next.is_whitespace(),
1689 }
1690}
1691
1692fn paren_group_end<'a>(slice: &'a str, element_spans: &[(usize, usize)], offset: usize) -> Option<(usize, &'a str)> {
1706 debug_assert!(slice.starts_with('('));
1707 let mut depth: i32 = 0;
1708 for (local_byte, c) in slice.char_indices() {
1709 let global_byte = offset + local_byte;
1710 if depth > 0 && is_inside_element(global_byte, element_spans) {
1715 continue;
1716 }
1717 match c {
1718 '(' => depth += 1,
1719 ')' => {
1720 depth -= 1;
1721 if depth == 0 {
1722 let end = local_byte + 1;
1723 let inner = &slice[1..local_byte];
1724 return Some((end, inner));
1725 }
1726 }
1727 _ => {}
1728 }
1729 }
1730 None
1731}
1732
1733fn split_at_parenthetical(
1750 text: &str,
1751 line_length: usize,
1752 element_spans: &[(usize, usize)],
1753 length_mode: ReflowLengthMode,
1754) -> Option<(String, String)> {
1755 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
1756
1757 if text.starts_with('(')
1759 && let Some((end_local, inner)) = paren_group_end(text, element_spans, 0)
1760 && inner.contains(' ')
1761 {
1762 let tail = &text[end_local..];
1766 let attached_len = tail
1767 .char_indices()
1768 .take_while(|(_, c)| is_closing_quote(*c) || is_clause_punctuation(*c))
1769 .last()
1770 .map_or(0, |(idx, c)| idx + c.len_utf8());
1771 let first_end = end_local + attached_len;
1772 let rest_start = first_end;
1773 let first = &text[..first_end];
1774 let first_len = display_len(first, length_mode);
1775 if first_len <= line_length {
1778 let rest = text[rest_start..].trim_start();
1779 if !rest.is_empty() {
1780 return Some((first.to_string(), rest.to_string()));
1781 }
1782 }
1783 }
1784
1785 let mut best_open_byte: Option<usize> = None;
1787 let mut pos = 0usize;
1788 while pos < text.len() {
1789 if text.as_bytes()[pos] != b'(' {
1791 let c = text[pos..].chars().next().unwrap();
1792 pos += c.len_utf8();
1793 continue;
1794 }
1795 if is_inside_element(pos, element_spans) {
1797 pos += 1;
1798 continue;
1799 }
1800 if let Some((end_local, inner)) = paren_group_end(&text[pos..], element_spans, pos) {
1801 let first = text[..pos].trim_end();
1802 let first_len = display_len(first, length_mode);
1803 if !first.is_empty()
1804 && first_len >= min_first_len
1805 && first_len <= line_length
1806 && inner.contains(' ')
1807 && best_open_byte.is_none_or(|prev| pos > prev)
1808 {
1809 best_open_byte = Some(pos);
1810 }
1811 pos += end_local;
1812 } else {
1813 pos += 1;
1814 }
1815 }
1816
1817 let open_byte = best_open_byte?;
1818 let first = text[..open_byte].trim_end().to_string();
1819 let rest = text[open_byte..].to_string();
1820 if first.is_empty() || rest.trim().is_empty() {
1821 return None;
1822 }
1823 Some((first, rest))
1824}
1825
1826fn compute_element_spans(elements: &[Element]) -> Vec<(usize, usize)> {
1830 let mut spans = Vec::new();
1831 let mut offset = 0;
1832 for element in elements {
1833 let rendered = format!("{element}");
1834 let len = rendered.len();
1835 if !matches!(element, Element::Text(_)) {
1836 spans.push((offset, offset + len));
1837 }
1838 offset += len;
1839 }
1840 spans
1841}
1842
1843fn is_inside_element(pos: usize, spans: &[(usize, usize)]) -> bool {
1845 spans.iter().any(|(start, end)| pos > *start && pos < *end)
1846}
1847
1848const MIN_SPLIT_RATIO: f64 = 0.3;
1851
1852fn split_at_clause_punctuation(
1856 text: &str,
1857 line_length: usize,
1858 element_spans: &[(usize, usize)],
1859 length_mode: ReflowLengthMode,
1860) -> Option<(String, String)> {
1861 let chars: Vec<char> = text.chars().collect();
1862 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
1863
1864 let mut width_acc = 0;
1866 let mut search_end_char = 0;
1867 for (idx, &c) in chars.iter().enumerate() {
1868 let c_width = display_len(&c.to_string(), length_mode);
1869 if width_acc + c_width > line_length {
1870 break;
1871 }
1872 width_acc += c_width;
1873 search_end_char = idx + 1;
1874 }
1875
1876 let mut paren_depth: i32 = 0;
1883 let mut best_pos = None;
1884 for i in (0..search_end_char).rev() {
1885 let byte_start: usize = chars[..i].iter().map(|c| c.len_utf8()).sum();
1887 let byte_after: usize = byte_start + chars[i].len_utf8();
1889
1890 if !is_inside_element(byte_start, element_spans) {
1891 match chars[i] {
1892 ')' => paren_depth += 1,
1893 '(' => paren_depth = paren_depth.saturating_sub(1),
1894 _ => {}
1895 }
1896 }
1897
1898 if paren_depth == 0
1899 && is_clause_punctuation(chars[i])
1900 && clause_break_allowed_after(&chars, i)
1901 && !is_inside_element(byte_after, element_spans)
1902 {
1903 best_pos = Some(i);
1904 break;
1905 }
1906 }
1907
1908 let pos = best_pos?;
1909
1910 let first: String = chars[..=pos].iter().collect();
1912 let first_display_len = display_len(&first, length_mode);
1913 if first_display_len < min_first_len {
1914 return None;
1915 }
1916
1917 let rest: String = chars[pos + 1..].iter().collect();
1919 let rest = rest.trim_start().to_string();
1920
1921 if rest.is_empty() {
1922 return None;
1923 }
1924
1925 Some((first, rest))
1926}
1927
1928fn paren_depth_map(text: &str, element_spans: &[(usize, usize)]) -> Vec<i32> {
1935 let mut map = vec![0i32; text.len()];
1936 let mut depth = 0i32;
1937 for (byte, c) in text.char_indices() {
1938 if !is_inside_element(byte, element_spans) {
1939 match c {
1940 '(' => depth += 1,
1941 ')' => depth = depth.saturating_sub(1),
1942 _ => {}
1943 }
1944 }
1945 let end = (byte + c.len_utf8()).min(map.len());
1947 for slot in &mut map[byte..end] {
1948 *slot = depth;
1949 }
1950 }
1951 map
1952}
1953
1954fn is_standalone_parenthetical(line: &str) -> bool {
1963 let trimmed = line.trim();
1964 if !trimmed.starts_with('(') {
1965 return false;
1966 }
1967 let core = trimmed.trim_end_matches(|c: char| is_clause_punctuation(c));
1969 if !core.ends_with(')') {
1970 return false;
1971 }
1972 let inner = &core[1..core.len() - 1];
1974 if !inner.contains(' ') {
1975 return false;
1976 }
1977 let mut depth = 0i32;
1979 for c in core.chars() {
1980 match c {
1981 '(' => depth += 1,
1982 ')' => depth -= 1,
1983 _ => {}
1984 }
1985 if depth < 0 {
1986 return false;
1987 }
1988 }
1989 depth == 0
1990}
1991
1992fn split_at_break_word(
1996 text: &str,
1997 line_length: usize,
1998 element_spans: &[(usize, usize)],
1999 length_mode: ReflowLengthMode,
2000) -> Option<(String, String)> {
2001 let lower = text.to_lowercase();
2002 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
2003 let mut best_split: Option<(usize, usize)> = None; let depth_map = paren_depth_map(text, element_spans);
2008
2009 for &word in BREAK_WORDS {
2010 let mut search_start = 0;
2011 while let Some(pos) = lower[search_start..].find(word) {
2012 let abs_pos = search_start + pos;
2013
2014 let preceded_by_space = abs_pos == 0 || text.as_bytes().get(abs_pos - 1) == Some(&b' ');
2016 let followed_by_space = text.as_bytes().get(abs_pos + word.len()) == Some(&b' ');
2017
2018 if preceded_by_space && followed_by_space {
2019 let first_part = text[..abs_pos].trim_end();
2021 let first_part_len = display_len(first_part, length_mode);
2022
2023 let inside_paren = depth_map.get(abs_pos).is_some_and(|&d| d > 0);
2025
2026 if first_part_len >= min_first_len
2027 && first_part_len <= line_length
2028 && !is_inside_element(abs_pos, element_spans)
2029 && !inside_paren
2030 {
2031 if best_split.is_none_or(|(prev_pos, _)| abs_pos > prev_pos) {
2033 best_split = Some((abs_pos, word.len()));
2034 }
2035 }
2036 }
2037
2038 search_start = abs_pos + word.len();
2039 }
2040 }
2041
2042 let (byte_start, _word_len) = best_split?;
2043
2044 let first = text[..byte_start].trim_end().to_string();
2045 let rest = text[byte_start..].to_string();
2046
2047 if first.is_empty() || rest.trim().is_empty() {
2048 return None;
2049 }
2050
2051 Some((first, rest))
2052}
2053
2054fn cascade_split_line(
2065 text: &str,
2066 line_length: usize,
2067 abbreviations: &Option<Vec<String>>,
2068 length_mode: ReflowLengthMode,
2069 attr_lists: bool,
2070 myst_roles: bool,
2071 defined_references: Option<&HashSet<String>>,
2072) -> Vec<String> {
2073 if line_length == 0 || display_len(text, length_mode) <= line_length {
2074 return vec![text.to_string()];
2075 }
2076
2077 let elements = parse_markdown_elements_inner(text, attr_lists, myst_roles, defined_references);
2078 let element_spans = compute_element_spans(&elements);
2079
2080 let rebased_spans = |start: usize| -> Vec<(usize, usize)> {
2084 if start == 0 {
2085 return element_spans.clone();
2086 }
2087 element_spans
2088 .iter()
2089 .filter(|&&(_, end)| end > start)
2090 .map(|&(s, e)| (s.saturating_sub(start), e.saturating_sub(start)))
2091 .collect()
2092 };
2093
2094 let mut result = Vec::new();
2095 let mut start = 0usize;
2096
2097 loop {
2098 let remaining = &text[start..];
2099 if display_len(remaining, length_mode) <= line_length {
2100 result.push(remaining.to_string());
2101 return result;
2102 }
2103
2104 let spans = rebased_spans(start);
2105
2106 let split = split_at_parenthetical(remaining, line_length, &spans, length_mode)
2110 .or_else(|| split_at_clause_punctuation(remaining, line_length, &spans, length_mode))
2111 .or_else(|| split_at_break_word(remaining, line_length, &spans, length_mode));
2112
2113 if let Some((first, rest)) = split {
2114 let consumed = remaining.len().saturating_sub(rest.len());
2115 if consumed == 0 {
2118 break;
2119 }
2120 result.push(first);
2121 start += consumed;
2122 continue;
2123 }
2124
2125 break;
2127 }
2128
2129 let options = ReflowOptions {
2131 line_length,
2132 break_on_sentences: false,
2133 preserve_breaks: false,
2134 sentence_per_line: false,
2135 semantic_line_breaks: false,
2136 abbreviations: abbreviations.clone(),
2137 length_mode,
2138 attr_lists,
2139 myst_roles,
2140 require_sentence_capital: true,
2141 max_list_continuation_indent: None,
2142 defined_references: None,
2145 };
2146 let remaining = &text[start..];
2147 let tail_elements = if start == 0 {
2148 elements
2149 } else {
2150 parse_markdown_elements_inner(remaining, attr_lists, myst_roles, defined_references)
2151 };
2152 result.extend(reflow_elements(&tail_elements, &options));
2153 result
2154}
2155
2156fn reflow_elements_semantic(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
2160 let sentence_lines =
2162 reflow_elements_sentence_per_line(elements, &options.abbreviations, options.require_sentence_capital);
2163
2164 if options.line_length == 0 {
2167 return sentence_lines;
2168 }
2169
2170 let length_mode = options.length_mode;
2171 let mut result = Vec::new();
2172 for line in sentence_lines {
2173 if display_len(&line, length_mode) <= options.line_length {
2174 result.push(line);
2175 } else {
2176 result.extend(cascade_split_line(
2177 &line,
2178 options.line_length,
2179 &options.abbreviations,
2180 length_mode,
2181 options.attr_lists,
2182 options.myst_roles,
2183 options.defined_references.as_ref(),
2184 ));
2185 }
2186 }
2187
2188 let min_line_len = ((options.line_length as f64) * MIN_SPLIT_RATIO) as usize;
2191 let mut merged: Vec<String> = Vec::with_capacity(result.len());
2192 for line in result {
2193 if !merged.is_empty() && display_len(&line, length_mode) < min_line_len && !line.trim().is_empty() {
2194 if is_standalone_parenthetical(&line) {
2197 merged.push(line);
2198 continue;
2199 }
2200
2201 let prev_ends_at_sentence = {
2203 let trimmed = merged.last().unwrap().trim_end();
2204 trimmed
2205 .chars()
2206 .rev()
2207 .find(|c| !matches!(c, '"' | '\'' | '\u{201D}' | '\u{2019}' | ')' | ']'))
2208 .is_some_and(|c| matches!(c, '.' | '!' | '?'))
2209 };
2210
2211 if !prev_ends_at_sentence {
2212 let prev = merged.last_mut().unwrap();
2213 let combined = format!("{prev} {line}");
2214 if display_len(&combined, length_mode) <= options.line_length {
2216 *prev = combined;
2217 continue;
2218 }
2219 }
2220 }
2221 merged.push(line);
2222 }
2223 merged
2224}
2225
2226fn rfind_safe_space(line: &str, element_spans: &[(usize, usize)]) -> Option<usize> {
2234 line.char_indices()
2235 .rev()
2236 .map(|(pos, _)| pos)
2237 .find(|&pos| line.as_bytes()[pos] == b' ' && !element_spans.iter().any(|(s, e)| pos > *s && pos < *e))
2238}
2239
2240fn reflow_elements(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
2242 let mut lines = Vec::new();
2243 let mut current_line = String::new();
2244 let mut current_length = 0;
2245 let mut current_line_element_spans: Vec<(usize, usize)> = Vec::new();
2247 let length_mode = options.length_mode;
2248
2249 for (idx, element) in elements.iter().enumerate() {
2250 let element_str = format!("{element}");
2253 let element_len = display_len(&element_str, length_mode);
2254
2255 let is_adjacent_to_prev = if idx > 0 {
2261 match (&elements[idx - 1], element) {
2262 (Element::Text(t), _) => !t.is_empty() && !t.ends_with(char::is_whitespace),
2263 (_, Element::Text(t)) => !t.is_empty() && !t.starts_with(char::is_whitespace),
2264 _ => true,
2265 }
2266 } else {
2267 false
2268 };
2269
2270 if let Element::Text(text) = element {
2272 let has_leading_space = text.starts_with(char::is_whitespace);
2274 let words: Vec<&str> = text.split_whitespace().collect();
2276
2277 for (i, word) in words.iter().enumerate() {
2278 let word_len = display_len(word, length_mode);
2279 let is_trailing_punct = word
2281 .chars()
2282 .all(|c| matches!(c, ',' | '.' | ':' | ';' | '!' | '?' | ')' | ']' | '}'));
2283
2284 let is_first_adjacent = i == 0 && is_adjacent_to_prev;
2287
2288 if is_first_adjacent {
2289 if current_length + word_len > options.line_length && current_length > 0 {
2291 if let Some(last_space) = rfind_safe_space(¤t_line, ¤t_line_element_spans) {
2294 let before = current_line[..last_space].trim_end().to_string();
2295 let after = current_line[last_space + 1..].to_string();
2296 lines.push(before);
2297 current_line = format!("{after}{word}");
2298 current_length = display_len(¤t_line, length_mode);
2299 current_line_element_spans.clear();
2300 } else {
2301 current_line.push_str(word);
2302 current_length += word_len;
2303 }
2304 } else {
2305 current_line.push_str(word);
2306 current_length += word_len;
2307 }
2308 } else if current_length > 0
2309 && current_length + 1 + word_len > options.line_length
2310 && !is_trailing_punct
2311 {
2312 lines.push(current_line.trim().to_string());
2314 current_line = word.to_string();
2315 current_length = word_len;
2316 current_line_element_spans.clear();
2317 } else {
2318 let add_space = current_length > 0 && if i == 0 { has_leading_space } else { !is_trailing_punct };
2328 if add_space {
2329 current_line.push(' ');
2330 current_length += 1;
2331 }
2332 current_line.push_str(word);
2333 current_length += word_len;
2334 }
2335 }
2336 } else if matches!(
2337 element,
2338 Element::Italic { .. } | Element::Bold { .. } | Element::Strikethrough { .. }
2339 ) && element_len > options.line_length
2340 {
2341 let (content, marker): (&str, &str) = match element {
2345 Element::Italic { content, underscore } => (content.as_str(), if *underscore { "_" } else { "*" }),
2346 Element::Bold { content, underscore } => (content.as_str(), if *underscore { "__" } else { "**" }),
2347 Element::Strikethrough { content, double } => (content.as_str(), if *double { "~~" } else { "~" }),
2348 _ => unreachable!(),
2349 };
2350
2351 let words: Vec<&str> = content.split_whitespace().collect();
2352 let n = words.len();
2353
2354 if n == 0 {
2355 let full = format!("{marker}{marker}");
2357 let full_len = display_len(&full, length_mode);
2358 if !is_adjacent_to_prev && current_length > 0 {
2359 current_line.push(' ');
2360 current_length += 1;
2361 }
2362 current_line.push_str(&full);
2363 current_length += full_len;
2364 } else {
2365 for (i, word) in words.iter().enumerate() {
2366 let is_first = i == 0;
2367 let is_last = i == n - 1;
2368 let word_str: String = match (is_first, is_last) {
2369 (true, true) => format!("{marker}{word}{marker}"),
2370 (true, false) => format!("{marker}{word}"),
2371 (false, true) => format!("{word}{marker}"),
2372 (false, false) => word.to_string(),
2373 };
2374 let word_len = display_len(&word_str, length_mode);
2375
2376 let needs_space = if is_first {
2377 !is_adjacent_to_prev && current_length > 0
2378 } else {
2379 current_length > 0
2380 };
2381
2382 if needs_space && current_length + 1 + word_len > options.line_length {
2383 lines.push(current_line.trim_end().to_string());
2384 current_line = word_str;
2385 current_length = word_len;
2386 current_line_element_spans.clear();
2387 } else {
2388 if needs_space {
2389 current_line.push(' ');
2390 current_length += 1;
2391 }
2392 current_line.push_str(&word_str);
2393 current_length += word_len;
2394 }
2395 }
2396 }
2397 } else {
2398 if is_adjacent_to_prev {
2402 if current_length + element_len > options.line_length {
2404 if let Some(last_space) = rfind_safe_space(¤t_line, ¤t_line_element_spans) {
2407 let before = current_line[..last_space].trim_end().to_string();
2408 let after = current_line[last_space + 1..].to_string();
2409 lines.push(before);
2410 current_line = format!("{after}{element_str}");
2411 current_length = display_len(¤t_line, length_mode);
2412 current_line_element_spans.clear();
2413 let start = after.len();
2415 current_line_element_spans.push((start, start + element_str.len()));
2416 } else {
2417 let start = current_line.len();
2419 current_line.push_str(&element_str);
2420 current_length += element_len;
2421 current_line_element_spans.push((start, current_line.len()));
2422 }
2423 } else {
2424 let start = current_line.len();
2425 current_line.push_str(&element_str);
2426 current_length += element_len;
2427 current_line_element_spans.push((start, current_line.len()));
2428 }
2429 } else if current_length > 0 && current_length + 1 + element_len > options.line_length {
2430 lines.push(current_line.trim().to_string());
2432 current_line.clone_from(&element_str);
2433 current_length = element_len;
2434 current_line_element_spans.clear();
2435 current_line_element_spans.push((0, element_str.len()));
2436 } else {
2437 let ends_with_opener =
2439 current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
2440 if current_length > 0 && !ends_with_opener {
2441 current_line.push(' ');
2442 current_length += 1;
2443 }
2444 let start = current_line.len();
2445 current_line.push_str(&element_str);
2446 current_length += element_len;
2447 current_line_element_spans.push((start, current_line.len()));
2448 }
2449 }
2450 }
2451
2452 if !current_line.is_empty() {
2454 lines.push(current_line.trim_end().to_string());
2455 }
2456
2457 lines
2458}
2459
2460pub fn reflow_markdown(content: &str, options: &ReflowOptions) -> String {
2462 let lines: Vec<&str> = content.lines().collect();
2463 let mut result = Vec::new();
2464 let mut i = 0;
2465
2466 while i < lines.len() {
2467 let line = lines[i];
2468 let trimmed = line.trim();
2469
2470 if trimmed.is_empty() {
2472 result.push(String::new());
2473 i += 1;
2474 continue;
2475 }
2476
2477 if trimmed.starts_with('#') {
2479 result.push(line.to_string());
2480 i += 1;
2481 continue;
2482 }
2483
2484 if trimmed.starts_with(":::") {
2486 result.push(line.to_string());
2487 i += 1;
2488 continue;
2489 }
2490
2491 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
2493 result.push(line.to_string());
2494 i += 1;
2495 while i < lines.len() {
2497 result.push(lines[i].to_string());
2498 if lines[i].trim().starts_with("```") || lines[i].trim().starts_with("~~~") {
2499 i += 1;
2500 break;
2501 }
2502 i += 1;
2503 }
2504 continue;
2505 }
2506
2507 if calculate_indentation_width_default(line) >= 4 {
2509 result.push(line.to_string());
2511 i += 1;
2512 while i < lines.len() {
2513 let next_line = lines[i];
2514 if calculate_indentation_width_default(next_line) >= 4 || next_line.trim().is_empty() {
2516 result.push(next_line.to_string());
2517 i += 1;
2518 } else {
2519 break;
2520 }
2521 }
2522 continue;
2523 }
2524
2525 if trimmed.starts_with('>') {
2527 let gt_pos = line.find('>').expect("'>' must exist since trimmed.starts_with('>')");
2530 let quote_prefix = line[0..=gt_pos].to_string();
2531 let quote_content = &line[quote_prefix.len()..].trim_start();
2532
2533 let reflowed = reflow_line(quote_content, options);
2534 for reflowed_line in &reflowed {
2535 result.push(format!("{quote_prefix} {reflowed_line}"));
2536 }
2537 i += 1;
2538 continue;
2539 }
2540
2541 if is_horizontal_rule(trimmed) {
2543 result.push(line.to_string());
2544 i += 1;
2545 continue;
2546 }
2547
2548 if is_unordered_list_marker(trimmed) || is_numbered_list_item(trimmed) {
2550 let indent = line.len() - line.trim_start().len();
2552 let indent_str = " ".repeat(indent);
2553
2554 let mut marker_end = indent;
2557 let mut content_start = indent;
2558
2559 if trimmed.chars().next().is_some_and(char::is_numeric) {
2560 if let Some(period_pos) = line[indent..].find('.') {
2562 marker_end = indent + period_pos + 1; content_start = marker_end;
2564 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
2568 content_start += 1;
2569 }
2570 }
2571 } else {
2572 marker_end = indent + 1; content_start = marker_end;
2575 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
2579 content_start += 1;
2580 }
2581 }
2582
2583 let min_continuation_indent = content_start;
2585
2586 let rest = &line[content_start..];
2589 if rest.starts_with("[ ] ") || rest.starts_with("[x] ") || rest.starts_with("[X] ") {
2590 marker_end = content_start + 3; content_start += 4; }
2593
2594 let marker = &line[indent..marker_end];
2595
2596 let mut list_content = vec![trim_preserving_hard_break(&line[content_start..])];
2599 i += 1;
2600
2601 while i < lines.len() {
2605 let next_line = lines[i];
2606 let next_trimmed = next_line.trim();
2607
2608 if is_block_boundary(next_trimmed) {
2610 break;
2611 }
2612
2613 let next_indent = next_line.len() - next_line.trim_start().len();
2615 if next_indent >= min_continuation_indent {
2616 let trimmed_start = next_line.trim_start();
2619 list_content.push(trim_preserving_hard_break(trimmed_start));
2620 i += 1;
2621 } else {
2622 break;
2624 }
2625 }
2626
2627 let combined_content = if options.preserve_breaks {
2630 list_content[0].clone()
2631 } else {
2632 let has_hard_breaks = list_content.iter().any(|line| has_hard_break(line));
2634 if has_hard_breaks {
2635 list_content.join("\n")
2637 } else {
2638 list_content.join(" ")
2640 }
2641 };
2642
2643 let trimmed_marker = marker;
2645 let continuation_spaces = if let Some(max_indent) = options.max_list_continuation_indent {
2646 indent + (content_start - indent).min(max_indent)
2649 } else {
2650 content_start
2651 };
2652
2653 let prefix_length = indent + trimmed_marker.len() + 1;
2655
2656 let adjusted_options = ReflowOptions {
2658 line_length: options.line_length.saturating_sub(prefix_length),
2659 ..options.clone()
2660 };
2661
2662 let reflowed = reflow_line(&combined_content, &adjusted_options);
2663 for (j, reflowed_line) in reflowed.iter().enumerate() {
2664 if j == 0 {
2665 result.push(format!("{indent_str}{trimmed_marker} {reflowed_line}"));
2666 } else {
2667 let continuation_indent = " ".repeat(continuation_spaces);
2669 result.push(format!("{continuation_indent}{reflowed_line}"));
2670 }
2671 }
2672 continue;
2673 }
2674
2675 if crate::utils::table_utils::TableUtils::is_potential_table_row(line) {
2677 result.push(line.to_string());
2678 i += 1;
2679 continue;
2680 }
2681
2682 if trimmed.starts_with('[') && line.contains("]:") {
2684 result.push(line.to_string());
2685 i += 1;
2686 continue;
2687 }
2688
2689 if is_definition_list_item(trimmed) {
2691 result.push(line.to_string());
2692 i += 1;
2693 continue;
2694 }
2695
2696 let mut is_single_line_paragraph = true;
2698 if i + 1 < lines.len() {
2699 let next_trimmed = lines[i + 1].trim();
2700 if !is_block_boundary(next_trimmed) {
2702 is_single_line_paragraph = false;
2703 }
2704 }
2705
2706 if is_single_line_paragraph && display_len(line, options.length_mode) <= options.line_length {
2708 result.push(line.to_string());
2709 i += 1;
2710 continue;
2711 }
2712
2713 let mut paragraph_parts = Vec::new();
2715 let mut current_part = vec![line];
2716 i += 1;
2717
2718 if options.preserve_breaks {
2720 let hard_break_type = if line.strip_suffix('\r').unwrap_or(line).ends_with('\\') {
2722 Some("\\")
2723 } else if line.ends_with(" ") {
2724 Some(" ")
2725 } else {
2726 None
2727 };
2728 let reflowed = reflow_line(line, options);
2729
2730 if let Some(break_marker) = hard_break_type {
2732 if !reflowed.is_empty() {
2733 let mut reflowed_with_break = reflowed;
2734 let last_idx = reflowed_with_break.len() - 1;
2735 if !has_hard_break(&reflowed_with_break[last_idx]) {
2736 reflowed_with_break[last_idx].push_str(break_marker);
2737 }
2738 result.extend(reflowed_with_break);
2739 }
2740 } else {
2741 result.extend(reflowed);
2742 }
2743 } else {
2744 while i < lines.len() {
2746 let prev_line = if !current_part.is_empty() {
2747 current_part.last().unwrap()
2748 } else {
2749 ""
2750 };
2751 let next_line = lines[i];
2752 let next_trimmed = next_line.trim();
2753
2754 if is_block_boundary(next_trimmed) {
2756 break;
2757 }
2758
2759 let prev_trimmed = prev_line.trim();
2762 let abbreviations = get_abbreviations(&options.abbreviations);
2763 let ends_with_sentence = (prev_trimmed.ends_with('.')
2764 || prev_trimmed.ends_with('!')
2765 || prev_trimmed.ends_with('?')
2766 || prev_trimmed.ends_with(".*")
2767 || prev_trimmed.ends_with("!*")
2768 || prev_trimmed.ends_with("?*")
2769 || prev_trimmed.ends_with("._")
2770 || prev_trimmed.ends_with("!_")
2771 || prev_trimmed.ends_with("?_")
2772 || prev_trimmed.ends_with(".\"")
2774 || prev_trimmed.ends_with("!\"")
2775 || prev_trimmed.ends_with("?\"")
2776 || prev_trimmed.ends_with(".'")
2777 || prev_trimmed.ends_with("!'")
2778 || prev_trimmed.ends_with("?'")
2779 || prev_trimmed.ends_with(".\u{201D}")
2780 || prev_trimmed.ends_with("!\u{201D}")
2781 || prev_trimmed.ends_with("?\u{201D}")
2782 || prev_trimmed.ends_with(".\u{2019}")
2783 || prev_trimmed.ends_with("!\u{2019}")
2784 || prev_trimmed.ends_with("?\u{2019}"))
2785 && !text_ends_with_abbreviation(
2786 prev_trimmed.trim_end_matches(['*', '_', '"', '\'', '\u{201D}', '\u{2019}']),
2787 &abbreviations,
2788 );
2789
2790 if has_hard_break(prev_line) || (options.sentence_per_line && ends_with_sentence) {
2791 paragraph_parts.push(current_part.join(" "));
2793 current_part = vec![next_line];
2794 } else {
2795 current_part.push(next_line);
2796 }
2797 i += 1;
2798 }
2799
2800 if !current_part.is_empty() {
2802 if current_part.len() == 1 {
2803 paragraph_parts.push(current_part[0].to_string());
2805 } else {
2806 paragraph_parts.push(current_part.join(" "));
2807 }
2808 }
2809
2810 for (j, part) in paragraph_parts.iter().enumerate() {
2812 let reflowed = reflow_line(part, options);
2813 result.extend(reflowed);
2814
2815 if j < paragraph_parts.len() - 1 && !result.is_empty() && !options.sentence_per_line {
2819 let last_idx = result.len() - 1;
2820 if !has_hard_break(&result[last_idx]) {
2821 result[last_idx].push_str(" ");
2822 }
2823 }
2824 }
2825 }
2826 }
2827
2828 let result_text = result.join("\n");
2830 if content.ends_with('\n') && !result_text.ends_with('\n') {
2831 format!("{result_text}\n")
2832 } else {
2833 result_text
2834 }
2835}
2836
2837#[derive(Debug, Clone)]
2839pub struct ParagraphReflow {
2840 pub start_byte: usize,
2842 pub end_byte: usize,
2844 pub reflowed_text: String,
2846}
2847
2848#[derive(Debug, Clone)]
2854pub struct BlockquoteLineData {
2855 pub(crate) content: String,
2857 pub(crate) is_explicit: bool,
2859 pub(crate) prefix: Option<String>,
2861}
2862
2863impl BlockquoteLineData {
2864 pub fn explicit(content: String, prefix: String) -> Self {
2866 Self {
2867 content,
2868 is_explicit: true,
2869 prefix: Some(prefix),
2870 }
2871 }
2872
2873 pub fn lazy(content: String) -> Self {
2875 Self {
2876 content,
2877 is_explicit: false,
2878 prefix: None,
2879 }
2880 }
2881}
2882
2883#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2885pub enum BlockquoteContinuationStyle {
2886 Explicit,
2887 Lazy,
2888}
2889
2890pub fn blockquote_continuation_style(lines: &[BlockquoteLineData]) -> BlockquoteContinuationStyle {
2898 let mut explicit_count = 0usize;
2899 let mut lazy_count = 0usize;
2900
2901 for line in lines.iter().skip(1) {
2902 if line.is_explicit {
2903 explicit_count += 1;
2904 } else {
2905 lazy_count += 1;
2906 }
2907 }
2908
2909 if explicit_count > 0 && lazy_count == 0 {
2910 BlockquoteContinuationStyle::Explicit
2911 } else if lazy_count > 0 && explicit_count == 0 {
2912 BlockquoteContinuationStyle::Lazy
2913 } else if explicit_count >= lazy_count {
2914 BlockquoteContinuationStyle::Explicit
2915 } else {
2916 BlockquoteContinuationStyle::Lazy
2917 }
2918}
2919
2920pub fn dominant_blockquote_prefix(lines: &[BlockquoteLineData], fallback: &str) -> String {
2925 let mut counts: std::collections::HashMap<String, (usize, usize)> = std::collections::HashMap::new();
2926
2927 for (idx, line) in lines.iter().enumerate() {
2928 let Some(prefix) = line.prefix.as_ref() else {
2929 continue;
2930 };
2931 counts
2932 .entry(prefix.clone())
2933 .and_modify(|entry| entry.0 += 1)
2934 .or_insert((1, idx));
2935 }
2936
2937 counts
2938 .into_iter()
2939 .max_by(|(_, (count_a, first_idx_a)), (_, (count_b, first_idx_b))| {
2940 count_a.cmp(count_b).then_with(|| first_idx_b.cmp(first_idx_a))
2941 })
2942 .map_or_else(|| fallback.to_string(), |(prefix, _)| prefix)
2943}
2944
2945pub(crate) fn should_force_explicit_blockquote_line(content_line: &str) -> bool {
2950 let trimmed = content_line.trim_start();
2951 trimmed.starts_with('>')
2952 || trimmed.starts_with('#')
2953 || trimmed.starts_with("```")
2954 || trimmed.starts_with("~~~")
2955 || is_unordered_list_marker(trimmed)
2956 || is_numbered_list_item(trimmed)
2957 || is_horizontal_rule(trimmed)
2958 || is_definition_list_item(trimmed)
2959 || (trimmed.starts_with('[') && trimmed.contains("]:"))
2960 || trimmed.starts_with(":::")
2961 || (trimmed.starts_with('<')
2962 && !trimmed.starts_with("<http")
2963 && !trimmed.starts_with("<https")
2964 && !trimmed.starts_with("<mailto:"))
2965}
2966
2967pub fn reflow_blockquote_content(
2976 lines: &[BlockquoteLineData],
2977 explicit_prefix: &str,
2978 continuation_style: BlockquoteContinuationStyle,
2979 options: &ReflowOptions,
2980) -> Vec<String> {
2981 let content_strs: Vec<&str> = lines.iter().map(|l| l.content.as_str()).collect();
2982 let segments = split_into_segments_strs(&content_strs);
2983 let mut reflowed_content_lines: Vec<String> = Vec::new();
2984
2985 for segment in segments {
2986 let hard_break_type = segment.last().and_then(|&line| {
2987 let line = line.strip_suffix('\r').unwrap_or(line);
2988 if line.ends_with('\\') {
2989 Some("\\")
2990 } else if line.ends_with(" ") {
2991 Some(" ")
2992 } else {
2993 None
2994 }
2995 });
2996
2997 let pieces: Vec<&str> = segment
2998 .iter()
2999 .map(|&line| {
3000 if let Some(l) = line.strip_suffix('\\') {
3001 l.trim_end()
3002 } else if let Some(l) = line.strip_suffix(" ") {
3003 l.trim_end()
3004 } else {
3005 line.trim_end()
3006 }
3007 })
3008 .collect();
3009
3010 let segment_text = pieces.join(" ");
3011 let segment_text = segment_text.trim();
3012 if segment_text.is_empty() {
3013 continue;
3014 }
3015
3016 let mut reflowed = reflow_line(segment_text, options);
3017 if let Some(break_marker) = hard_break_type
3018 && !reflowed.is_empty()
3019 {
3020 let last_idx = reflowed.len() - 1;
3021 if !has_hard_break(&reflowed[last_idx]) {
3022 reflowed[last_idx].push_str(break_marker);
3023 }
3024 }
3025 reflowed_content_lines.extend(reflowed);
3026 }
3027
3028 let mut styled_lines: Vec<String> = Vec::new();
3029 for (idx, line) in reflowed_content_lines.iter().enumerate() {
3030 let force_explicit = idx == 0
3031 || continuation_style == BlockquoteContinuationStyle::Explicit
3032 || should_force_explicit_blockquote_line(line);
3033 if force_explicit {
3034 styled_lines.push(format!("{explicit_prefix}{line}"));
3035 } else {
3036 styled_lines.push(line.clone());
3037 }
3038 }
3039
3040 styled_lines
3041}
3042
3043fn is_blockquote_content_boundary(content: &str) -> bool {
3044 let trimmed = content.trim();
3045 trimmed.is_empty()
3046 || is_block_boundary(trimmed)
3047 || crate::utils::table_utils::TableUtils::is_potential_table_row(content)
3048 || trimmed.starts_with(":::")
3049 || crate::utils::is_template_directive_only(content)
3050 || is_standalone_attr_list(content)
3051 || is_snippet_block_delimiter(content)
3052}
3053
3054fn split_into_segments_strs<'a>(lines: &[&'a str]) -> Vec<Vec<&'a str>> {
3055 let mut segments = Vec::new();
3056 let mut current = Vec::new();
3057
3058 for &line in lines {
3059 current.push(line);
3060 if has_hard_break(line) {
3061 segments.push(current);
3062 current = Vec::new();
3063 }
3064 }
3065
3066 if !current.is_empty() {
3067 segments.push(current);
3068 }
3069
3070 segments
3071}
3072
3073fn reflow_blockquote_paragraph_at_line(
3074 content: &str,
3075 lines: &[&str],
3076 target_idx: usize,
3077 options: &ReflowOptions,
3078) -> Option<ParagraphReflow> {
3079 let mut anchor_idx = target_idx;
3080 let mut target_level = if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[target_idx]) {
3081 parsed.nesting_level
3082 } else {
3083 let mut found = None;
3084 let mut idx = target_idx;
3085 loop {
3086 if lines[idx].trim().is_empty() {
3087 break;
3088 }
3089 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[idx]) {
3090 found = Some((idx, parsed.nesting_level));
3091 break;
3092 }
3093 if idx == 0 {
3094 break;
3095 }
3096 idx -= 1;
3097 }
3098 let (idx, level) = found?;
3099 anchor_idx = idx;
3100 level
3101 };
3102
3103 let mut para_start = anchor_idx;
3105 while para_start > 0 {
3106 let prev_idx = para_start - 1;
3107 let prev_line = lines[prev_idx];
3108
3109 if prev_line.trim().is_empty() {
3110 break;
3111 }
3112
3113 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(prev_line) {
3114 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
3115 break;
3116 }
3117 para_start = prev_idx;
3118 continue;
3119 }
3120
3121 let prev_lazy = prev_line.trim_start();
3122 if is_blockquote_content_boundary(prev_lazy) {
3123 break;
3124 }
3125 para_start = prev_idx;
3126 }
3127
3128 while para_start < lines.len() {
3130 let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[para_start]) else {
3131 para_start += 1;
3132 continue;
3133 };
3134 target_level = parsed.nesting_level;
3135 break;
3136 }
3137
3138 if para_start >= lines.len() || para_start > target_idx {
3139 return None;
3140 }
3141
3142 let mut collected: Vec<(usize, BlockquoteLineData)> = Vec::new();
3145 let mut idx = para_start;
3146 while idx < lines.len() {
3147 if !collected.is_empty() && has_hard_break(&collected[collected.len() - 1].1.content) {
3148 break;
3149 }
3150
3151 let line = lines[idx];
3152 if line.trim().is_empty() {
3153 break;
3154 }
3155
3156 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(line) {
3157 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
3158 break;
3159 }
3160 collected.push((
3161 idx,
3162 BlockquoteLineData::explicit(trim_preserving_hard_break(parsed.content), parsed.prefix.to_string()),
3163 ));
3164 idx += 1;
3165 continue;
3166 }
3167
3168 let lazy_content = line.trim_start();
3169 if is_blockquote_content_boundary(lazy_content) {
3170 break;
3171 }
3172
3173 collected.push((idx, BlockquoteLineData::lazy(trim_preserving_hard_break(lazy_content))));
3174 idx += 1;
3175 }
3176
3177 if collected.is_empty() {
3178 return None;
3179 }
3180
3181 let para_end = collected[collected.len() - 1].0;
3182 if target_idx < para_start || target_idx > para_end {
3183 return None;
3184 }
3185
3186 let line_data: Vec<BlockquoteLineData> = collected.iter().map(|(_, d)| d.clone()).collect();
3187
3188 let fallback_prefix = line_data
3189 .iter()
3190 .find_map(|d| d.prefix.clone())
3191 .unwrap_or_else(|| "> ".to_string());
3192 let explicit_prefix = dominant_blockquote_prefix(&line_data, &fallback_prefix);
3193 let continuation_style = blockquote_continuation_style(&line_data);
3194
3195 let adjusted_line_length = options
3196 .line_length
3197 .saturating_sub(display_len(&explicit_prefix, options.length_mode))
3198 .max(1);
3199
3200 let adjusted_options = ReflowOptions {
3201 line_length: adjusted_line_length,
3202 ..options.clone()
3203 };
3204
3205 let styled_lines = reflow_blockquote_content(&line_data, &explicit_prefix, continuation_style, &adjusted_options);
3206
3207 if styled_lines.is_empty() {
3208 return None;
3209 }
3210
3211 let mut start_byte = 0;
3213 for line in lines.iter().take(para_start) {
3214 start_byte += line.len() + 1;
3215 }
3216
3217 let mut end_byte = start_byte;
3218 for line in lines.iter().take(para_end + 1).skip(para_start) {
3219 end_byte += line.len() + 1;
3220 }
3221
3222 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
3223 if !includes_trailing_newline {
3224 end_byte -= 1;
3225 }
3226
3227 let reflowed_joined = styled_lines.join("\n");
3228 let reflowed_text = if includes_trailing_newline {
3229 if reflowed_joined.ends_with('\n') {
3230 reflowed_joined
3231 } else {
3232 format!("{reflowed_joined}\n")
3233 }
3234 } else if reflowed_joined.ends_with('\n') {
3235 reflowed_joined.trim_end_matches('\n').to_string()
3236 } else {
3237 reflowed_joined
3238 };
3239
3240 Some(ParagraphReflow {
3241 start_byte,
3242 end_byte,
3243 reflowed_text,
3244 })
3245}
3246
3247pub fn reflow_paragraph_at_line(content: &str, line_number: usize, line_length: usize) -> Option<ParagraphReflow> {
3265 reflow_paragraph_at_line_with_mode(content, line_number, line_length, ReflowLengthMode::default())
3266}
3267
3268pub fn reflow_paragraph_at_line_with_mode(
3270 content: &str,
3271 line_number: usize,
3272 line_length: usize,
3273 length_mode: ReflowLengthMode,
3274) -> Option<ParagraphReflow> {
3275 let options = ReflowOptions {
3276 line_length,
3277 length_mode,
3278 ..Default::default()
3279 };
3280 reflow_paragraph_at_line_with_options(content, line_number, &options)
3281}
3282
3283pub fn reflow_paragraph_at_line_with_options(
3294 content: &str,
3295 line_number: usize,
3296 options: &ReflowOptions,
3297) -> Option<ParagraphReflow> {
3298 if line_number == 0 {
3299 return None;
3300 }
3301
3302 let lines: Vec<&str> = content.lines().collect();
3303
3304 if line_number > lines.len() {
3306 return None;
3307 }
3308
3309 let target_idx = line_number - 1; let target_line = lines[target_idx];
3311 let trimmed = target_line.trim();
3312
3313 if let Some(blockquote_reflow) = reflow_blockquote_paragraph_at_line(content, &lines, target_idx, options) {
3316 return Some(blockquote_reflow);
3317 }
3318
3319 if is_paragraph_boundary(trimmed, target_line) {
3321 return None;
3322 }
3323
3324 let mut para_start = target_idx;
3326 while para_start > 0 {
3327 let prev_idx = para_start - 1;
3328 let prev_line = lines[prev_idx];
3329 let prev_trimmed = prev_line.trim();
3330
3331 if is_paragraph_boundary(prev_trimmed, prev_line) {
3333 break;
3334 }
3335
3336 para_start = prev_idx;
3337 }
3338
3339 let mut para_end = target_idx;
3341 while para_end + 1 < lines.len() {
3342 let next_idx = para_end + 1;
3343 let next_line = lines[next_idx];
3344 let next_trimmed = next_line.trim();
3345
3346 if is_paragraph_boundary(next_trimmed, next_line) {
3348 break;
3349 }
3350
3351 para_end = next_idx;
3352 }
3353
3354 let paragraph_lines = &lines[para_start..=para_end];
3356
3357 let mut start_byte = 0;
3359 for line in lines.iter().take(para_start) {
3360 start_byte += line.len() + 1; }
3362
3363 let mut end_byte = start_byte;
3364 for line in paragraph_lines {
3365 end_byte += line.len() + 1; }
3367
3368 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
3371
3372 if !includes_trailing_newline {
3374 end_byte -= 1;
3375 }
3376
3377 let paragraph_text = paragraph_lines.join("\n");
3379
3380 let reflowed = reflow_markdown(¶graph_text, options);
3382
3383 let reflowed_text = if includes_trailing_newline {
3387 if reflowed.ends_with('\n') {
3389 reflowed
3390 } else {
3391 format!("{reflowed}\n")
3392 }
3393 } else {
3394 if reflowed.ends_with('\n') {
3396 reflowed.trim_end_matches('\n').to_string()
3397 } else {
3398 reflowed
3399 }
3400 };
3401
3402 Some(ParagraphReflow {
3403 start_byte,
3404 end_byte,
3405 reflowed_text,
3406 })
3407}
3408
3409#[cfg(test)]
3410mod tests {
3411 use super::*;
3412
3413 #[test]
3414 fn cascade_split_line_handles_a_very_long_line_without_overflowing() {
3415 let words: Vec<String> = (0..4000).map(|i| format!("word{i}")).collect();
3421 let line = words.join(" ");
3422
3423 let out = cascade_split_line(&line, 80, &None, ReflowLengthMode::Chars, false, false, None);
3424
3425 assert!(out.len() > 1, "a very long line should split into many lines");
3426 for segment in &out {
3427 assert!(
3428 display_len(segment, ReflowLengthMode::Chars) <= 80 || !segment.contains(' '),
3429 "each wrapped line should fit the width (or be a single unbreakable token)"
3430 );
3431 }
3432 let rejoined = out.join(" ");
3434 let original_words: Vec<&str> = line.split(' ').collect();
3435 let result_words: Vec<&str> = rejoined.split_whitespace().collect();
3436 assert_eq!(original_words, result_words, "reflow must preserve all words in order");
3437 }
3438
3439 #[test]
3444 fn test_helper_function_text_ends_with_abbreviation() {
3445 let abbreviations = get_abbreviations(&None);
3447
3448 assert!(text_ends_with_abbreviation("Dr.", &abbreviations));
3450 assert!(text_ends_with_abbreviation("word Dr.", &abbreviations));
3451 assert!(text_ends_with_abbreviation("e.g.", &abbreviations));
3452 assert!(text_ends_with_abbreviation("i.e.", &abbreviations));
3453 assert!(text_ends_with_abbreviation("Mr.", &abbreviations));
3454 assert!(text_ends_with_abbreviation("Mrs.", &abbreviations));
3455 assert!(text_ends_with_abbreviation("Ms.", &abbreviations));
3456 assert!(text_ends_with_abbreviation("Prof.", &abbreviations));
3457
3458 assert!(!text_ends_with_abbreviation("etc.", &abbreviations));
3460 assert!(!text_ends_with_abbreviation("paradigms.", &abbreviations));
3461 assert!(!text_ends_with_abbreviation("programs.", &abbreviations));
3462 assert!(!text_ends_with_abbreviation("items.", &abbreviations));
3463 assert!(!text_ends_with_abbreviation("systems.", &abbreviations));
3464 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)); }
3470
3471 #[test]
3472 fn test_footnote_after_period_splits_sentence() {
3473 let text = "First sentence.[^1] Second sentence.";
3477 let sentences = split_into_sentences(text);
3478 assert_eq!(
3479 sentences,
3480 vec!["First sentence.[^1]".to_string(), "Second sentence.".to_string()],
3481 "footnote glued to the period should keep the boundary and stay attached to the first sentence"
3482 );
3483 }
3484
3485 #[test]
3486 fn test_multiple_consecutive_footnotes_after_period_splits_sentence() {
3487 let text = "Notes here.[^1][^2] Second sentence.";
3489 let sentences = split_into_sentences(text);
3490 assert_eq!(
3491 sentences,
3492 vec!["Notes here.[^1][^2]".to_string(), "Second sentence.".to_string()]
3493 );
3494 }
3495
3496 #[test]
3497 fn test_footnote_before_period_still_splits_sentence() {
3498 let text = "Annotation here[^1]. Second sentence.";
3502 let sentences = split_into_sentences(text);
3503 assert_eq!(
3504 sentences,
3505 vec!["Annotation here[^1].".to_string(), "Second sentence.".to_string()]
3506 );
3507 }
3508
3509 #[test]
3510 fn test_mid_sentence_footnote_does_not_split() {
3511 let text = "The system word[^1] more words. Next sentence.";
3514 let sentences = split_into_sentences(text);
3515 assert_eq!(
3516 sentences,
3517 vec![
3518 "The system word[^1] more words.".to_string(),
3519 "Next sentence.".to_string()
3520 ]
3521 );
3522 }
3523
3524 #[test]
3525 fn test_bare_numeric_bracket_after_period_does_not_split() {
3526 let text = "Citation here.[1] Second sentence.";
3529 let sentences = split_into_sentences(text);
3530 assert_eq!(
3531 sentences,
3532 vec![text.to_string()],
3533 "a bare numeric bracket must not be treated as a sentence boundary"
3534 );
3535 }
3536
3537 #[test]
3538 fn test_footnote_glued_to_following_word_does_not_split() {
3539 let text = "First sentence.[^1]Continued glued text.";
3542 let sentences = split_into_sentences(text);
3543 assert_eq!(sentences, vec![text.to_string()]);
3544 }
3545
3546 #[test]
3547 fn test_footnote_at_end_of_text_is_preserved() {
3548 let text = "Sentence.[^1]";
3551 let sentences = split_into_sentences(text);
3552 assert_eq!(sentences, vec![text.to_string()]);
3553 }
3554
3555 #[test]
3556 fn test_abbreviation_before_footnote_does_not_split() {
3557 let text = "See the notes, e.g.[^1] this one.";
3560 let sentences = split_into_sentences(text);
3561 assert_eq!(
3562 sentences,
3563 vec![text.to_string()],
3564 "e.g. is an abbreviation, not a sentence boundary"
3565 );
3566 }
3567
3568 #[test]
3569 fn test_is_unordered_list_marker() {
3570 assert!(is_unordered_list_marker("- item"));
3572 assert!(is_unordered_list_marker("* item"));
3573 assert!(is_unordered_list_marker("+ item"));
3574 assert!(is_unordered_list_marker("-")); assert!(is_unordered_list_marker("*"));
3576 assert!(is_unordered_list_marker("+"));
3577
3578 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")); }
3589
3590 #[test]
3591 fn test_is_block_boundary() {
3592 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"));
3614 assert!(!is_block_boundary("*emphasis*")); assert!(!is_block_boundary("[link](url)")); assert!(!is_block_boundary("some words here"));
3617 }
3618
3619 #[test]
3620 fn test_definition_list_boundary_in_single_line_paragraph() {
3621 let options = ReflowOptions {
3624 line_length: 80,
3625 ..Default::default()
3626 };
3627 let input = "Term\n: Definition of the term";
3628 let result = reflow_markdown(input, &options);
3629 assert!(
3631 result.contains(": Definition"),
3632 "Definition list item should not be merged into previous line. Got: {result:?}"
3633 );
3634 let lines: Vec<&str> = result.lines().collect();
3635 assert_eq!(lines.len(), 2, "Should remain two separate lines. Got: {lines:?}");
3636 assert_eq!(lines[0], "Term");
3637 assert_eq!(lines[1], ": Definition of the term");
3638 }
3639
3640 #[test]
3641 fn test_is_paragraph_boundary() {
3642 assert!(is_paragraph_boundary("# Heading", "# Heading"));
3644 assert!(is_paragraph_boundary("- item", "- item"));
3645 assert!(is_paragraph_boundary(":::", ":::"));
3646 assert!(is_paragraph_boundary(": definition", ": definition"));
3647
3648 assert!(is_paragraph_boundary("code", " code"));
3650 assert!(is_paragraph_boundary("code", "\tcode"));
3651
3652 assert!(is_paragraph_boundary("| a | b |", "| a | b |"));
3654 assert!(is_paragraph_boundary("a | b", "a | b")); assert!(!is_paragraph_boundary("regular text", "regular text"));
3658 assert!(!is_paragraph_boundary("text", " text")); }
3660
3661 #[test]
3662 fn test_div_marker_boundary_in_reflow_paragraph_at_line() {
3663 let content = "Some paragraph text here.\n\n::: {.callout-note}\nThis is a callout.\n:::\n";
3666 let result = reflow_paragraph_at_line(content, 3, 80);
3668 assert!(result.is_none(), "Div marker line should not be reflowed");
3669 }
3670}