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 is_sentence_boundary(
158 text: &str,
159 chars: &[char],
160 pos: usize,
161 abbreviations: &HashSet<String>,
162 require_sentence_capital: bool,
163) -> bool {
164 if pos + 1 >= chars.len() {
165 return false;
166 }
167
168 let c = chars[pos];
169 let next_char = chars[pos + 1];
170
171 if is_cjk_sentence_ending(c) {
174 let mut after_punct_pos = pos + 1;
176 while after_punct_pos < chars.len()
177 && (chars[after_punct_pos] == '*' || chars[after_punct_pos] == '_' || chars[after_punct_pos] == '~')
178 {
179 after_punct_pos += 1;
180 }
181
182 while after_punct_pos < chars.len() && chars[after_punct_pos].is_whitespace() {
184 after_punct_pos += 1;
185 }
186
187 if after_punct_pos >= chars.len() {
189 return false;
190 }
191
192 while after_punct_pos < chars.len()
194 && (chars[after_punct_pos] == '*' || chars[after_punct_pos] == '_' || chars[after_punct_pos] == '~')
195 {
196 after_punct_pos += 1;
197 }
198
199 if after_punct_pos >= chars.len() {
200 return false;
201 }
202
203 return true;
206 }
207
208 if c != '.' && c != '!' && c != '?' {
210 return false;
211 }
212
213 let (_space_pos, after_space_pos) = if next_char == ' ' {
215 (pos + 1, pos + 2)
217 } else if is_closing_quote(next_char) && pos + 2 < chars.len() {
218 if chars[pos + 2] == ' ' {
220 (pos + 2, pos + 3)
222 } else if (chars[pos + 2] == '*' || chars[pos + 2] == '_') && pos + 3 < chars.len() && chars[pos + 3] == ' ' {
223 (pos + 3, pos + 4)
225 } else if (chars[pos + 2] == '*' || chars[pos + 2] == '_')
226 && pos + 4 < chars.len()
227 && chars[pos + 3] == chars[pos + 2]
228 && chars[pos + 4] == ' '
229 {
230 (pos + 4, pos + 5)
232 } else {
233 return false;
234 }
235 } else if (next_char == '*' || next_char == '_') && pos + 2 < chars.len() && chars[pos + 2] == ' ' {
236 (pos + 2, pos + 3)
238 } else if (next_char == '*' || next_char == '_')
239 && pos + 3 < chars.len()
240 && chars[pos + 2] == next_char
241 && chars[pos + 3] == ' '
242 {
243 (pos + 3, pos + 4)
245 } else if next_char == '~' && pos + 3 < chars.len() && chars[pos + 2] == '~' && chars[pos + 3] == ' ' {
246 (pos + 3, pos + 4)
248 } else {
249 return false;
250 };
251
252 let mut next_char_pos = after_space_pos;
254 while next_char_pos < chars.len() && chars[next_char_pos].is_whitespace() {
255 next_char_pos += 1;
256 }
257
258 if next_char_pos >= chars.len() {
260 return false;
261 }
262
263 let mut first_letter_pos = next_char_pos;
265 while first_letter_pos < chars.len()
266 && (chars[first_letter_pos] == '*'
267 || chars[first_letter_pos] == '_'
268 || chars[first_letter_pos] == '~'
269 || is_opening_quote(chars[first_letter_pos]))
270 {
271 first_letter_pos += 1;
272 }
273
274 if first_letter_pos >= chars.len() {
276 return false;
277 }
278
279 let first_char = chars[first_letter_pos];
280
281 if c == '!' || c == '?' {
283 return true;
284 }
285
286 if pos > 0 {
290 let byte_offset: usize = chars[..=pos].iter().map(|ch| ch.len_utf8()).sum();
292 if text_ends_with_abbreviation(&text[..byte_offset], abbreviations) {
293 return false;
294 }
295
296 if chars[pos - 1].is_numeric() && first_char.is_ascii_digit() {
298 return false;
299 }
300
301 if chars[pos - 1].is_ascii_uppercase() && (pos == 1 || (pos >= 2 && chars[pos - 2].is_whitespace())) {
305 return false;
306 }
307 }
308
309 if require_sentence_capital && !first_char.is_uppercase() && !is_cjk_char(first_char) {
312 return false;
313 }
314
315 true
316}
317
318pub fn split_into_sentences(text: &str) -> Vec<String> {
320 split_into_sentences_custom(text, &None)
321}
322
323pub fn split_into_sentences_custom(text: &str, custom_abbreviations: &Option<Vec<String>>) -> Vec<String> {
325 let abbreviations = get_abbreviations(custom_abbreviations);
326 split_into_sentences_with_set(text, &abbreviations, true)
327}
328
329fn split_into_sentences_with_set(
332 text: &str,
333 abbreviations: &HashSet<String>,
334 require_sentence_capital: bool,
335) -> Vec<String> {
336 let in_code = compute_inline_code_mask(text);
338 let char_vec: Vec<char> = text.chars().collect();
341
342 let mut sentences = Vec::new();
343 let mut current_sentence = String::new();
344 let mut chars = text.chars().peekable();
345 let mut pos = 0;
346
347 while let Some(c) = chars.next() {
348 current_sentence.push(c);
349
350 if !in_code[pos] && is_sentence_boundary(text, &char_vec, pos, abbreviations, require_sentence_capital) {
351 while let Some(&next) = chars.peek() {
353 if next == '*' || next == '_' || next == '~' || is_closing_quote(next) {
354 current_sentence.push(chars.next().unwrap());
355 pos += 1;
356 } else {
357 break;
358 }
359 }
360
361 if chars.peek() == Some(&' ') {
363 chars.next();
364 pos += 1;
365 }
366
367 sentences.push(current_sentence.trim().to_string());
368 current_sentence.clear();
369 }
370
371 pos += 1;
372 }
373
374 if !current_sentence.trim().is_empty() {
376 sentences.push(current_sentence.trim().to_string());
377 }
378 sentences
379}
380
381fn is_horizontal_rule(line: &str) -> bool {
383 if line.len() < 3 {
384 return false;
385 }
386
387 let mut chars = line.chars();
390 let Some(first_char) = chars.next() else {
391 return false;
392 };
393 if first_char != '-' && first_char != '_' && first_char != '*' {
394 return false;
395 }
396
397 let mut non_space_count = 1usize; for c in chars {
399 if c == ' ' {
400 continue;
401 }
402 if c != first_char {
403 return false;
404 }
405 non_space_count += 1;
406 }
407 non_space_count >= 3
408}
409
410fn is_numbered_list_item(line: &str) -> bool {
412 let mut chars = line.chars();
413
414 if !chars.next().is_some_and(char::is_numeric) {
416 return false;
417 }
418
419 while let Some(c) = chars.next() {
421 if c == '.' {
422 return chars.next() == Some(' ');
425 }
426 if !c.is_numeric() {
427 return false;
428 }
429 }
430
431 false
432}
433
434fn is_unordered_list_marker(s: &str) -> bool {
436 matches!(s.as_bytes().first(), Some(b'-' | b'*' | b'+'))
437 && !is_horizontal_rule(s)
438 && (s.len() == 1 || s.as_bytes().get(1) == Some(&b' '))
439}
440
441fn is_block_boundary_core(trimmed: &str) -> bool {
444 trimmed.is_empty()
445 || trimmed.starts_with('#')
446 || trimmed.starts_with("```")
447 || trimmed.starts_with("~~~")
448 || trimmed.starts_with('>')
449 || (trimmed.starts_with('[') && trimmed.contains("]:"))
450 || is_horizontal_rule(trimmed)
451 || is_unordered_list_marker(trimmed)
452 || is_numbered_list_item(trimmed)
453 || is_definition_list_item(trimmed)
454 || trimmed.starts_with(":::")
455}
456
457fn is_block_boundary(trimmed: &str) -> bool {
460 is_block_boundary_core(trimmed) || trimmed.starts_with('|')
461}
462
463fn is_paragraph_boundary(trimmed: &str, line: &str) -> bool {
467 is_block_boundary_core(trimmed)
468 || calculate_indentation_width_default(line) >= 4
469 || crate::utils::table_utils::TableUtils::is_potential_table_row(line)
470}
471
472fn has_hard_break(line: &str) -> bool {
478 let line = line.strip_suffix('\r').unwrap_or(line);
479 line.ends_with(" ") || line.ends_with('\\')
480}
481
482fn ends_with_sentence_punct(text: &str) -> bool {
484 text.ends_with('.') || text.ends_with('!') || text.ends_with('?')
485}
486
487fn trim_preserving_hard_break(s: &str) -> String {
493 let s = s.strip_suffix('\r').unwrap_or(s);
495
496 if s.ends_with('\\') {
498 return s.to_string();
500 }
501
502 if s.ends_with(" ") {
504 let content_end = s.trim_end().len();
506 if content_end == 0 {
507 return String::new();
509 }
510 format!("{} ", &s[..content_end])
512 } else {
513 s.trim_end().to_string()
515 }
516}
517
518fn parse_elements(text: &str, options: &ReflowOptions) -> Vec<Element> {
520 parse_markdown_elements_inner(
521 text,
522 options.attr_lists,
523 options.myst_roles,
524 options.defined_references.as_ref(),
525 )
526}
527
528pub fn reflow_line(line: &str, options: &ReflowOptions) -> Vec<String> {
529 if options.sentence_per_line {
531 let elements = parse_elements(line, options);
532 return reflow_elements_sentence_per_line(&elements, &options.abbreviations, options.require_sentence_capital);
533 }
534
535 if options.semantic_line_breaks {
537 let elements = parse_elements(line, options);
538 return reflow_elements_semantic(&elements, options);
539 }
540
541 if options.line_length == 0 || display_len(line, options.length_mode) <= options.line_length {
544 return vec![line.to_string()];
545 }
546
547 let elements = parse_elements(line, options);
549
550 reflow_elements(&elements, options)
552}
553
554#[derive(Debug, Clone)]
556enum Element {
557 Text(String),
559 Link(String),
561 ReferenceLink(String),
563 EmptyReferenceLink(String),
565 ShortcutReference(String),
567 InlineImage(String),
569 ReferenceImage(String),
571 EmptyReferenceImage(String),
573 LinkedImage(String),
575 FootnoteReference(String),
577 Strikethrough {
579 content: String,
580 double: bool,
582 },
583 WikiLink(String),
585 InlineMath(String),
587 DisplayMath(String),
589 EmojiShortcode(String),
591 Autolink(String),
593 HtmlTag(String),
595 HtmlEntity(String),
597 HugoShortcode(String),
599 AttrList(String),
601 MystRole(String),
605 Code(String),
607 Bold {
609 content: String,
610 underscore: bool,
612 },
613 Italic {
615 content: String,
616 underscore: bool,
618 },
619}
620
621impl std::fmt::Display for Element {
622 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
623 match self {
624 Element::Text(s) => write!(f, "{s}"),
625 Element::Link(s) => write!(f, "{s}"),
626 Element::ReferenceLink(s) => write!(f, "{s}"),
627 Element::EmptyReferenceLink(s) => write!(f, "{s}"),
628 Element::ShortcutReference(s) => write!(f, "{s}"),
629 Element::InlineImage(s) => write!(f, "{s}"),
630 Element::ReferenceImage(s) => write!(f, "{s}"),
631 Element::EmptyReferenceImage(s) => write!(f, "{s}"),
632 Element::LinkedImage(s) => write!(f, "{s}"),
633 Element::FootnoteReference(s) => write!(f, "{s}"),
634 Element::Strikethrough { content, double } => {
635 let marker = if *double { "~~" } else { "~" };
636 write!(f, "{marker}{content}{marker}")
637 }
638 Element::WikiLink(s) => write!(f, "[[{s}]]"),
639 Element::InlineMath(s) => write!(f, "${s}$"),
640 Element::DisplayMath(s) => write!(f, "$${s}$$"),
641 Element::EmojiShortcode(s) => write!(f, ":{s}:"),
642 Element::Autolink(s) => write!(f, "{s}"),
643 Element::HtmlTag(s) => write!(f, "{s}"),
644 Element::HtmlEntity(s) => write!(f, "{s}"),
645 Element::HugoShortcode(s) => write!(f, "{s}"),
646 Element::AttrList(s) => write!(f, "{s}"),
647 Element::MystRole(s) => write!(f, "{s}"),
648 Element::Code(s) => write!(f, "`{s}`"),
649 Element::Bold { content, underscore } => {
650 if *underscore {
651 write!(f, "__{content}__")
652 } else {
653 write!(f, "**{content}**")
654 }
655 }
656 Element::Italic { content, underscore } => {
657 if *underscore {
658 write!(f, "_{content}_")
659 } else {
660 write!(f, "*{content}*")
661 }
662 }
663 }
664 }
665}
666
667#[derive(Debug, Clone)]
669struct EmphasisSpan {
670 start: usize,
672 end: usize,
674 content: String,
676 is_strong: bool,
678 is_strikethrough: bool,
680 uses_underscore: bool,
682 strikethrough_double: bool,
685}
686
687fn extract_emphasis_spans(text: &str) -> Vec<EmphasisSpan> {
697 let mut spans = Vec::new();
698 let mut options = Options::empty();
699 options.insert(Options::ENABLE_STRIKETHROUGH);
700
701 let mut emphasis_stack: Vec<(usize, bool)> = Vec::new(); let mut strong_stack: Vec<(usize, bool)> = Vec::new();
704 let mut strikethrough_stack: Vec<usize> = Vec::new();
705
706 let parser = Parser::new_ext(text, options).into_offset_iter();
707
708 for (event, range) in parser {
709 match event {
710 Event::Start(Tag::Emphasis) => {
711 let uses_underscore = text.get(range.start..range.start + 1) == Some("_");
713 emphasis_stack.push((range.start, uses_underscore));
714 }
715 Event::End(TagEnd::Emphasis) => {
716 if let Some((start_byte, uses_underscore)) = emphasis_stack.pop() {
717 let content_start = start_byte + 1;
719 let content_end = range.end - 1;
720 if content_end > content_start
721 && let Some(content) = text.get(content_start..content_end)
722 {
723 spans.push(EmphasisSpan {
724 start: start_byte,
725 end: range.end,
726 content: content.to_string(),
727 is_strong: false,
728 is_strikethrough: false,
729 uses_underscore,
730 strikethrough_double: false,
731 });
732 }
733 }
734 }
735 Event::Start(Tag::Strong) => {
736 let uses_underscore = text.get(range.start..range.start + 2) == Some("__");
738 strong_stack.push((range.start, uses_underscore));
739 }
740 Event::End(TagEnd::Strong) => {
741 if let Some((start_byte, uses_underscore)) = strong_stack.pop() {
742 let content_start = start_byte + 2;
744 let content_end = range.end - 2;
745 if content_end > content_start
746 && let Some(content) = text.get(content_start..content_end)
747 {
748 spans.push(EmphasisSpan {
749 start: start_byte,
750 end: range.end,
751 content: content.to_string(),
752 is_strong: true,
753 is_strikethrough: false,
754 uses_underscore,
755 strikethrough_double: false,
756 });
757 }
758 }
759 }
760 Event::Start(Tag::Strikethrough) => {
761 strikethrough_stack.push(range.start);
762 }
763 Event::End(TagEnd::Strikethrough) => {
764 if let Some(start_byte) = strikethrough_stack.pop() {
765 let double = text.get(start_byte..start_byte + 2) == Some("~~");
769 let marker_len = if double { 2 } else { 1 };
770 let content_start = start_byte + marker_len;
771 let content_end = range.end - marker_len;
772 if content_end > content_start
773 && let Some(content) = text.get(content_start..content_end)
774 {
775 spans.push(EmphasisSpan {
776 start: start_byte,
777 end: range.end,
778 content: content.to_string(),
779 is_strong: false,
780 is_strikethrough: true,
781 uses_underscore: false,
782 strikethrough_double: double,
783 });
784 }
785 }
786 }
787 _ => {}
788 }
789 }
790
791 spans.sort_by_key(|s| s.start);
793 spans
794}
795
796#[derive(Debug, Clone)]
797struct CodeSpan {
798 start: usize,
799 end: usize,
800}
801
802fn extract_code_spans(text: &str) -> Vec<CodeSpan> {
803 let mut spans = Vec::new();
804 let parser = Parser::new(text).into_offset_iter();
805 for (event, range) in parser {
806 if let Event::Code(_) = event {
807 spans.push(CodeSpan {
808 start: range.start,
809 end: range.end,
810 });
811 }
812 }
813 spans
814}
815
816#[derive(Debug, Clone)]
817struct LinkSpan {
818 start: usize,
819 end: usize,
820 link_type: Option<LinkType>,
821 is_image: bool,
822 is_footnote: bool,
823}
824
825fn extract_link_spans(text: &str, defined_references: Option<&HashSet<String>>) -> Vec<LinkSpan> {
826 let mut spans = Vec::new();
827 let mut options = Options::empty();
828 options.insert(Options::ENABLE_FOOTNOTES);
829
830 let resolve = move |link: BrokenLink<'_>| -> Option<(CowStr<'_>, CowStr<'_>)> {
847 let atomic = match link.link_type {
852 LinkType::Shortcut | LinkType::ShortcutUnknown => match defined_references {
853 Some(defs) => defs.contains(&normalize_reference_label(link.reference.as_ref())),
854 None => true,
855 },
856 _ => true,
857 };
858 atomic.then_some((CowStr::Borrowed(""), CowStr::Borrowed("")))
859 };
860 let parser = Parser::new_with_broken_link_callback(text, options, Some(resolve)).into_offset_iter();
861 let mut stack = Vec::new();
862
863 for (event, range) in parser {
864 match event {
865 Event::Start(Tag::Link { link_type, .. }) => {
866 stack.push((range.start, Some(link_type), false));
867 }
868 Event::Start(Tag::Image { link_type, .. }) => {
869 stack.push((range.start, Some(link_type), true));
870 }
871 Event::End(TagEnd::Link) => {
872 if let Some((start_byte, link_type, is_image)) = stack.pop()
873 && stack.is_empty()
874 {
875 spans.push(LinkSpan {
876 start: start_byte,
877 end: range.end,
878 link_type,
879 is_image,
880 is_footnote: false,
881 });
882 }
883 }
884 Event::End(TagEnd::Image) => {
885 if let Some((start_byte, link_type, is_image)) = stack.pop()
886 && stack.is_empty()
887 {
888 spans.push(LinkSpan {
889 start: start_byte,
890 end: range.end,
891 link_type,
892 is_image,
893 is_footnote: false,
894 });
895 }
896 }
897 Event::FootnoteReference(_) if stack.is_empty() => {
898 spans.push(LinkSpan {
899 start: range.start,
900 end: range.end,
901 link_type: None,
902 is_image: false,
903 is_footnote: true,
904 });
905 }
906 _ => {}
907 }
908 }
909
910 spans.sort_by_key(|s| s.start);
911 spans
912}
913
914fn myst_role_len_at(text: &str) -> Option<usize> {
922 let bytes = text.as_bytes();
923 if bytes.first() != Some(&b'{') {
924 return None;
925 }
926
927 let mut j = 1;
929 match bytes.get(j) {
930 Some(&b) if b.is_ascii_alphabetic() || b == b'_' => {}
931 _ => return None,
932 }
933 while let Some(&b) = bytes.get(j) {
934 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b':' | b'.') {
935 j += 1;
936 } else {
937 break;
938 }
939 }
940 if bytes.get(j) != Some(&b'}') {
941 return None;
942 }
943 j += 1; if bytes.get(j) != Some(&b'`') {
947 return None;
948 }
949 let backtick_start = j;
950 while bytes.get(j) == Some(&b'`') {
951 j += 1;
952 }
953 let backtick_count = j - backtick_start;
954
955 while j + backtick_count <= bytes.len() {
957 if bytes[j] == b'`' {
958 let close_count = bytes[j..].iter().take_while(|&&b| b == b'`').count();
959 if close_count == backtick_count {
960 return Some(j + close_count);
961 }
962 j += close_count;
963 } else {
964 j += 1;
965 }
966 }
967
968 None
969}
970
971fn parse_markdown_elements_inner(
982 text: &str,
983 attr_lists: bool,
984 myst_roles: bool,
985 defined_references: Option<&HashSet<String>>,
986) -> Vec<Element> {
987 let mut elements = Vec::new();
988 let mut remaining = text;
989
990 let emphasis_spans = extract_emphasis_spans(text);
992 let link_spans = extract_link_spans(text, defined_references);
993
994 while !remaining.is_empty() {
995 let current_offset = text.len() - remaining.len();
997 let mut earliest_match: Option<(usize, usize, &str)> = None;
1000
1001 let mut next_link: Option<&LinkSpan> = None;
1003 for span in &link_spans {
1004 if span.start >= current_offset {
1005 next_link = Some(span);
1006 break;
1007 }
1008 }
1009
1010 if let Some(span) = next_link {
1011 let pos_in_remaining = span.start - current_offset;
1012 if earliest_match
1013 .as_ref()
1014 .is_none_or(|(start, _, _)| pos_in_remaining < *start)
1015 {
1016 let match_end = span.end - current_offset;
1017 earliest_match = Some((pos_in_remaining, match_end, "link_span"));
1018 }
1019 }
1020
1021 if let Some(m) = WIKI_LINK_REGEX.find(remaining)
1023 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
1024 {
1025 earliest_match = Some((m.start(), m.end(), "wiki_link"));
1026 }
1027
1028 if let Some(m) = DISPLAY_MATH_REGEX.find(remaining)
1030 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
1031 {
1032 earliest_match = Some((m.start(), m.end(), "display_math"));
1033 }
1034
1035 if let Ok(Some(m)) = INLINE_MATH_REGEX.find(remaining)
1037 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
1038 {
1039 earliest_match = Some((m.start(), m.end(), "inline_math"));
1040 }
1041
1042 if let Some(m) = EMOJI_SHORTCODE_REGEX.find(remaining)
1044 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
1045 {
1046 earliest_match = Some((m.start(), m.end(), "emoji"));
1047 }
1048
1049 if let Some(m) = HTML_ENTITY_REGEX.find(remaining)
1051 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
1052 {
1053 earliest_match = Some((m.start(), m.end(), "html_entity"));
1054 }
1055
1056 if let Some(m) = HUGO_SHORTCODE_REGEX.find(remaining)
1059 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
1060 {
1061 earliest_match = Some((m.start(), m.end(), "hugo_shortcode"));
1062 }
1063
1064 if let Some(m) = HTML_TAG_PATTERN.find(remaining)
1067 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
1068 {
1069 let matched_text = &remaining[m.start()..m.end()];
1071 let is_url_autolink = matched_text.starts_with("<http://")
1072 || matched_text.starts_with("<https://")
1073 || matched_text.starts_with("<mailto:")
1074 || matched_text.starts_with("<ftp://")
1075 || matched_text.starts_with("<ftps://");
1076
1077 let is_email_autolink = {
1080 let content = matched_text.trim_start_matches('<').trim_end_matches('>');
1081 EMAIL_PATTERN.is_match(content)
1082 };
1083
1084 if is_url_autolink || is_email_autolink {
1085 } else {
1087 earliest_match = Some((m.start(), m.end(), "html_tag"));
1088 }
1089 }
1090
1091 let mut next_special = remaining.len();
1093 let mut special_type = "";
1094 let mut pulldown_emphasis: Option<&EmphasisSpan> = None;
1095 let mut attr_list_len: usize = 0;
1096 let mut myst_role_len: usize = 0;
1097
1098 if let Some(pos) = remaining.find('`')
1100 && pos < next_special
1101 {
1102 next_special = pos;
1103 special_type = "code";
1104 }
1105
1106 if myst_roles
1111 && let Some(pos) = remaining.find('{')
1112 && pos < next_special
1113 && let Some(role_len) = myst_role_len_at(&remaining[pos..])
1114 {
1115 next_special = pos;
1116 special_type = "myst_role";
1117 myst_role_len = role_len;
1118 }
1119
1120 if attr_lists
1122 && let Some(pos) = remaining.find('{')
1123 && pos < next_special
1124 && let Some(m) = ATTR_LIST_PATTERN.find(&remaining[pos..])
1125 && m.start() == 0
1126 {
1127 next_special = pos;
1128 special_type = "attr_list";
1129 attr_list_len = m.end();
1130 }
1131
1132 for span in &emphasis_spans {
1135 if span.start >= current_offset && span.start < current_offset + remaining.len() {
1136 let pos_in_remaining = span.start - current_offset;
1137 if pos_in_remaining < next_special {
1138 next_special = pos_in_remaining;
1139 special_type = "pulldown_emphasis";
1140 pulldown_emphasis = Some(span);
1141 }
1142 break; }
1144 }
1145
1146 let should_process_markdown_link = if let Some((pos, _, _)) = earliest_match {
1148 pos < next_special
1149 } else {
1150 false
1151 };
1152
1153 if should_process_markdown_link {
1154 let (pos, match_end, pattern_type) = earliest_match.unwrap();
1155
1156 if pos > 0 {
1158 elements.push(Element::Text(remaining[..pos].to_string()));
1159 }
1160
1161 match pattern_type {
1163 "link_span" => {
1164 let span = next_link.unwrap();
1165 let raw_text = remaining[pos..match_end].to_string();
1166 if span.is_footnote {
1167 elements.push(Element::FootnoteReference(raw_text));
1168 } else if span.is_image {
1169 match span.link_type {
1170 Some(LinkType::Inline) => elements.push(Element::InlineImage(raw_text)),
1171 Some(LinkType::Reference)
1174 | Some(LinkType::ReferenceUnknown)
1175 | Some(LinkType::Shortcut)
1176 | Some(LinkType::ShortcutUnknown) => elements.push(Element::ReferenceImage(raw_text)),
1177 Some(LinkType::Collapsed) | Some(LinkType::CollapsedUnknown) => {
1178 elements.push(Element::EmptyReferenceImage(raw_text))
1179 }
1180 _ => elements.push(Element::InlineImage(raw_text)),
1181 }
1182 } else {
1183 match span.link_type {
1184 Some(LinkType::Inline) => {
1185 if raw_text.starts_with('[') && raw_text.contains("![") {
1186 elements.push(Element::LinkedImage(raw_text));
1187 } else {
1188 elements.push(Element::Link(raw_text));
1189 }
1190 }
1191 Some(LinkType::Reference) | Some(LinkType::ReferenceUnknown) => {
1194 elements.push(Element::ReferenceLink(raw_text))
1195 }
1196 Some(LinkType::Collapsed) | Some(LinkType::CollapsedUnknown) => {
1197 elements.push(Element::EmptyReferenceLink(raw_text))
1198 }
1199 Some(LinkType::Shortcut) | Some(LinkType::ShortcutUnknown) => {
1200 elements.push(Element::ShortcutReference(raw_text))
1201 }
1202 Some(LinkType::Autolink) | Some(LinkType::Email) => {
1203 elements.push(Element::Autolink(raw_text))
1204 }
1205 _ => elements.push(Element::Link(raw_text)),
1206 }
1207 }
1208 remaining = &remaining[match_end..];
1209 }
1210 "wiki_link" => {
1211 if let Some(caps) = WIKI_LINK_REGEX.captures(remaining) {
1212 let content = caps.get(1).map_or("", |m| m.as_str());
1213 elements.push(Element::WikiLink(content.to_string()));
1214 remaining = &remaining[match_end..];
1215 } else {
1216 elements.push(Element::Text("[[".to_string()));
1217 remaining = &remaining[2..];
1218 }
1219 }
1220 "display_math" => {
1221 if let Some(caps) = DISPLAY_MATH_REGEX.captures(remaining) {
1222 let math = caps.get(1).map_or("", |m| m.as_str());
1223 elements.push(Element::DisplayMath(math.to_string()));
1224 remaining = &remaining[match_end..];
1225 } else {
1226 elements.push(Element::Text("$$".to_string()));
1227 remaining = &remaining[2..];
1228 }
1229 }
1230 "inline_math" => {
1231 if let Ok(Some(caps)) = INLINE_MATH_REGEX.captures(remaining) {
1232 let math = caps.get(1).map_or("", |m| m.as_str());
1233 elements.push(Element::InlineMath(math.to_string()));
1234 remaining = &remaining[match_end..];
1235 } else {
1236 elements.push(Element::Text("$".to_string()));
1237 remaining = &remaining[1..];
1238 }
1239 }
1240 "emoji" => {
1241 if let Some(caps) = EMOJI_SHORTCODE_REGEX.captures(remaining) {
1242 let emoji = caps.get(1).map_or("", |m| m.as_str());
1243 elements.push(Element::EmojiShortcode(emoji.to_string()));
1244 remaining = &remaining[match_end..];
1245 } else {
1246 elements.push(Element::Text(":".to_string()));
1247 remaining = &remaining[1..];
1248 }
1249 }
1250 "html_entity" => {
1251 elements.push(Element::HtmlEntity(remaining[pos..match_end].to_string()));
1253 remaining = &remaining[match_end..];
1254 }
1255 "hugo_shortcode" => {
1256 elements.push(Element::HugoShortcode(remaining[pos..match_end].to_string()));
1258 remaining = &remaining[match_end..];
1259 }
1260 "html_tag" => {
1261 elements.push(Element::HtmlTag(remaining[pos..match_end].to_string()));
1263 remaining = &remaining[match_end..];
1264 }
1265 _ => {
1266 elements.push(Element::Text("[".to_string()));
1268 remaining = &remaining[1..];
1269 }
1270 }
1271 } else {
1272 if next_special > 0 && next_special < remaining.len() {
1276 elements.push(Element::Text(remaining[..next_special].to_string()));
1277 remaining = &remaining[next_special..];
1278 }
1279
1280 match special_type {
1282 "code" => {
1283 if let Some(code_end) = remaining[1..].find('`') {
1285 let code = &remaining[1..=code_end];
1286 elements.push(Element::Code(code.to_string()));
1287 remaining = &remaining[1 + code_end + 1..];
1288 } else {
1289 elements.push(Element::Text(remaining.to_string()));
1291 break;
1292 }
1293 }
1294 "attr_list" => {
1295 elements.push(Element::AttrList(remaining[..attr_list_len].to_string()));
1296 remaining = &remaining[attr_list_len..];
1297 }
1298 "myst_role" => {
1299 elements.push(Element::MystRole(remaining[..myst_role_len].to_string()));
1300 remaining = &remaining[myst_role_len..];
1301 }
1302 "pulldown_emphasis" => {
1303 if let Some(span) = pulldown_emphasis {
1305 let span_len = span.end - span.start;
1306 if span.is_strikethrough {
1307 elements.push(Element::Strikethrough {
1308 content: span.content.clone(),
1309 double: span.strikethrough_double,
1310 });
1311 } else if span.is_strong {
1312 elements.push(Element::Bold {
1313 content: span.content.clone(),
1314 underscore: span.uses_underscore,
1315 });
1316 } else {
1317 elements.push(Element::Italic {
1318 content: span.content.clone(),
1319 underscore: span.uses_underscore,
1320 });
1321 }
1322 remaining = &remaining[span_len..];
1323 } else {
1324 elements.push(Element::Text(remaining[..1].to_string()));
1326 remaining = &remaining[1..];
1327 }
1328 }
1329 _ => {
1330 elements.push(Element::Text(remaining.to_string()));
1332 break;
1333 }
1334 }
1335 }
1336 }
1337
1338 elements
1339}
1340
1341fn should_insert_space_before_join(current: &str) -> bool {
1342 !current.is_empty()
1343 && !current.ends_with(' ')
1344 && !current.ends_with('(')
1345 && !current.ends_with('[')
1346 && !current.ends_with('-')
1347}
1348
1349fn reflow_elements_sentence_per_line(
1351 elements: &[Element],
1352 custom_abbreviations: &Option<Vec<String>>,
1353 require_sentence_capital: bool,
1354) -> Vec<String> {
1355 let abbreviations = get_abbreviations(custom_abbreviations);
1356 let mut lines = Vec::new();
1357 let mut current_line = String::new();
1358
1359 for (idx, element) in elements.iter().enumerate() {
1360 let element_str = format!("{element}");
1361
1362 if let Element::Text(text) = element {
1364 let combined = format!("{current_line}{text}");
1366 let sentences = split_into_sentences_with_set(&combined, &abbreviations, require_sentence_capital);
1368
1369 if sentences.len() > 1 {
1370 for (i, sentence) in sentences.iter().enumerate() {
1372 if i == 0 {
1373 let trimmed = sentence.trim();
1376
1377 if text_ends_with_abbreviation(trimmed, &abbreviations) {
1378 current_line.clone_from(sentence);
1380 } else {
1381 lines.push(sentence.clone());
1383 current_line.clear();
1384 }
1385 } else if i == sentences.len() - 1 {
1386 let trimmed = sentence.trim();
1388 let ends_with_sentence_punct = ends_with_sentence_punct(trimmed);
1389
1390 if ends_with_sentence_punct && !text_ends_with_abbreviation(trimmed, &abbreviations) {
1391 lines.push(sentence.clone());
1393 current_line.clear();
1394 } else {
1395 current_line.clone_from(sentence);
1397 }
1398 } else {
1399 lines.push(sentence.clone());
1401 }
1402 }
1403 } else {
1404 let trimmed = combined.trim();
1406
1407 if trimmed.is_empty() {
1411 continue;
1412 }
1413
1414 let ends_with_sentence_punct = ends_with_sentence_punct(trimmed);
1415
1416 if ends_with_sentence_punct && !text_ends_with_abbreviation(trimmed, &abbreviations) {
1417 lines.push(trimmed.to_string());
1419 current_line.clear();
1420 } else {
1421 current_line = combined;
1423 }
1424 }
1425 } else if let Element::Italic { content, underscore } = element {
1426 let marker = if *underscore { "_" } else { "*" };
1428 handle_emphasis_sentence_split(
1429 content,
1430 marker,
1431 &abbreviations,
1432 require_sentence_capital,
1433 &mut current_line,
1434 &mut lines,
1435 );
1436 } else if let Element::Bold { content, underscore } = element {
1437 let marker = if *underscore { "__" } else { "**" };
1439 handle_emphasis_sentence_split(
1440 content,
1441 marker,
1442 &abbreviations,
1443 require_sentence_capital,
1444 &mut current_line,
1445 &mut lines,
1446 );
1447 } else if let Element::Strikethrough { content, double } = element {
1448 handle_emphasis_sentence_split(
1450 content,
1451 if *double { "~~" } else { "~" },
1452 &abbreviations,
1453 require_sentence_capital,
1454 &mut current_line,
1455 &mut lines,
1456 );
1457 } else {
1458 let is_adjacent = if idx > 0 {
1461 match &elements[idx - 1] {
1462 Element::Text(t) => !t.is_empty() && !t.ends_with(char::is_whitespace),
1463 _ => true,
1464 }
1465 } else {
1466 false
1467 };
1468
1469 if !is_adjacent && should_insert_space_before_join(¤t_line) {
1471 current_line.push(' ');
1472 }
1473 current_line.push_str(&element_str);
1474 }
1475 }
1476
1477 if !current_line.is_empty() {
1479 lines.push(current_line.trim().to_string());
1480 }
1481 lines
1482}
1483
1484fn handle_emphasis_sentence_split(
1486 content: &str,
1487 marker: &str,
1488 abbreviations: &HashSet<String>,
1489 require_sentence_capital: bool,
1490 current_line: &mut String,
1491 lines: &mut Vec<String>,
1492) {
1493 let sentences = split_into_sentences_with_set(content, abbreviations, require_sentence_capital);
1495
1496 if sentences.len() <= 1 {
1497 if should_insert_space_before_join(current_line) {
1499 current_line.push(' ');
1500 }
1501 current_line.push_str(marker);
1502 current_line.push_str(content);
1503 current_line.push_str(marker);
1504
1505 let trimmed = content.trim();
1507 let ends_with_punct = ends_with_sentence_punct(trimmed);
1508 if ends_with_punct && !text_ends_with_abbreviation(trimmed, abbreviations) {
1509 lines.push(current_line.clone());
1510 current_line.clear();
1511 }
1512 } else {
1513 for (i, sentence) in sentences.iter().enumerate() {
1515 let trimmed = sentence.trim();
1516 if trimmed.is_empty() {
1517 continue;
1518 }
1519
1520 if i == 0 {
1521 if should_insert_space_before_join(current_line) {
1523 current_line.push(' ');
1524 }
1525 current_line.push_str(marker);
1526 current_line.push_str(trimmed);
1527 current_line.push_str(marker);
1528
1529 let ends_with_punct = ends_with_sentence_punct(trimmed);
1531 if ends_with_punct && !text_ends_with_abbreviation(trimmed, abbreviations) {
1532 lines.push(current_line.clone());
1533 current_line.clear();
1534 }
1535 } else if i == sentences.len() - 1 {
1536 let ends_with_punct = ends_with_sentence_punct(trimmed);
1538
1539 let mut line = String::new();
1540 line.push_str(marker);
1541 line.push_str(trimmed);
1542 line.push_str(marker);
1543
1544 if ends_with_punct && !text_ends_with_abbreviation(trimmed, abbreviations) {
1545 lines.push(line);
1546 } else {
1547 *current_line = line;
1549 }
1550 } else {
1551 let mut line = String::new();
1553 line.push_str(marker);
1554 line.push_str(trimmed);
1555 line.push_str(marker);
1556 lines.push(line);
1557 }
1558 }
1559 }
1560}
1561
1562const BREAK_WORDS: &[&str] = &[
1566 "and",
1567 "or",
1568 "but",
1569 "nor",
1570 "yet",
1571 "so",
1572 "for",
1573 "which",
1574 "that",
1575 "because",
1576 "when",
1577 "if",
1578 "while",
1579 "where",
1580 "although",
1581 "though",
1582 "unless",
1583 "since",
1584 "after",
1585 "before",
1586 "until",
1587 "as",
1588 "once",
1589 "whether",
1590 "however",
1591 "therefore",
1592 "moreover",
1593 "furthermore",
1594 "nevertheless",
1595 "whereas",
1596];
1597
1598fn is_clause_punctuation(c: char) -> bool {
1600 matches!(c, ',' | ';' | ':' | '\u{2014}') }
1602
1603fn clause_break_allowed_after(chars: &[char], i: usize) -> bool {
1611 if chars[i] == '\u{2014}' {
1612 return true;
1613 }
1614 match chars.get(i + 1) {
1615 None => true,
1616 Some(next) => next.is_whitespace(),
1617 }
1618}
1619
1620fn paren_group_end<'a>(slice: &'a str, element_spans: &[(usize, usize)], offset: usize) -> Option<(usize, &'a str)> {
1634 debug_assert!(slice.starts_with('('));
1635 let mut depth: i32 = 0;
1636 for (local_byte, c) in slice.char_indices() {
1637 let global_byte = offset + local_byte;
1638 if depth > 0 && is_inside_element(global_byte, element_spans) {
1643 continue;
1644 }
1645 match c {
1646 '(' => depth += 1,
1647 ')' => {
1648 depth -= 1;
1649 if depth == 0 {
1650 let end = local_byte + 1;
1651 let inner = &slice[1..local_byte];
1652 return Some((end, inner));
1653 }
1654 }
1655 _ => {}
1656 }
1657 }
1658 None
1659}
1660
1661fn split_at_parenthetical(
1678 text: &str,
1679 line_length: usize,
1680 element_spans: &[(usize, usize)],
1681 length_mode: ReflowLengthMode,
1682) -> Option<(String, String)> {
1683 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
1684
1685 if text.starts_with('(')
1687 && let Some((end_local, inner)) = paren_group_end(text, element_spans, 0)
1688 && inner.contains(' ')
1689 {
1690 let tail = &text[end_local..];
1694 let attached_len = tail
1695 .char_indices()
1696 .take_while(|(_, c)| is_closing_quote(*c) || is_clause_punctuation(*c))
1697 .last()
1698 .map_or(0, |(idx, c)| idx + c.len_utf8());
1699 let first_end = end_local + attached_len;
1700 let rest_start = first_end;
1701 let first = &text[..first_end];
1702 let first_len = display_len(first, length_mode);
1703 if first_len <= line_length {
1706 let rest = text[rest_start..].trim_start();
1707 if !rest.is_empty() {
1708 return Some((first.to_string(), rest.to_string()));
1709 }
1710 }
1711 }
1712
1713 let mut best_open_byte: Option<usize> = None;
1715 let mut pos = 0usize;
1716 while pos < text.len() {
1717 if text.as_bytes()[pos] != b'(' {
1719 let c = text[pos..].chars().next().unwrap();
1720 pos += c.len_utf8();
1721 continue;
1722 }
1723 if is_inside_element(pos, element_spans) {
1725 pos += 1;
1726 continue;
1727 }
1728 if let Some((end_local, inner)) = paren_group_end(&text[pos..], element_spans, pos) {
1729 let first = text[..pos].trim_end();
1730 let first_len = display_len(first, length_mode);
1731 if !first.is_empty()
1732 && first_len >= min_first_len
1733 && first_len <= line_length
1734 && inner.contains(' ')
1735 && best_open_byte.is_none_or(|prev| pos > prev)
1736 {
1737 best_open_byte = Some(pos);
1738 }
1739 pos += end_local;
1740 } else {
1741 pos += 1;
1742 }
1743 }
1744
1745 let open_byte = best_open_byte?;
1746 let first = text[..open_byte].trim_end().to_string();
1747 let rest = text[open_byte..].to_string();
1748 if first.is_empty() || rest.trim().is_empty() {
1749 return None;
1750 }
1751 Some((first, rest))
1752}
1753
1754fn compute_element_spans(elements: &[Element]) -> Vec<(usize, usize)> {
1758 let mut spans = Vec::new();
1759 let mut offset = 0;
1760 for element in elements {
1761 let rendered = format!("{element}");
1762 let len = rendered.len();
1763 if !matches!(element, Element::Text(_)) {
1764 spans.push((offset, offset + len));
1765 }
1766 offset += len;
1767 }
1768 spans
1769}
1770
1771fn is_inside_element(pos: usize, spans: &[(usize, usize)]) -> bool {
1773 spans.iter().any(|(start, end)| pos > *start && pos < *end)
1774}
1775
1776const MIN_SPLIT_RATIO: f64 = 0.3;
1779
1780fn split_at_clause_punctuation(
1784 text: &str,
1785 line_length: usize,
1786 element_spans: &[(usize, usize)],
1787 length_mode: ReflowLengthMode,
1788) -> Option<(String, String)> {
1789 let chars: Vec<char> = text.chars().collect();
1790 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
1791
1792 let mut width_acc = 0;
1794 let mut search_end_char = 0;
1795 for (idx, &c) in chars.iter().enumerate() {
1796 let c_width = display_len(&c.to_string(), length_mode);
1797 if width_acc + c_width > line_length {
1798 break;
1799 }
1800 width_acc += c_width;
1801 search_end_char = idx + 1;
1802 }
1803
1804 let mut paren_depth: i32 = 0;
1811 let mut best_pos = None;
1812 for i in (0..search_end_char).rev() {
1813 let byte_start: usize = chars[..i].iter().map(|c| c.len_utf8()).sum();
1815 let byte_after: usize = byte_start + chars[i].len_utf8();
1817
1818 if !is_inside_element(byte_start, element_spans) {
1819 match chars[i] {
1820 ')' => paren_depth += 1,
1821 '(' => paren_depth = paren_depth.saturating_sub(1),
1822 _ => {}
1823 }
1824 }
1825
1826 if paren_depth == 0
1827 && is_clause_punctuation(chars[i])
1828 && clause_break_allowed_after(&chars, i)
1829 && !is_inside_element(byte_after, element_spans)
1830 {
1831 best_pos = Some(i);
1832 break;
1833 }
1834 }
1835
1836 let pos = best_pos?;
1837
1838 let first: String = chars[..=pos].iter().collect();
1840 let first_display_len = display_len(&first, length_mode);
1841 if first_display_len < min_first_len {
1842 return None;
1843 }
1844
1845 let rest: String = chars[pos + 1..].iter().collect();
1847 let rest = rest.trim_start().to_string();
1848
1849 if rest.is_empty() {
1850 return None;
1851 }
1852
1853 Some((first, rest))
1854}
1855
1856fn paren_depth_map(text: &str, element_spans: &[(usize, usize)]) -> Vec<i32> {
1863 let mut map = vec![0i32; text.len()];
1864 let mut depth = 0i32;
1865 for (byte, c) in text.char_indices() {
1866 if !is_inside_element(byte, element_spans) {
1867 match c {
1868 '(' => depth += 1,
1869 ')' => depth = depth.saturating_sub(1),
1870 _ => {}
1871 }
1872 }
1873 let end = (byte + c.len_utf8()).min(map.len());
1875 for slot in &mut map[byte..end] {
1876 *slot = depth;
1877 }
1878 }
1879 map
1880}
1881
1882fn is_standalone_parenthetical(line: &str) -> bool {
1891 let trimmed = line.trim();
1892 if !trimmed.starts_with('(') {
1893 return false;
1894 }
1895 let core = trimmed.trim_end_matches(|c: char| is_clause_punctuation(c));
1897 if !core.ends_with(')') {
1898 return false;
1899 }
1900 let inner = &core[1..core.len() - 1];
1902 if !inner.contains(' ') {
1903 return false;
1904 }
1905 let mut depth = 0i32;
1907 for c in core.chars() {
1908 match c {
1909 '(' => depth += 1,
1910 ')' => depth -= 1,
1911 _ => {}
1912 }
1913 if depth < 0 {
1914 return false;
1915 }
1916 }
1917 depth == 0
1918}
1919
1920fn split_at_break_word(
1924 text: &str,
1925 line_length: usize,
1926 element_spans: &[(usize, usize)],
1927 length_mode: ReflowLengthMode,
1928) -> Option<(String, String)> {
1929 let lower = text.to_lowercase();
1930 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
1931 let mut best_split: Option<(usize, usize)> = None; let depth_map = paren_depth_map(text, element_spans);
1936
1937 for &word in BREAK_WORDS {
1938 let mut search_start = 0;
1939 while let Some(pos) = lower[search_start..].find(word) {
1940 let abs_pos = search_start + pos;
1941
1942 let preceded_by_space = abs_pos == 0 || text.as_bytes().get(abs_pos - 1) == Some(&b' ');
1944 let followed_by_space = text.as_bytes().get(abs_pos + word.len()) == Some(&b' ');
1945
1946 if preceded_by_space && followed_by_space {
1947 let first_part = text[..abs_pos].trim_end();
1949 let first_part_len = display_len(first_part, length_mode);
1950
1951 let inside_paren = depth_map.get(abs_pos).is_some_and(|&d| d > 0);
1953
1954 if first_part_len >= min_first_len
1955 && first_part_len <= line_length
1956 && !is_inside_element(abs_pos, element_spans)
1957 && !inside_paren
1958 {
1959 if best_split.is_none_or(|(prev_pos, _)| abs_pos > prev_pos) {
1961 best_split = Some((abs_pos, word.len()));
1962 }
1963 }
1964 }
1965
1966 search_start = abs_pos + word.len();
1967 }
1968 }
1969
1970 let (byte_start, _word_len) = best_split?;
1971
1972 let first = text[..byte_start].trim_end().to_string();
1973 let rest = text[byte_start..].to_string();
1974
1975 if first.is_empty() || rest.trim().is_empty() {
1976 return None;
1977 }
1978
1979 Some((first, rest))
1980}
1981
1982fn cascade_split_line(
1993 text: &str,
1994 line_length: usize,
1995 abbreviations: &Option<Vec<String>>,
1996 length_mode: ReflowLengthMode,
1997 attr_lists: bool,
1998 myst_roles: bool,
1999 defined_references: Option<&HashSet<String>>,
2000) -> Vec<String> {
2001 if line_length == 0 || display_len(text, length_mode) <= line_length {
2002 return vec![text.to_string()];
2003 }
2004
2005 let elements = parse_markdown_elements_inner(text, attr_lists, myst_roles, defined_references);
2006 let element_spans = compute_element_spans(&elements);
2007
2008 let rebased_spans = |start: usize| -> Vec<(usize, usize)> {
2012 if start == 0 {
2013 return element_spans.clone();
2014 }
2015 element_spans
2016 .iter()
2017 .filter(|&&(_, end)| end > start)
2018 .map(|&(s, e)| (s.saturating_sub(start), e.saturating_sub(start)))
2019 .collect()
2020 };
2021
2022 let mut result = Vec::new();
2023 let mut start = 0usize;
2024
2025 loop {
2026 let remaining = &text[start..];
2027 if display_len(remaining, length_mode) <= line_length {
2028 result.push(remaining.to_string());
2029 return result;
2030 }
2031
2032 let spans = rebased_spans(start);
2033
2034 let split = split_at_parenthetical(remaining, line_length, &spans, length_mode)
2038 .or_else(|| split_at_clause_punctuation(remaining, line_length, &spans, length_mode))
2039 .or_else(|| split_at_break_word(remaining, line_length, &spans, length_mode));
2040
2041 if let Some((first, rest)) = split {
2042 let consumed = remaining.len().saturating_sub(rest.len());
2043 if consumed == 0 {
2046 break;
2047 }
2048 result.push(first);
2049 start += consumed;
2050 continue;
2051 }
2052
2053 break;
2055 }
2056
2057 let options = ReflowOptions {
2059 line_length,
2060 break_on_sentences: false,
2061 preserve_breaks: false,
2062 sentence_per_line: false,
2063 semantic_line_breaks: false,
2064 abbreviations: abbreviations.clone(),
2065 length_mode,
2066 attr_lists,
2067 myst_roles,
2068 require_sentence_capital: true,
2069 max_list_continuation_indent: None,
2070 defined_references: None,
2073 };
2074 let remaining = &text[start..];
2075 let tail_elements = if start == 0 {
2076 elements
2077 } else {
2078 parse_markdown_elements_inner(remaining, attr_lists, myst_roles, defined_references)
2079 };
2080 result.extend(reflow_elements(&tail_elements, &options));
2081 result
2082}
2083
2084fn reflow_elements_semantic(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
2088 let sentence_lines =
2090 reflow_elements_sentence_per_line(elements, &options.abbreviations, options.require_sentence_capital);
2091
2092 if options.line_length == 0 {
2095 return sentence_lines;
2096 }
2097
2098 let length_mode = options.length_mode;
2099 let mut result = Vec::new();
2100 for line in sentence_lines {
2101 if display_len(&line, length_mode) <= options.line_length {
2102 result.push(line);
2103 } else {
2104 result.extend(cascade_split_line(
2105 &line,
2106 options.line_length,
2107 &options.abbreviations,
2108 length_mode,
2109 options.attr_lists,
2110 options.myst_roles,
2111 options.defined_references.as_ref(),
2112 ));
2113 }
2114 }
2115
2116 let min_line_len = ((options.line_length as f64) * MIN_SPLIT_RATIO) as usize;
2119 let mut merged: Vec<String> = Vec::with_capacity(result.len());
2120 for line in result {
2121 if !merged.is_empty() && display_len(&line, length_mode) < min_line_len && !line.trim().is_empty() {
2122 if is_standalone_parenthetical(&line) {
2125 merged.push(line);
2126 continue;
2127 }
2128
2129 let prev_ends_at_sentence = {
2131 let trimmed = merged.last().unwrap().trim_end();
2132 trimmed
2133 .chars()
2134 .rev()
2135 .find(|c| !matches!(c, '"' | '\'' | '\u{201D}' | '\u{2019}' | ')' | ']'))
2136 .is_some_and(|c| matches!(c, '.' | '!' | '?'))
2137 };
2138
2139 if !prev_ends_at_sentence {
2140 let prev = merged.last_mut().unwrap();
2141 let combined = format!("{prev} {line}");
2142 if display_len(&combined, length_mode) <= options.line_length {
2144 *prev = combined;
2145 continue;
2146 }
2147 }
2148 }
2149 merged.push(line);
2150 }
2151 merged
2152}
2153
2154fn rfind_safe_space(line: &str, element_spans: &[(usize, usize)]) -> Option<usize> {
2162 line.char_indices()
2163 .rev()
2164 .map(|(pos, _)| pos)
2165 .find(|&pos| line.as_bytes()[pos] == b' ' && !element_spans.iter().any(|(s, e)| pos > *s && pos < *e))
2166}
2167
2168fn reflow_elements(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
2170 let mut lines = Vec::new();
2171 let mut current_line = String::new();
2172 let mut current_length = 0;
2173 let mut current_line_element_spans: Vec<(usize, usize)> = Vec::new();
2175 let length_mode = options.length_mode;
2176
2177 for (idx, element) in elements.iter().enumerate() {
2178 let element_str = format!("{element}");
2181 let element_len = display_len(&element_str, length_mode);
2182
2183 let is_adjacent_to_prev = if idx > 0 {
2189 match (&elements[idx - 1], element) {
2190 (Element::Text(t), _) => !t.is_empty() && !t.ends_with(char::is_whitespace),
2191 (_, Element::Text(t)) => !t.is_empty() && !t.starts_with(char::is_whitespace),
2192 _ => true,
2193 }
2194 } else {
2195 false
2196 };
2197
2198 if let Element::Text(text) = element {
2200 let has_leading_space = text.starts_with(char::is_whitespace);
2202 let words: Vec<&str> = text.split_whitespace().collect();
2204
2205 for (i, word) in words.iter().enumerate() {
2206 let word_len = display_len(word, length_mode);
2207 let is_trailing_punct = word
2209 .chars()
2210 .all(|c| matches!(c, ',' | '.' | ':' | ';' | '!' | '?' | ')' | ']' | '}'));
2211
2212 let is_first_adjacent = i == 0 && is_adjacent_to_prev;
2215
2216 if is_first_adjacent {
2217 if current_length + word_len > options.line_length && current_length > 0 {
2219 if let Some(last_space) = rfind_safe_space(¤t_line, ¤t_line_element_spans) {
2222 let before = current_line[..last_space].trim_end().to_string();
2223 let after = current_line[last_space + 1..].to_string();
2224 lines.push(before);
2225 current_line = format!("{after}{word}");
2226 current_length = display_len(¤t_line, length_mode);
2227 current_line_element_spans.clear();
2228 } else {
2229 current_line.push_str(word);
2230 current_length += word_len;
2231 }
2232 } else {
2233 current_line.push_str(word);
2234 current_length += word_len;
2235 }
2236 } else if current_length > 0
2237 && current_length + 1 + word_len > options.line_length
2238 && !is_trailing_punct
2239 {
2240 lines.push(current_line.trim().to_string());
2242 current_line = word.to_string();
2243 current_length = word_len;
2244 current_line_element_spans.clear();
2245 } else {
2246 let add_space = current_length > 0 && if i == 0 { has_leading_space } else { !is_trailing_punct };
2256 if add_space {
2257 current_line.push(' ');
2258 current_length += 1;
2259 }
2260 current_line.push_str(word);
2261 current_length += word_len;
2262 }
2263 }
2264 } else if matches!(
2265 element,
2266 Element::Italic { .. } | Element::Bold { .. } | Element::Strikethrough { .. }
2267 ) && element_len > options.line_length
2268 {
2269 let (content, marker): (&str, &str) = match element {
2273 Element::Italic { content, underscore } => (content.as_str(), if *underscore { "_" } else { "*" }),
2274 Element::Bold { content, underscore } => (content.as_str(), if *underscore { "__" } else { "**" }),
2275 Element::Strikethrough { content, double } => (content.as_str(), if *double { "~~" } else { "~" }),
2276 _ => unreachable!(),
2277 };
2278
2279 let words: Vec<&str> = content.split_whitespace().collect();
2280 let n = words.len();
2281
2282 if n == 0 {
2283 let full = format!("{marker}{marker}");
2285 let full_len = display_len(&full, length_mode);
2286 if !is_adjacent_to_prev && current_length > 0 {
2287 current_line.push(' ');
2288 current_length += 1;
2289 }
2290 current_line.push_str(&full);
2291 current_length += full_len;
2292 } else {
2293 for (i, word) in words.iter().enumerate() {
2294 let is_first = i == 0;
2295 let is_last = i == n - 1;
2296 let word_str: String = match (is_first, is_last) {
2297 (true, true) => format!("{marker}{word}{marker}"),
2298 (true, false) => format!("{marker}{word}"),
2299 (false, true) => format!("{word}{marker}"),
2300 (false, false) => word.to_string(),
2301 };
2302 let word_len = display_len(&word_str, length_mode);
2303
2304 let needs_space = if is_first {
2305 !is_adjacent_to_prev && current_length > 0
2306 } else {
2307 current_length > 0
2308 };
2309
2310 if needs_space && current_length + 1 + word_len > options.line_length {
2311 lines.push(current_line.trim_end().to_string());
2312 current_line = word_str;
2313 current_length = word_len;
2314 current_line_element_spans.clear();
2315 } else {
2316 if needs_space {
2317 current_line.push(' ');
2318 current_length += 1;
2319 }
2320 current_line.push_str(&word_str);
2321 current_length += word_len;
2322 }
2323 }
2324 }
2325 } else {
2326 if is_adjacent_to_prev {
2330 if current_length + element_len > options.line_length {
2332 if let Some(last_space) = rfind_safe_space(¤t_line, ¤t_line_element_spans) {
2335 let before = current_line[..last_space].trim_end().to_string();
2336 let after = current_line[last_space + 1..].to_string();
2337 lines.push(before);
2338 current_line = format!("{after}{element_str}");
2339 current_length = display_len(¤t_line, length_mode);
2340 current_line_element_spans.clear();
2341 let start = after.len();
2343 current_line_element_spans.push((start, start + element_str.len()));
2344 } else {
2345 let start = current_line.len();
2347 current_line.push_str(&element_str);
2348 current_length += element_len;
2349 current_line_element_spans.push((start, current_line.len()));
2350 }
2351 } else {
2352 let start = current_line.len();
2353 current_line.push_str(&element_str);
2354 current_length += element_len;
2355 current_line_element_spans.push((start, current_line.len()));
2356 }
2357 } else if current_length > 0 && current_length + 1 + element_len > options.line_length {
2358 lines.push(current_line.trim().to_string());
2360 current_line.clone_from(&element_str);
2361 current_length = element_len;
2362 current_line_element_spans.clear();
2363 current_line_element_spans.push((0, element_str.len()));
2364 } else {
2365 let ends_with_opener =
2367 current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
2368 if current_length > 0 && !ends_with_opener {
2369 current_line.push(' ');
2370 current_length += 1;
2371 }
2372 let start = current_line.len();
2373 current_line.push_str(&element_str);
2374 current_length += element_len;
2375 current_line_element_spans.push((start, current_line.len()));
2376 }
2377 }
2378 }
2379
2380 if !current_line.is_empty() {
2382 lines.push(current_line.trim_end().to_string());
2383 }
2384
2385 lines
2386}
2387
2388pub fn reflow_markdown(content: &str, options: &ReflowOptions) -> String {
2390 let lines: Vec<&str> = content.lines().collect();
2391 let mut result = Vec::new();
2392 let mut i = 0;
2393
2394 while i < lines.len() {
2395 let line = lines[i];
2396 let trimmed = line.trim();
2397
2398 if trimmed.is_empty() {
2400 result.push(String::new());
2401 i += 1;
2402 continue;
2403 }
2404
2405 if trimmed.starts_with('#') {
2407 result.push(line.to_string());
2408 i += 1;
2409 continue;
2410 }
2411
2412 if trimmed.starts_with(":::") {
2414 result.push(line.to_string());
2415 i += 1;
2416 continue;
2417 }
2418
2419 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
2421 result.push(line.to_string());
2422 i += 1;
2423 while i < lines.len() {
2425 result.push(lines[i].to_string());
2426 if lines[i].trim().starts_with("```") || lines[i].trim().starts_with("~~~") {
2427 i += 1;
2428 break;
2429 }
2430 i += 1;
2431 }
2432 continue;
2433 }
2434
2435 if calculate_indentation_width_default(line) >= 4 {
2437 result.push(line.to_string());
2439 i += 1;
2440 while i < lines.len() {
2441 let next_line = lines[i];
2442 if calculate_indentation_width_default(next_line) >= 4 || next_line.trim().is_empty() {
2444 result.push(next_line.to_string());
2445 i += 1;
2446 } else {
2447 break;
2448 }
2449 }
2450 continue;
2451 }
2452
2453 if trimmed.starts_with('>') {
2455 let gt_pos = line.find('>').expect("'>' must exist since trimmed.starts_with('>')");
2458 let quote_prefix = line[0..=gt_pos].to_string();
2459 let quote_content = &line[quote_prefix.len()..].trim_start();
2460
2461 let reflowed = reflow_line(quote_content, options);
2462 for reflowed_line in &reflowed {
2463 result.push(format!("{quote_prefix} {reflowed_line}"));
2464 }
2465 i += 1;
2466 continue;
2467 }
2468
2469 if is_horizontal_rule(trimmed) {
2471 result.push(line.to_string());
2472 i += 1;
2473 continue;
2474 }
2475
2476 if is_unordered_list_marker(trimmed) || is_numbered_list_item(trimmed) {
2478 let indent = line.len() - line.trim_start().len();
2480 let indent_str = " ".repeat(indent);
2481
2482 let mut marker_end = indent;
2485 let mut content_start = indent;
2486
2487 if trimmed.chars().next().is_some_and(char::is_numeric) {
2488 if let Some(period_pos) = line[indent..].find('.') {
2490 marker_end = indent + period_pos + 1; content_start = marker_end;
2492 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
2496 content_start += 1;
2497 }
2498 }
2499 } else {
2500 marker_end = indent + 1; content_start = marker_end;
2503 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
2507 content_start += 1;
2508 }
2509 }
2510
2511 let min_continuation_indent = content_start;
2513
2514 let rest = &line[content_start..];
2517 if rest.starts_with("[ ] ") || rest.starts_with("[x] ") || rest.starts_with("[X] ") {
2518 marker_end = content_start + 3; content_start += 4; }
2521
2522 let marker = &line[indent..marker_end];
2523
2524 let mut list_content = vec![trim_preserving_hard_break(&line[content_start..])];
2527 i += 1;
2528
2529 while i < lines.len() {
2533 let next_line = lines[i];
2534 let next_trimmed = next_line.trim();
2535
2536 if is_block_boundary(next_trimmed) {
2538 break;
2539 }
2540
2541 let next_indent = next_line.len() - next_line.trim_start().len();
2543 if next_indent >= min_continuation_indent {
2544 let trimmed_start = next_line.trim_start();
2547 list_content.push(trim_preserving_hard_break(trimmed_start));
2548 i += 1;
2549 } else {
2550 break;
2552 }
2553 }
2554
2555 let combined_content = if options.preserve_breaks {
2558 list_content[0].clone()
2559 } else {
2560 let has_hard_breaks = list_content.iter().any(|line| has_hard_break(line));
2562 if has_hard_breaks {
2563 list_content.join("\n")
2565 } else {
2566 list_content.join(" ")
2568 }
2569 };
2570
2571 let trimmed_marker = marker;
2573 let continuation_spaces = if let Some(max_indent) = options.max_list_continuation_indent {
2574 indent + (content_start - indent).min(max_indent)
2577 } else {
2578 content_start
2579 };
2580
2581 let prefix_length = indent + trimmed_marker.len() + 1;
2583
2584 let adjusted_options = ReflowOptions {
2586 line_length: options.line_length.saturating_sub(prefix_length),
2587 ..options.clone()
2588 };
2589
2590 let reflowed = reflow_line(&combined_content, &adjusted_options);
2591 for (j, reflowed_line) in reflowed.iter().enumerate() {
2592 if j == 0 {
2593 result.push(format!("{indent_str}{trimmed_marker} {reflowed_line}"));
2594 } else {
2595 let continuation_indent = " ".repeat(continuation_spaces);
2597 result.push(format!("{continuation_indent}{reflowed_line}"));
2598 }
2599 }
2600 continue;
2601 }
2602
2603 if crate::utils::table_utils::TableUtils::is_potential_table_row(line) {
2605 result.push(line.to_string());
2606 i += 1;
2607 continue;
2608 }
2609
2610 if trimmed.starts_with('[') && line.contains("]:") {
2612 result.push(line.to_string());
2613 i += 1;
2614 continue;
2615 }
2616
2617 if is_definition_list_item(trimmed) {
2619 result.push(line.to_string());
2620 i += 1;
2621 continue;
2622 }
2623
2624 let mut is_single_line_paragraph = true;
2626 if i + 1 < lines.len() {
2627 let next_trimmed = lines[i + 1].trim();
2628 if !is_block_boundary(next_trimmed) {
2630 is_single_line_paragraph = false;
2631 }
2632 }
2633
2634 if is_single_line_paragraph && display_len(line, options.length_mode) <= options.line_length {
2636 result.push(line.to_string());
2637 i += 1;
2638 continue;
2639 }
2640
2641 let mut paragraph_parts = Vec::new();
2643 let mut current_part = vec![line];
2644 i += 1;
2645
2646 if options.preserve_breaks {
2648 let hard_break_type = if line.strip_suffix('\r').unwrap_or(line).ends_with('\\') {
2650 Some("\\")
2651 } else if line.ends_with(" ") {
2652 Some(" ")
2653 } else {
2654 None
2655 };
2656 let reflowed = reflow_line(line, options);
2657
2658 if let Some(break_marker) = hard_break_type {
2660 if !reflowed.is_empty() {
2661 let mut reflowed_with_break = reflowed;
2662 let last_idx = reflowed_with_break.len() - 1;
2663 if !has_hard_break(&reflowed_with_break[last_idx]) {
2664 reflowed_with_break[last_idx].push_str(break_marker);
2665 }
2666 result.extend(reflowed_with_break);
2667 }
2668 } else {
2669 result.extend(reflowed);
2670 }
2671 } else {
2672 while i < lines.len() {
2674 let prev_line = if !current_part.is_empty() {
2675 current_part.last().unwrap()
2676 } else {
2677 ""
2678 };
2679 let next_line = lines[i];
2680 let next_trimmed = next_line.trim();
2681
2682 if is_block_boundary(next_trimmed) {
2684 break;
2685 }
2686
2687 let prev_trimmed = prev_line.trim();
2690 let abbreviations = get_abbreviations(&options.abbreviations);
2691 let ends_with_sentence = (prev_trimmed.ends_with('.')
2692 || prev_trimmed.ends_with('!')
2693 || prev_trimmed.ends_with('?')
2694 || prev_trimmed.ends_with(".*")
2695 || prev_trimmed.ends_with("!*")
2696 || prev_trimmed.ends_with("?*")
2697 || prev_trimmed.ends_with("._")
2698 || prev_trimmed.ends_with("!_")
2699 || prev_trimmed.ends_with("?_")
2700 || prev_trimmed.ends_with(".\"")
2702 || prev_trimmed.ends_with("!\"")
2703 || prev_trimmed.ends_with("?\"")
2704 || prev_trimmed.ends_with(".'")
2705 || prev_trimmed.ends_with("!'")
2706 || prev_trimmed.ends_with("?'")
2707 || prev_trimmed.ends_with(".\u{201D}")
2708 || prev_trimmed.ends_with("!\u{201D}")
2709 || prev_trimmed.ends_with("?\u{201D}")
2710 || prev_trimmed.ends_with(".\u{2019}")
2711 || prev_trimmed.ends_with("!\u{2019}")
2712 || prev_trimmed.ends_with("?\u{2019}"))
2713 && !text_ends_with_abbreviation(
2714 prev_trimmed.trim_end_matches(['*', '_', '"', '\'', '\u{201D}', '\u{2019}']),
2715 &abbreviations,
2716 );
2717
2718 if has_hard_break(prev_line) || (options.sentence_per_line && ends_with_sentence) {
2719 paragraph_parts.push(current_part.join(" "));
2721 current_part = vec![next_line];
2722 } else {
2723 current_part.push(next_line);
2724 }
2725 i += 1;
2726 }
2727
2728 if !current_part.is_empty() {
2730 if current_part.len() == 1 {
2731 paragraph_parts.push(current_part[0].to_string());
2733 } else {
2734 paragraph_parts.push(current_part.join(" "));
2735 }
2736 }
2737
2738 for (j, part) in paragraph_parts.iter().enumerate() {
2740 let reflowed = reflow_line(part, options);
2741 result.extend(reflowed);
2742
2743 if j < paragraph_parts.len() - 1 && !result.is_empty() && !options.sentence_per_line {
2747 let last_idx = result.len() - 1;
2748 if !has_hard_break(&result[last_idx]) {
2749 result[last_idx].push_str(" ");
2750 }
2751 }
2752 }
2753 }
2754 }
2755
2756 let result_text = result.join("\n");
2758 if content.ends_with('\n') && !result_text.ends_with('\n') {
2759 format!("{result_text}\n")
2760 } else {
2761 result_text
2762 }
2763}
2764
2765#[derive(Debug, Clone)]
2767pub struct ParagraphReflow {
2768 pub start_byte: usize,
2770 pub end_byte: usize,
2772 pub reflowed_text: String,
2774}
2775
2776#[derive(Debug, Clone)]
2782pub struct BlockquoteLineData {
2783 pub(crate) content: String,
2785 pub(crate) is_explicit: bool,
2787 pub(crate) prefix: Option<String>,
2789}
2790
2791impl BlockquoteLineData {
2792 pub fn explicit(content: String, prefix: String) -> Self {
2794 Self {
2795 content,
2796 is_explicit: true,
2797 prefix: Some(prefix),
2798 }
2799 }
2800
2801 pub fn lazy(content: String) -> Self {
2803 Self {
2804 content,
2805 is_explicit: false,
2806 prefix: None,
2807 }
2808 }
2809}
2810
2811#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2813pub enum BlockquoteContinuationStyle {
2814 Explicit,
2815 Lazy,
2816}
2817
2818pub fn blockquote_continuation_style(lines: &[BlockquoteLineData]) -> BlockquoteContinuationStyle {
2826 let mut explicit_count = 0usize;
2827 let mut lazy_count = 0usize;
2828
2829 for line in lines.iter().skip(1) {
2830 if line.is_explicit {
2831 explicit_count += 1;
2832 } else {
2833 lazy_count += 1;
2834 }
2835 }
2836
2837 if explicit_count > 0 && lazy_count == 0 {
2838 BlockquoteContinuationStyle::Explicit
2839 } else if lazy_count > 0 && explicit_count == 0 {
2840 BlockquoteContinuationStyle::Lazy
2841 } else if explicit_count >= lazy_count {
2842 BlockquoteContinuationStyle::Explicit
2843 } else {
2844 BlockquoteContinuationStyle::Lazy
2845 }
2846}
2847
2848pub fn dominant_blockquote_prefix(lines: &[BlockquoteLineData], fallback: &str) -> String {
2853 let mut counts: std::collections::HashMap<String, (usize, usize)> = std::collections::HashMap::new();
2854
2855 for (idx, line) in lines.iter().enumerate() {
2856 let Some(prefix) = line.prefix.as_ref() else {
2857 continue;
2858 };
2859 counts
2860 .entry(prefix.clone())
2861 .and_modify(|entry| entry.0 += 1)
2862 .or_insert((1, idx));
2863 }
2864
2865 counts
2866 .into_iter()
2867 .max_by(|(_, (count_a, first_idx_a)), (_, (count_b, first_idx_b))| {
2868 count_a.cmp(count_b).then_with(|| first_idx_b.cmp(first_idx_a))
2869 })
2870 .map_or_else(|| fallback.to_string(), |(prefix, _)| prefix)
2871}
2872
2873pub(crate) fn should_force_explicit_blockquote_line(content_line: &str) -> bool {
2878 let trimmed = content_line.trim_start();
2879 trimmed.starts_with('>')
2880 || trimmed.starts_with('#')
2881 || trimmed.starts_with("```")
2882 || trimmed.starts_with("~~~")
2883 || is_unordered_list_marker(trimmed)
2884 || is_numbered_list_item(trimmed)
2885 || is_horizontal_rule(trimmed)
2886 || is_definition_list_item(trimmed)
2887 || (trimmed.starts_with('[') && trimmed.contains("]:"))
2888 || trimmed.starts_with(":::")
2889 || (trimmed.starts_with('<')
2890 && !trimmed.starts_with("<http")
2891 && !trimmed.starts_with("<https")
2892 && !trimmed.starts_with("<mailto:"))
2893}
2894
2895pub fn reflow_blockquote_content(
2904 lines: &[BlockquoteLineData],
2905 explicit_prefix: &str,
2906 continuation_style: BlockquoteContinuationStyle,
2907 options: &ReflowOptions,
2908) -> Vec<String> {
2909 let content_strs: Vec<&str> = lines.iter().map(|l| l.content.as_str()).collect();
2910 let segments = split_into_segments_strs(&content_strs);
2911 let mut reflowed_content_lines: Vec<String> = Vec::new();
2912
2913 for segment in segments {
2914 let hard_break_type = segment.last().and_then(|&line| {
2915 let line = line.strip_suffix('\r').unwrap_or(line);
2916 if line.ends_with('\\') {
2917 Some("\\")
2918 } else if line.ends_with(" ") {
2919 Some(" ")
2920 } else {
2921 None
2922 }
2923 });
2924
2925 let pieces: Vec<&str> = segment
2926 .iter()
2927 .map(|&line| {
2928 if let Some(l) = line.strip_suffix('\\') {
2929 l.trim_end()
2930 } else if let Some(l) = line.strip_suffix(" ") {
2931 l.trim_end()
2932 } else {
2933 line.trim_end()
2934 }
2935 })
2936 .collect();
2937
2938 let segment_text = pieces.join(" ");
2939 let segment_text = segment_text.trim();
2940 if segment_text.is_empty() {
2941 continue;
2942 }
2943
2944 let mut reflowed = reflow_line(segment_text, options);
2945 if let Some(break_marker) = hard_break_type
2946 && !reflowed.is_empty()
2947 {
2948 let last_idx = reflowed.len() - 1;
2949 if !has_hard_break(&reflowed[last_idx]) {
2950 reflowed[last_idx].push_str(break_marker);
2951 }
2952 }
2953 reflowed_content_lines.extend(reflowed);
2954 }
2955
2956 let mut styled_lines: Vec<String> = Vec::new();
2957 for (idx, line) in reflowed_content_lines.iter().enumerate() {
2958 let force_explicit = idx == 0
2959 || continuation_style == BlockquoteContinuationStyle::Explicit
2960 || should_force_explicit_blockquote_line(line);
2961 if force_explicit {
2962 styled_lines.push(format!("{explicit_prefix}{line}"));
2963 } else {
2964 styled_lines.push(line.clone());
2965 }
2966 }
2967
2968 styled_lines
2969}
2970
2971fn is_blockquote_content_boundary(content: &str) -> bool {
2972 let trimmed = content.trim();
2973 trimmed.is_empty()
2974 || is_block_boundary(trimmed)
2975 || crate::utils::table_utils::TableUtils::is_potential_table_row(content)
2976 || trimmed.starts_with(":::")
2977 || crate::utils::is_template_directive_only(content)
2978 || is_standalone_attr_list(content)
2979 || is_snippet_block_delimiter(content)
2980}
2981
2982fn split_into_segments_strs<'a>(lines: &[&'a str]) -> Vec<Vec<&'a str>> {
2983 let mut segments = Vec::new();
2984 let mut current = Vec::new();
2985
2986 for &line in lines {
2987 current.push(line);
2988 if has_hard_break(line) {
2989 segments.push(current);
2990 current = Vec::new();
2991 }
2992 }
2993
2994 if !current.is_empty() {
2995 segments.push(current);
2996 }
2997
2998 segments
2999}
3000
3001fn reflow_blockquote_paragraph_at_line(
3002 content: &str,
3003 lines: &[&str],
3004 target_idx: usize,
3005 options: &ReflowOptions,
3006) -> Option<ParagraphReflow> {
3007 let mut anchor_idx = target_idx;
3008 let mut target_level = if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[target_idx]) {
3009 parsed.nesting_level
3010 } else {
3011 let mut found = None;
3012 let mut idx = target_idx;
3013 loop {
3014 if lines[idx].trim().is_empty() {
3015 break;
3016 }
3017 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[idx]) {
3018 found = Some((idx, parsed.nesting_level));
3019 break;
3020 }
3021 if idx == 0 {
3022 break;
3023 }
3024 idx -= 1;
3025 }
3026 let (idx, level) = found?;
3027 anchor_idx = idx;
3028 level
3029 };
3030
3031 let mut para_start = anchor_idx;
3033 while para_start > 0 {
3034 let prev_idx = para_start - 1;
3035 let prev_line = lines[prev_idx];
3036
3037 if prev_line.trim().is_empty() {
3038 break;
3039 }
3040
3041 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(prev_line) {
3042 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
3043 break;
3044 }
3045 para_start = prev_idx;
3046 continue;
3047 }
3048
3049 let prev_lazy = prev_line.trim_start();
3050 if is_blockquote_content_boundary(prev_lazy) {
3051 break;
3052 }
3053 para_start = prev_idx;
3054 }
3055
3056 while para_start < lines.len() {
3058 let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[para_start]) else {
3059 para_start += 1;
3060 continue;
3061 };
3062 target_level = parsed.nesting_level;
3063 break;
3064 }
3065
3066 if para_start >= lines.len() || para_start > target_idx {
3067 return None;
3068 }
3069
3070 let mut collected: Vec<(usize, BlockquoteLineData)> = Vec::new();
3073 let mut idx = para_start;
3074 while idx < lines.len() {
3075 if !collected.is_empty() && has_hard_break(&collected[collected.len() - 1].1.content) {
3076 break;
3077 }
3078
3079 let line = lines[idx];
3080 if line.trim().is_empty() {
3081 break;
3082 }
3083
3084 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(line) {
3085 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
3086 break;
3087 }
3088 collected.push((
3089 idx,
3090 BlockquoteLineData::explicit(trim_preserving_hard_break(parsed.content), parsed.prefix.to_string()),
3091 ));
3092 idx += 1;
3093 continue;
3094 }
3095
3096 let lazy_content = line.trim_start();
3097 if is_blockquote_content_boundary(lazy_content) {
3098 break;
3099 }
3100
3101 collected.push((idx, BlockquoteLineData::lazy(trim_preserving_hard_break(lazy_content))));
3102 idx += 1;
3103 }
3104
3105 if collected.is_empty() {
3106 return None;
3107 }
3108
3109 let para_end = collected[collected.len() - 1].0;
3110 if target_idx < para_start || target_idx > para_end {
3111 return None;
3112 }
3113
3114 let line_data: Vec<BlockquoteLineData> = collected.iter().map(|(_, d)| d.clone()).collect();
3115
3116 let fallback_prefix = line_data
3117 .iter()
3118 .find_map(|d| d.prefix.clone())
3119 .unwrap_or_else(|| "> ".to_string());
3120 let explicit_prefix = dominant_blockquote_prefix(&line_data, &fallback_prefix);
3121 let continuation_style = blockquote_continuation_style(&line_data);
3122
3123 let adjusted_line_length = options
3124 .line_length
3125 .saturating_sub(display_len(&explicit_prefix, options.length_mode))
3126 .max(1);
3127
3128 let adjusted_options = ReflowOptions {
3129 line_length: adjusted_line_length,
3130 ..options.clone()
3131 };
3132
3133 let styled_lines = reflow_blockquote_content(&line_data, &explicit_prefix, continuation_style, &adjusted_options);
3134
3135 if styled_lines.is_empty() {
3136 return None;
3137 }
3138
3139 let mut start_byte = 0;
3141 for line in lines.iter().take(para_start) {
3142 start_byte += line.len() + 1;
3143 }
3144
3145 let mut end_byte = start_byte;
3146 for line in lines.iter().take(para_end + 1).skip(para_start) {
3147 end_byte += line.len() + 1;
3148 }
3149
3150 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
3151 if !includes_trailing_newline {
3152 end_byte -= 1;
3153 }
3154
3155 let reflowed_joined = styled_lines.join("\n");
3156 let reflowed_text = if includes_trailing_newline {
3157 if reflowed_joined.ends_with('\n') {
3158 reflowed_joined
3159 } else {
3160 format!("{reflowed_joined}\n")
3161 }
3162 } else if reflowed_joined.ends_with('\n') {
3163 reflowed_joined.trim_end_matches('\n').to_string()
3164 } else {
3165 reflowed_joined
3166 };
3167
3168 Some(ParagraphReflow {
3169 start_byte,
3170 end_byte,
3171 reflowed_text,
3172 })
3173}
3174
3175pub fn reflow_paragraph_at_line(content: &str, line_number: usize, line_length: usize) -> Option<ParagraphReflow> {
3193 reflow_paragraph_at_line_with_mode(content, line_number, line_length, ReflowLengthMode::default())
3194}
3195
3196pub fn reflow_paragraph_at_line_with_mode(
3198 content: &str,
3199 line_number: usize,
3200 line_length: usize,
3201 length_mode: ReflowLengthMode,
3202) -> Option<ParagraphReflow> {
3203 let options = ReflowOptions {
3204 line_length,
3205 length_mode,
3206 ..Default::default()
3207 };
3208 reflow_paragraph_at_line_with_options(content, line_number, &options)
3209}
3210
3211pub fn reflow_paragraph_at_line_with_options(
3222 content: &str,
3223 line_number: usize,
3224 options: &ReflowOptions,
3225) -> Option<ParagraphReflow> {
3226 if line_number == 0 {
3227 return None;
3228 }
3229
3230 let lines: Vec<&str> = content.lines().collect();
3231
3232 if line_number > lines.len() {
3234 return None;
3235 }
3236
3237 let target_idx = line_number - 1; let target_line = lines[target_idx];
3239 let trimmed = target_line.trim();
3240
3241 if let Some(blockquote_reflow) = reflow_blockquote_paragraph_at_line(content, &lines, target_idx, options) {
3244 return Some(blockquote_reflow);
3245 }
3246
3247 if is_paragraph_boundary(trimmed, target_line) {
3249 return None;
3250 }
3251
3252 let mut para_start = target_idx;
3254 while para_start > 0 {
3255 let prev_idx = para_start - 1;
3256 let prev_line = lines[prev_idx];
3257 let prev_trimmed = prev_line.trim();
3258
3259 if is_paragraph_boundary(prev_trimmed, prev_line) {
3261 break;
3262 }
3263
3264 para_start = prev_idx;
3265 }
3266
3267 let mut para_end = target_idx;
3269 while para_end + 1 < lines.len() {
3270 let next_idx = para_end + 1;
3271 let next_line = lines[next_idx];
3272 let next_trimmed = next_line.trim();
3273
3274 if is_paragraph_boundary(next_trimmed, next_line) {
3276 break;
3277 }
3278
3279 para_end = next_idx;
3280 }
3281
3282 let paragraph_lines = &lines[para_start..=para_end];
3284
3285 let mut start_byte = 0;
3287 for line in lines.iter().take(para_start) {
3288 start_byte += line.len() + 1; }
3290
3291 let mut end_byte = start_byte;
3292 for line in paragraph_lines {
3293 end_byte += line.len() + 1; }
3295
3296 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
3299
3300 if !includes_trailing_newline {
3302 end_byte -= 1;
3303 }
3304
3305 let paragraph_text = paragraph_lines.join("\n");
3307
3308 let reflowed = reflow_markdown(¶graph_text, options);
3310
3311 let reflowed_text = if includes_trailing_newline {
3315 if reflowed.ends_with('\n') {
3317 reflowed
3318 } else {
3319 format!("{reflowed}\n")
3320 }
3321 } else {
3322 if reflowed.ends_with('\n') {
3324 reflowed.trim_end_matches('\n').to_string()
3325 } else {
3326 reflowed
3327 }
3328 };
3329
3330 Some(ParagraphReflow {
3331 start_byte,
3332 end_byte,
3333 reflowed_text,
3334 })
3335}
3336
3337#[cfg(test)]
3338mod tests {
3339 use super::*;
3340
3341 #[test]
3342 fn cascade_split_line_handles_a_very_long_line_without_overflowing() {
3343 let words: Vec<String> = (0..4000).map(|i| format!("word{i}")).collect();
3349 let line = words.join(" ");
3350
3351 let out = cascade_split_line(&line, 80, &None, ReflowLengthMode::Chars, false, false, None);
3352
3353 assert!(out.len() > 1, "a very long line should split into many lines");
3354 for segment in &out {
3355 assert!(
3356 display_len(segment, ReflowLengthMode::Chars) <= 80 || !segment.contains(' '),
3357 "each wrapped line should fit the width (or be a single unbreakable token)"
3358 );
3359 }
3360 let rejoined = out.join(" ");
3362 let original_words: Vec<&str> = line.split(' ').collect();
3363 let result_words: Vec<&str> = rejoined.split_whitespace().collect();
3364 assert_eq!(original_words, result_words, "reflow must preserve all words in order");
3365 }
3366
3367 #[test]
3372 fn test_helper_function_text_ends_with_abbreviation() {
3373 let abbreviations = get_abbreviations(&None);
3375
3376 assert!(text_ends_with_abbreviation("Dr.", &abbreviations));
3378 assert!(text_ends_with_abbreviation("word Dr.", &abbreviations));
3379 assert!(text_ends_with_abbreviation("e.g.", &abbreviations));
3380 assert!(text_ends_with_abbreviation("i.e.", &abbreviations));
3381 assert!(text_ends_with_abbreviation("Mr.", &abbreviations));
3382 assert!(text_ends_with_abbreviation("Mrs.", &abbreviations));
3383 assert!(text_ends_with_abbreviation("Ms.", &abbreviations));
3384 assert!(text_ends_with_abbreviation("Prof.", &abbreviations));
3385
3386 assert!(!text_ends_with_abbreviation("etc.", &abbreviations));
3388 assert!(!text_ends_with_abbreviation("paradigms.", &abbreviations));
3389 assert!(!text_ends_with_abbreviation("programs.", &abbreviations));
3390 assert!(!text_ends_with_abbreviation("items.", &abbreviations));
3391 assert!(!text_ends_with_abbreviation("systems.", &abbreviations));
3392 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)); }
3398
3399 #[test]
3400 fn test_is_unordered_list_marker() {
3401 assert!(is_unordered_list_marker("- item"));
3403 assert!(is_unordered_list_marker("* item"));
3404 assert!(is_unordered_list_marker("+ item"));
3405 assert!(is_unordered_list_marker("-")); assert!(is_unordered_list_marker("*"));
3407 assert!(is_unordered_list_marker("+"));
3408
3409 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")); }
3420
3421 #[test]
3422 fn test_is_block_boundary() {
3423 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"));
3445 assert!(!is_block_boundary("*emphasis*")); assert!(!is_block_boundary("[link](url)")); assert!(!is_block_boundary("some words here"));
3448 }
3449
3450 #[test]
3451 fn test_definition_list_boundary_in_single_line_paragraph() {
3452 let options = ReflowOptions {
3455 line_length: 80,
3456 ..Default::default()
3457 };
3458 let input = "Term\n: Definition of the term";
3459 let result = reflow_markdown(input, &options);
3460 assert!(
3462 result.contains(": Definition"),
3463 "Definition list item should not be merged into previous line. Got: {result:?}"
3464 );
3465 let lines: Vec<&str> = result.lines().collect();
3466 assert_eq!(lines.len(), 2, "Should remain two separate lines. Got: {lines:?}");
3467 assert_eq!(lines[0], "Term");
3468 assert_eq!(lines[1], ": Definition of the term");
3469 }
3470
3471 #[test]
3472 fn test_is_paragraph_boundary() {
3473 assert!(is_paragraph_boundary("# Heading", "# Heading"));
3475 assert!(is_paragraph_boundary("- item", "- item"));
3476 assert!(is_paragraph_boundary(":::", ":::"));
3477 assert!(is_paragraph_boundary(": definition", ": definition"));
3478
3479 assert!(is_paragraph_boundary("code", " code"));
3481 assert!(is_paragraph_boundary("code", "\tcode"));
3482
3483 assert!(is_paragraph_boundary("| a | b |", "| a | b |"));
3485 assert!(is_paragraph_boundary("a | b", "a | b")); assert!(!is_paragraph_boundary("regular text", "regular text"));
3489 assert!(!is_paragraph_boundary("text", " text")); }
3491
3492 #[test]
3493 fn test_div_marker_boundary_in_reflow_paragraph_at_line() {
3494 let content = "Some paragraph text here.\n\n::: {.callout-note}\nThis is a callout.\n:::\n";
3497 let result = reflow_paragraph_at_line(content, 3, 80);
3499 assert!(result.is_none(), "Div marker line should not be reflowed");
3500 }
3501}