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 chars: Vec<char> = text.chars().collect();
127 let len = chars.len();
128 let mut mask = vec![false; len];
129 let mut i = 0;
130
131 while i < len {
132 if chars[i] == '`' {
133 let open_start = i;
135 let mut backtick_count = 0;
136 while i < len && chars[i] == '`' {
137 backtick_count += 1;
138 i += 1;
139 }
140
141 let mut found_close = false;
143 let content_start = i;
144 while i < len {
145 if chars[i] == '`' {
146 let close_start = i;
147 let mut close_count = 0;
148 while i < len && chars[i] == '`' {
149 close_count += 1;
150 i += 1;
151 }
152 if close_count == backtick_count {
153 for item in mask.iter_mut().take(close_start).skip(content_start) {
155 *item = true;
156 }
157 for item in mask.iter_mut().take(content_start).skip(open_start) {
159 *item = true;
160 }
161 for item in mask.iter_mut().take(i).skip(close_start) {
162 *item = true;
163 }
164 found_close = true;
165 break;
166 }
167 } else {
168 i += 1;
169 }
170 }
171
172 if !found_close {
173 i = open_start + backtick_count;
175 }
176 } else {
177 i += 1;
178 }
179 }
180
181 mask
182}
183
184fn is_sentence_boundary(
188 text: &str,
189 chars: &[char],
190 pos: usize,
191 abbreviations: &HashSet<String>,
192 require_sentence_capital: bool,
193) -> bool {
194 if pos + 1 >= chars.len() {
195 return false;
196 }
197
198 let c = chars[pos];
199 let next_char = chars[pos + 1];
200
201 if is_cjk_sentence_ending(c) {
204 let mut after_punct_pos = pos + 1;
206 while after_punct_pos < chars.len()
207 && (chars[after_punct_pos] == '*' || chars[after_punct_pos] == '_' || chars[after_punct_pos] == '~')
208 {
209 after_punct_pos += 1;
210 }
211
212 while after_punct_pos < chars.len() && chars[after_punct_pos].is_whitespace() {
214 after_punct_pos += 1;
215 }
216
217 if after_punct_pos >= chars.len() {
219 return false;
220 }
221
222 while after_punct_pos < chars.len()
224 && (chars[after_punct_pos] == '*' || chars[after_punct_pos] == '_' || chars[after_punct_pos] == '~')
225 {
226 after_punct_pos += 1;
227 }
228
229 if after_punct_pos >= chars.len() {
230 return false;
231 }
232
233 return true;
236 }
237
238 if c != '.' && c != '!' && c != '?' {
240 return false;
241 }
242
243 let (_space_pos, after_space_pos) = if next_char == ' ' {
245 (pos + 1, pos + 2)
247 } else if is_closing_quote(next_char) && pos + 2 < chars.len() {
248 if chars[pos + 2] == ' ' {
250 (pos + 2, pos + 3)
252 } else if (chars[pos + 2] == '*' || chars[pos + 2] == '_') && pos + 3 < chars.len() && chars[pos + 3] == ' ' {
253 (pos + 3, pos + 4)
255 } else if (chars[pos + 2] == '*' || chars[pos + 2] == '_')
256 && pos + 4 < chars.len()
257 && chars[pos + 3] == chars[pos + 2]
258 && chars[pos + 4] == ' '
259 {
260 (pos + 4, pos + 5)
262 } else {
263 return false;
264 }
265 } else if (next_char == '*' || next_char == '_') && pos + 2 < chars.len() && chars[pos + 2] == ' ' {
266 (pos + 2, pos + 3)
268 } else if (next_char == '*' || next_char == '_')
269 && pos + 3 < chars.len()
270 && chars[pos + 2] == next_char
271 && chars[pos + 3] == ' '
272 {
273 (pos + 3, pos + 4)
275 } else if next_char == '~' && pos + 3 < chars.len() && chars[pos + 2] == '~' && chars[pos + 3] == ' ' {
276 (pos + 3, pos + 4)
278 } else {
279 return false;
280 };
281
282 let mut next_char_pos = after_space_pos;
284 while next_char_pos < chars.len() && chars[next_char_pos].is_whitespace() {
285 next_char_pos += 1;
286 }
287
288 if next_char_pos >= chars.len() {
290 return false;
291 }
292
293 let mut first_letter_pos = next_char_pos;
295 while first_letter_pos < chars.len()
296 && (chars[first_letter_pos] == '*'
297 || chars[first_letter_pos] == '_'
298 || chars[first_letter_pos] == '~'
299 || is_opening_quote(chars[first_letter_pos]))
300 {
301 first_letter_pos += 1;
302 }
303
304 if first_letter_pos >= chars.len() {
306 return false;
307 }
308
309 let first_char = chars[first_letter_pos];
310
311 if c == '!' || c == '?' {
313 return true;
314 }
315
316 if pos > 0 {
320 let byte_offset: usize = chars[..=pos].iter().map(|ch| ch.len_utf8()).sum();
322 if text_ends_with_abbreviation(&text[..byte_offset], abbreviations) {
323 return false;
324 }
325
326 if chars[pos - 1].is_numeric() && first_char.is_ascii_digit() {
328 return false;
329 }
330
331 if chars[pos - 1].is_ascii_uppercase() && (pos == 1 || (pos >= 2 && chars[pos - 2].is_whitespace())) {
335 return false;
336 }
337 }
338
339 if require_sentence_capital && !first_char.is_uppercase() && !is_cjk_char(first_char) {
342 return false;
343 }
344
345 true
346}
347
348pub fn split_into_sentences(text: &str) -> Vec<String> {
350 split_into_sentences_custom(text, &None)
351}
352
353pub fn split_into_sentences_custom(text: &str, custom_abbreviations: &Option<Vec<String>>) -> Vec<String> {
355 let abbreviations = get_abbreviations(custom_abbreviations);
356 split_into_sentences_with_set(text, &abbreviations, true)
357}
358
359fn split_into_sentences_with_set(
362 text: &str,
363 abbreviations: &HashSet<String>,
364 require_sentence_capital: bool,
365) -> Vec<String> {
366 let in_code = compute_inline_code_mask(text);
368 let char_vec: Vec<char> = text.chars().collect();
371
372 let mut sentences = Vec::new();
373 let mut current_sentence = String::new();
374 let mut chars = text.chars().peekable();
375 let mut pos = 0;
376
377 while let Some(c) = chars.next() {
378 current_sentence.push(c);
379
380 if !in_code[pos] && is_sentence_boundary(text, &char_vec, pos, abbreviations, require_sentence_capital) {
381 while let Some(&next) = chars.peek() {
383 if next == '*' || next == '_' || next == '~' || is_closing_quote(next) {
384 current_sentence.push(chars.next().unwrap());
385 pos += 1;
386 } else {
387 break;
388 }
389 }
390
391 if chars.peek() == Some(&' ') {
393 chars.next();
394 pos += 1;
395 }
396
397 sentences.push(current_sentence.trim().to_string());
398 current_sentence.clear();
399 }
400
401 pos += 1;
402 }
403
404 if !current_sentence.trim().is_empty() {
406 sentences.push(current_sentence.trim().to_string());
407 }
408 sentences
409}
410
411fn is_horizontal_rule(line: &str) -> bool {
413 if line.len() < 3 {
414 return false;
415 }
416
417 let mut chars = line.chars();
420 let Some(first_char) = chars.next() else {
421 return false;
422 };
423 if first_char != '-' && first_char != '_' && first_char != '*' {
424 return false;
425 }
426
427 let mut non_space_count = 1usize; for c in chars {
429 if c == ' ' {
430 continue;
431 }
432 if c != first_char {
433 return false;
434 }
435 non_space_count += 1;
436 }
437 non_space_count >= 3
438}
439
440fn is_numbered_list_item(line: &str) -> bool {
442 let mut chars = line.chars();
443
444 if !chars.next().is_some_and(char::is_numeric) {
446 return false;
447 }
448
449 while let Some(c) = chars.next() {
451 if c == '.' {
452 return chars.next() == Some(' ');
455 }
456 if !c.is_numeric() {
457 return false;
458 }
459 }
460
461 false
462}
463
464fn is_unordered_list_marker(s: &str) -> bool {
466 matches!(s.as_bytes().first(), Some(b'-' | b'*' | b'+'))
467 && !is_horizontal_rule(s)
468 && (s.len() == 1 || s.as_bytes().get(1) == Some(&b' '))
469}
470
471fn is_block_boundary_core(trimmed: &str) -> bool {
474 trimmed.is_empty()
475 || trimmed.starts_with('#')
476 || trimmed.starts_with("```")
477 || trimmed.starts_with("~~~")
478 || trimmed.starts_with('>')
479 || (trimmed.starts_with('[') && trimmed.contains("]:"))
480 || is_horizontal_rule(trimmed)
481 || is_unordered_list_marker(trimmed)
482 || is_numbered_list_item(trimmed)
483 || is_definition_list_item(trimmed)
484 || trimmed.starts_with(":::")
485}
486
487fn is_block_boundary(trimmed: &str) -> bool {
490 is_block_boundary_core(trimmed) || trimmed.starts_with('|')
491}
492
493fn is_paragraph_boundary(trimmed: &str, line: &str) -> bool {
497 is_block_boundary_core(trimmed)
498 || calculate_indentation_width_default(line) >= 4
499 || crate::utils::table_utils::TableUtils::is_potential_table_row(line)
500}
501
502fn has_hard_break(line: &str) -> bool {
508 let line = line.strip_suffix('\r').unwrap_or(line);
509 line.ends_with(" ") || line.ends_with('\\')
510}
511
512fn ends_with_sentence_punct(text: &str) -> bool {
514 text.ends_with('.') || text.ends_with('!') || text.ends_with('?')
515}
516
517fn trim_preserving_hard_break(s: &str) -> String {
523 let s = s.strip_suffix('\r').unwrap_or(s);
525
526 if s.ends_with('\\') {
528 return s.to_string();
530 }
531
532 if s.ends_with(" ") {
534 let content_end = s.trim_end().len();
536 if content_end == 0 {
537 return String::new();
539 }
540 format!("{} ", &s[..content_end])
542 } else {
543 s.trim_end().to_string()
545 }
546}
547
548fn parse_elements(text: &str, options: &ReflowOptions) -> Vec<Element> {
550 parse_markdown_elements_inner(
551 text,
552 options.attr_lists,
553 options.myst_roles,
554 options.defined_references.as_ref(),
555 )
556}
557
558pub fn reflow_line(line: &str, options: &ReflowOptions) -> Vec<String> {
559 if options.sentence_per_line {
561 let elements = parse_elements(line, options);
562 return reflow_elements_sentence_per_line(&elements, &options.abbreviations, options.require_sentence_capital);
563 }
564
565 if options.semantic_line_breaks {
567 let elements = parse_elements(line, options);
568 return reflow_elements_semantic(&elements, options);
569 }
570
571 if options.line_length == 0 || display_len(line, options.length_mode) <= options.line_length {
574 return vec![line.to_string()];
575 }
576
577 let elements = parse_elements(line, options);
579
580 reflow_elements(&elements, options)
582}
583
584#[derive(Debug, Clone)]
586enum Element {
587 Text(String),
589 Link(String),
591 ReferenceLink(String),
593 EmptyReferenceLink(String),
595 ShortcutReference(String),
597 InlineImage(String),
599 ReferenceImage(String),
601 EmptyReferenceImage(String),
603 LinkedImage(String),
605 FootnoteReference(String),
607 Strikethrough {
609 content: String,
610 double: bool,
612 },
613 WikiLink(String),
615 InlineMath(String),
617 DisplayMath(String),
619 EmojiShortcode(String),
621 Autolink(String),
623 HtmlTag(String),
625 HtmlEntity(String),
627 HugoShortcode(String),
629 AttrList(String),
631 MystRole(String),
635 Code(String),
637 Bold {
639 content: String,
640 underscore: bool,
642 },
643 Italic {
645 content: String,
646 underscore: bool,
648 },
649}
650
651impl std::fmt::Display for Element {
652 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
653 match self {
654 Element::Text(s) => write!(f, "{s}"),
655 Element::Link(s) => write!(f, "{s}"),
656 Element::ReferenceLink(s) => write!(f, "{s}"),
657 Element::EmptyReferenceLink(s) => write!(f, "{s}"),
658 Element::ShortcutReference(s) => write!(f, "{s}"),
659 Element::InlineImage(s) => write!(f, "{s}"),
660 Element::ReferenceImage(s) => write!(f, "{s}"),
661 Element::EmptyReferenceImage(s) => write!(f, "{s}"),
662 Element::LinkedImage(s) => write!(f, "{s}"),
663 Element::FootnoteReference(s) => write!(f, "{s}"),
664 Element::Strikethrough { content, double } => {
665 let marker = if *double { "~~" } else { "~" };
666 write!(f, "{marker}{content}{marker}")
667 }
668 Element::WikiLink(s) => write!(f, "[[{s}]]"),
669 Element::InlineMath(s) => write!(f, "${s}$"),
670 Element::DisplayMath(s) => write!(f, "$${s}$$"),
671 Element::EmojiShortcode(s) => write!(f, ":{s}:"),
672 Element::Autolink(s) => write!(f, "{s}"),
673 Element::HtmlTag(s) => write!(f, "{s}"),
674 Element::HtmlEntity(s) => write!(f, "{s}"),
675 Element::HugoShortcode(s) => write!(f, "{s}"),
676 Element::AttrList(s) => write!(f, "{s}"),
677 Element::MystRole(s) => write!(f, "{s}"),
678 Element::Code(s) => write!(f, "`{s}`"),
679 Element::Bold { content, underscore } => {
680 if *underscore {
681 write!(f, "__{content}__")
682 } else {
683 write!(f, "**{content}**")
684 }
685 }
686 Element::Italic { content, underscore } => {
687 if *underscore {
688 write!(f, "_{content}_")
689 } else {
690 write!(f, "*{content}*")
691 }
692 }
693 }
694 }
695}
696
697#[derive(Debug, Clone)]
699struct EmphasisSpan {
700 start: usize,
702 end: usize,
704 content: String,
706 is_strong: bool,
708 is_strikethrough: bool,
710 uses_underscore: bool,
712 strikethrough_double: bool,
715}
716
717fn extract_emphasis_spans(text: &str) -> Vec<EmphasisSpan> {
727 let mut spans = Vec::new();
728 let mut options = Options::empty();
729 options.insert(Options::ENABLE_STRIKETHROUGH);
730
731 let mut emphasis_stack: Vec<(usize, bool)> = Vec::new(); let mut strong_stack: Vec<(usize, bool)> = Vec::new();
734 let mut strikethrough_stack: Vec<usize> = Vec::new();
735
736 let parser = Parser::new_ext(text, options).into_offset_iter();
737
738 for (event, range) in parser {
739 match event {
740 Event::Start(Tag::Emphasis) => {
741 let uses_underscore = text.get(range.start..range.start + 1) == Some("_");
743 emphasis_stack.push((range.start, uses_underscore));
744 }
745 Event::End(TagEnd::Emphasis) => {
746 if let Some((start_byte, uses_underscore)) = emphasis_stack.pop() {
747 let content_start = start_byte + 1;
749 let content_end = range.end - 1;
750 if content_end > content_start
751 && let Some(content) = text.get(content_start..content_end)
752 {
753 spans.push(EmphasisSpan {
754 start: start_byte,
755 end: range.end,
756 content: content.to_string(),
757 is_strong: false,
758 is_strikethrough: false,
759 uses_underscore,
760 strikethrough_double: false,
761 });
762 }
763 }
764 }
765 Event::Start(Tag::Strong) => {
766 let uses_underscore = text.get(range.start..range.start + 2) == Some("__");
768 strong_stack.push((range.start, uses_underscore));
769 }
770 Event::End(TagEnd::Strong) => {
771 if let Some((start_byte, uses_underscore)) = strong_stack.pop() {
772 let content_start = start_byte + 2;
774 let content_end = range.end - 2;
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: true,
783 is_strikethrough: false,
784 uses_underscore,
785 strikethrough_double: false,
786 });
787 }
788 }
789 }
790 Event::Start(Tag::Strikethrough) => {
791 strikethrough_stack.push(range.start);
792 }
793 Event::End(TagEnd::Strikethrough) => {
794 if let Some(start_byte) = strikethrough_stack.pop() {
795 let double = text.get(start_byte..start_byte + 2) == Some("~~");
799 let marker_len = if double { 2 } else { 1 };
800 let content_start = start_byte + marker_len;
801 let content_end = range.end - marker_len;
802 if content_end > content_start
803 && let Some(content) = text.get(content_start..content_end)
804 {
805 spans.push(EmphasisSpan {
806 start: start_byte,
807 end: range.end,
808 content: content.to_string(),
809 is_strong: false,
810 is_strikethrough: true,
811 uses_underscore: false,
812 strikethrough_double: double,
813 });
814 }
815 }
816 }
817 _ => {}
818 }
819 }
820
821 spans.sort_by_key(|s| s.start);
823 spans
824}
825
826#[derive(Debug, Clone)]
827struct LinkSpan {
828 start: usize,
829 end: usize,
830 link_type: Option<LinkType>,
831 is_image: bool,
832 is_footnote: bool,
833}
834
835fn extract_link_spans(text: &str, defined_references: Option<&HashSet<String>>) -> Vec<LinkSpan> {
836 let mut spans = Vec::new();
837 let mut options = Options::empty();
838 options.insert(Options::ENABLE_FOOTNOTES);
839
840 let resolve = move |link: BrokenLink<'_>| -> Option<(CowStr<'_>, CowStr<'_>)> {
857 let atomic = match link.link_type {
862 LinkType::Shortcut | LinkType::ShortcutUnknown => match defined_references {
863 Some(defs) => defs.contains(&normalize_reference_label(link.reference.as_ref())),
864 None => true,
865 },
866 _ => true,
867 };
868 atomic.then_some((CowStr::Borrowed(""), CowStr::Borrowed("")))
869 };
870 let parser = Parser::new_with_broken_link_callback(text, options, Some(resolve)).into_offset_iter();
871 let mut stack = Vec::new();
872
873 for (event, range) in parser {
874 match event {
875 Event::Start(Tag::Link { link_type, .. }) => {
876 stack.push((range.start, Some(link_type), false));
877 }
878 Event::Start(Tag::Image { link_type, .. }) => {
879 stack.push((range.start, Some(link_type), true));
880 }
881 Event::End(TagEnd::Link) => {
882 if let Some((start_byte, link_type, is_image)) = stack.pop()
883 && stack.is_empty()
884 {
885 spans.push(LinkSpan {
886 start: start_byte,
887 end: range.end,
888 link_type,
889 is_image,
890 is_footnote: false,
891 });
892 }
893 }
894 Event::End(TagEnd::Image) => {
895 if let Some((start_byte, link_type, is_image)) = stack.pop()
896 && stack.is_empty()
897 {
898 spans.push(LinkSpan {
899 start: start_byte,
900 end: range.end,
901 link_type,
902 is_image,
903 is_footnote: false,
904 });
905 }
906 }
907 Event::FootnoteReference(_) if stack.is_empty() => {
908 spans.push(LinkSpan {
909 start: range.start,
910 end: range.end,
911 link_type: None,
912 is_image: false,
913 is_footnote: true,
914 });
915 }
916 _ => {}
917 }
918 }
919
920 spans.sort_by_key(|s| s.start);
921 spans
922}
923
924fn myst_role_len_at(text: &str) -> Option<usize> {
932 let bytes = text.as_bytes();
933 if bytes.first() != Some(&b'{') {
934 return None;
935 }
936
937 let mut j = 1;
939 match bytes.get(j) {
940 Some(&b) if b.is_ascii_alphabetic() || b == b'_' => {}
941 _ => return None,
942 }
943 while let Some(&b) = bytes.get(j) {
944 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b':' | b'.') {
945 j += 1;
946 } else {
947 break;
948 }
949 }
950 if bytes.get(j) != Some(&b'}') {
951 return None;
952 }
953 j += 1; if bytes.get(j) != Some(&b'`') {
957 return None;
958 }
959 let backtick_start = j;
960 while bytes.get(j) == Some(&b'`') {
961 j += 1;
962 }
963 let backtick_count = j - backtick_start;
964
965 while j + backtick_count <= bytes.len() {
967 if bytes[j] == b'`' {
968 let close_count = bytes[j..].iter().take_while(|&&b| b == b'`').count();
969 if close_count == backtick_count {
970 return Some(j + close_count);
971 }
972 j += close_count;
973 } else {
974 j += 1;
975 }
976 }
977
978 None
979}
980
981fn parse_markdown_elements_inner(
992 text: &str,
993 attr_lists: bool,
994 myst_roles: bool,
995 defined_references: Option<&HashSet<String>>,
996) -> Vec<Element> {
997 let mut elements = Vec::new();
998 let mut remaining = text;
999
1000 let emphasis_spans = extract_emphasis_spans(text);
1002 let link_spans = extract_link_spans(text, defined_references);
1003
1004 while !remaining.is_empty() {
1005 let current_offset = text.len() - remaining.len();
1007 let mut earliest_match: Option<(usize, usize, &str)> = None;
1010
1011 let mut next_link: Option<&LinkSpan> = None;
1013 for span in &link_spans {
1014 if span.start >= current_offset {
1015 next_link = Some(span);
1016 break;
1017 }
1018 }
1019
1020 if let Some(span) = next_link {
1021 let pos_in_remaining = span.start - current_offset;
1022 if earliest_match
1023 .as_ref()
1024 .is_none_or(|(start, _, _)| pos_in_remaining < *start)
1025 {
1026 let match_end = span.end - current_offset;
1027 earliest_match = Some((pos_in_remaining, match_end, "link_span"));
1028 }
1029 }
1030
1031 if let Some(m) = WIKI_LINK_REGEX.find(remaining)
1033 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
1034 {
1035 earliest_match = Some((m.start(), m.end(), "wiki_link"));
1036 }
1037
1038 if let Some(m) = DISPLAY_MATH_REGEX.find(remaining)
1040 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
1041 {
1042 earliest_match = Some((m.start(), m.end(), "display_math"));
1043 }
1044
1045 if let Ok(Some(m)) = INLINE_MATH_REGEX.find(remaining)
1047 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
1048 {
1049 earliest_match = Some((m.start(), m.end(), "inline_math"));
1050 }
1051
1052 if let Some(m) = EMOJI_SHORTCODE_REGEX.find(remaining)
1054 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
1055 {
1056 earliest_match = Some((m.start(), m.end(), "emoji"));
1057 }
1058
1059 if let Some(m) = HTML_ENTITY_REGEX.find(remaining)
1061 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
1062 {
1063 earliest_match = Some((m.start(), m.end(), "html_entity"));
1064 }
1065
1066 if let Some(m) = HUGO_SHORTCODE_REGEX.find(remaining)
1069 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
1070 {
1071 earliest_match = Some((m.start(), m.end(), "hugo_shortcode"));
1072 }
1073
1074 if let Some(m) = HTML_TAG_PATTERN.find(remaining)
1077 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
1078 {
1079 let matched_text = &remaining[m.start()..m.end()];
1081 let is_url_autolink = matched_text.starts_with("<http://")
1082 || matched_text.starts_with("<https://")
1083 || matched_text.starts_with("<mailto:")
1084 || matched_text.starts_with("<ftp://")
1085 || matched_text.starts_with("<ftps://");
1086
1087 let is_email_autolink = {
1090 let content = matched_text.trim_start_matches('<').trim_end_matches('>');
1091 EMAIL_PATTERN.is_match(content)
1092 };
1093
1094 if is_url_autolink || is_email_autolink {
1095 } else {
1097 earliest_match = Some((m.start(), m.end(), "html_tag"));
1098 }
1099 }
1100
1101 let mut next_special = remaining.len();
1103 let mut special_type = "";
1104 let mut pulldown_emphasis: Option<&EmphasisSpan> = None;
1105 let mut attr_list_len: usize = 0;
1106 let mut myst_role_len: usize = 0;
1107
1108 if let Some(pos) = remaining.find('`')
1110 && pos < next_special
1111 {
1112 next_special = pos;
1113 special_type = "code";
1114 }
1115
1116 if myst_roles
1121 && let Some(pos) = remaining.find('{')
1122 && pos < next_special
1123 && let Some(role_len) = myst_role_len_at(&remaining[pos..])
1124 {
1125 next_special = pos;
1126 special_type = "myst_role";
1127 myst_role_len = role_len;
1128 }
1129
1130 if attr_lists
1132 && let Some(pos) = remaining.find('{')
1133 && pos < next_special
1134 && let Some(m) = ATTR_LIST_PATTERN.find(&remaining[pos..])
1135 && m.start() == 0
1136 {
1137 next_special = pos;
1138 special_type = "attr_list";
1139 attr_list_len = m.end();
1140 }
1141
1142 for span in &emphasis_spans {
1145 if span.start >= current_offset && span.start < current_offset + remaining.len() {
1146 let pos_in_remaining = span.start - current_offset;
1147 if pos_in_remaining < next_special {
1148 next_special = pos_in_remaining;
1149 special_type = "pulldown_emphasis";
1150 pulldown_emphasis = Some(span);
1151 }
1152 break; }
1154 }
1155
1156 let should_process_markdown_link = if let Some((pos, _, _)) = earliest_match {
1158 pos < next_special
1159 } else {
1160 false
1161 };
1162
1163 if should_process_markdown_link {
1164 let (pos, match_end, pattern_type) = earliest_match.unwrap();
1165
1166 if pos > 0 {
1168 elements.push(Element::Text(remaining[..pos].to_string()));
1169 }
1170
1171 match pattern_type {
1173 "link_span" => {
1174 let span = next_link.unwrap();
1175 let raw_text = remaining[pos..match_end].to_string();
1176 if span.is_footnote {
1177 elements.push(Element::FootnoteReference(raw_text));
1178 } else if span.is_image {
1179 match span.link_type {
1180 Some(LinkType::Inline) => elements.push(Element::InlineImage(raw_text)),
1181 Some(LinkType::Reference)
1184 | Some(LinkType::ReferenceUnknown)
1185 | Some(LinkType::Shortcut)
1186 | Some(LinkType::ShortcutUnknown) => elements.push(Element::ReferenceImage(raw_text)),
1187 Some(LinkType::Collapsed) | Some(LinkType::CollapsedUnknown) => {
1188 elements.push(Element::EmptyReferenceImage(raw_text))
1189 }
1190 _ => elements.push(Element::InlineImage(raw_text)),
1191 }
1192 } else {
1193 match span.link_type {
1194 Some(LinkType::Inline) => {
1195 if raw_text.starts_with('[') && raw_text.contains("![") {
1196 elements.push(Element::LinkedImage(raw_text));
1197 } else {
1198 elements.push(Element::Link(raw_text));
1199 }
1200 }
1201 Some(LinkType::Reference) | Some(LinkType::ReferenceUnknown) => {
1204 elements.push(Element::ReferenceLink(raw_text))
1205 }
1206 Some(LinkType::Collapsed) | Some(LinkType::CollapsedUnknown) => {
1207 elements.push(Element::EmptyReferenceLink(raw_text))
1208 }
1209 Some(LinkType::Shortcut) | Some(LinkType::ShortcutUnknown) => {
1210 elements.push(Element::ShortcutReference(raw_text))
1211 }
1212 Some(LinkType::Autolink) | Some(LinkType::Email) => {
1213 elements.push(Element::Autolink(raw_text))
1214 }
1215 _ => elements.push(Element::Link(raw_text)),
1216 }
1217 }
1218 remaining = &remaining[match_end..];
1219 }
1220 "wiki_link" => {
1221 if let Some(caps) = WIKI_LINK_REGEX.captures(remaining) {
1222 let content = caps.get(1).map_or("", |m| m.as_str());
1223 elements.push(Element::WikiLink(content.to_string()));
1224 remaining = &remaining[match_end..];
1225 } else {
1226 elements.push(Element::Text("[[".to_string()));
1227 remaining = &remaining[2..];
1228 }
1229 }
1230 "display_math" => {
1231 if let Some(caps) = DISPLAY_MATH_REGEX.captures(remaining) {
1232 let math = caps.get(1).map_or("", |m| m.as_str());
1233 elements.push(Element::DisplayMath(math.to_string()));
1234 remaining = &remaining[match_end..];
1235 } else {
1236 elements.push(Element::Text("$$".to_string()));
1237 remaining = &remaining[2..];
1238 }
1239 }
1240 "inline_math" => {
1241 if let Ok(Some(caps)) = INLINE_MATH_REGEX.captures(remaining) {
1242 let math = caps.get(1).map_or("", |m| m.as_str());
1243 elements.push(Element::InlineMath(math.to_string()));
1244 remaining = &remaining[match_end..];
1245 } else {
1246 elements.push(Element::Text("$".to_string()));
1247 remaining = &remaining[1..];
1248 }
1249 }
1250 "emoji" => {
1251 if let Some(caps) = EMOJI_SHORTCODE_REGEX.captures(remaining) {
1252 let emoji = caps.get(1).map_or("", |m| m.as_str());
1253 elements.push(Element::EmojiShortcode(emoji.to_string()));
1254 remaining = &remaining[match_end..];
1255 } else {
1256 elements.push(Element::Text(":".to_string()));
1257 remaining = &remaining[1..];
1258 }
1259 }
1260 "html_entity" => {
1261 elements.push(Element::HtmlEntity(remaining[pos..match_end].to_string()));
1263 remaining = &remaining[match_end..];
1264 }
1265 "hugo_shortcode" => {
1266 elements.push(Element::HugoShortcode(remaining[pos..match_end].to_string()));
1268 remaining = &remaining[match_end..];
1269 }
1270 "html_tag" => {
1271 elements.push(Element::HtmlTag(remaining[pos..match_end].to_string()));
1273 remaining = &remaining[match_end..];
1274 }
1275 _ => {
1276 elements.push(Element::Text("[".to_string()));
1278 remaining = &remaining[1..];
1279 }
1280 }
1281 } else {
1282 if next_special > 0 && next_special < remaining.len() {
1286 elements.push(Element::Text(remaining[..next_special].to_string()));
1287 remaining = &remaining[next_special..];
1288 }
1289
1290 match special_type {
1292 "code" => {
1293 if let Some(code_end) = remaining[1..].find('`') {
1295 let code = &remaining[1..=code_end];
1296 elements.push(Element::Code(code.to_string()));
1297 remaining = &remaining[1 + code_end + 1..];
1298 } else {
1299 elements.push(Element::Text(remaining.to_string()));
1301 break;
1302 }
1303 }
1304 "attr_list" => {
1305 elements.push(Element::AttrList(remaining[..attr_list_len].to_string()));
1306 remaining = &remaining[attr_list_len..];
1307 }
1308 "myst_role" => {
1309 elements.push(Element::MystRole(remaining[..myst_role_len].to_string()));
1310 remaining = &remaining[myst_role_len..];
1311 }
1312 "pulldown_emphasis" => {
1313 if let Some(span) = pulldown_emphasis {
1315 let span_len = span.end - span.start;
1316 if span.is_strikethrough {
1317 elements.push(Element::Strikethrough {
1318 content: span.content.clone(),
1319 double: span.strikethrough_double,
1320 });
1321 } else if span.is_strong {
1322 elements.push(Element::Bold {
1323 content: span.content.clone(),
1324 underscore: span.uses_underscore,
1325 });
1326 } else {
1327 elements.push(Element::Italic {
1328 content: span.content.clone(),
1329 underscore: span.uses_underscore,
1330 });
1331 }
1332 remaining = &remaining[span_len..];
1333 } else {
1334 elements.push(Element::Text(remaining[..1].to_string()));
1336 remaining = &remaining[1..];
1337 }
1338 }
1339 _ => {
1340 elements.push(Element::Text(remaining.to_string()));
1342 break;
1343 }
1344 }
1345 }
1346 }
1347
1348 elements
1349}
1350
1351fn should_insert_space_before_join(current: &str) -> bool {
1352 !current.is_empty()
1353 && !current.ends_with(' ')
1354 && !current.ends_with('(')
1355 && !current.ends_with('[')
1356 && !current.ends_with('-')
1357}
1358
1359fn reflow_elements_sentence_per_line(
1361 elements: &[Element],
1362 custom_abbreviations: &Option<Vec<String>>,
1363 require_sentence_capital: bool,
1364) -> Vec<String> {
1365 let abbreviations = get_abbreviations(custom_abbreviations);
1366 let mut lines = Vec::new();
1367 let mut current_line = String::new();
1368
1369 for (idx, element) in elements.iter().enumerate() {
1370 let element_str = format!("{element}");
1371
1372 if let Element::Text(text) = element {
1374 let combined = format!("{current_line}{text}");
1376 let sentences = split_into_sentences_with_set(&combined, &abbreviations, require_sentence_capital);
1378
1379 if sentences.len() > 1 {
1380 for (i, sentence) in sentences.iter().enumerate() {
1382 if i == 0 {
1383 let trimmed = sentence.trim();
1386
1387 if text_ends_with_abbreviation(trimmed, &abbreviations) {
1388 current_line.clone_from(sentence);
1390 } else {
1391 lines.push(sentence.clone());
1393 current_line.clear();
1394 }
1395 } else if i == sentences.len() - 1 {
1396 let trimmed = sentence.trim();
1398 let ends_with_sentence_punct = ends_with_sentence_punct(trimmed);
1399
1400 if ends_with_sentence_punct && !text_ends_with_abbreviation(trimmed, &abbreviations) {
1401 lines.push(sentence.clone());
1403 current_line.clear();
1404 } else {
1405 current_line.clone_from(sentence);
1407 }
1408 } else {
1409 lines.push(sentence.clone());
1411 }
1412 }
1413 } else {
1414 let trimmed = combined.trim();
1416
1417 if trimmed.is_empty() {
1421 continue;
1422 }
1423
1424 let ends_with_sentence_punct = ends_with_sentence_punct(trimmed);
1425
1426 if ends_with_sentence_punct && !text_ends_with_abbreviation(trimmed, &abbreviations) {
1427 lines.push(trimmed.to_string());
1429 current_line.clear();
1430 } else {
1431 current_line = combined;
1433 }
1434 }
1435 } else if let Element::Italic { content, underscore } = element {
1436 let marker = if *underscore { "_" } else { "*" };
1438 handle_emphasis_sentence_split(
1439 content,
1440 marker,
1441 &abbreviations,
1442 require_sentence_capital,
1443 &mut current_line,
1444 &mut lines,
1445 );
1446 } else if let Element::Bold { content, underscore } = element {
1447 let marker = if *underscore { "__" } else { "**" };
1449 handle_emphasis_sentence_split(
1450 content,
1451 marker,
1452 &abbreviations,
1453 require_sentence_capital,
1454 &mut current_line,
1455 &mut lines,
1456 );
1457 } else if let Element::Strikethrough { content, double } = element {
1458 handle_emphasis_sentence_split(
1460 content,
1461 if *double { "~~" } else { "~" },
1462 &abbreviations,
1463 require_sentence_capital,
1464 &mut current_line,
1465 &mut lines,
1466 );
1467 } else {
1468 let is_adjacent = if idx > 0 {
1471 match &elements[idx - 1] {
1472 Element::Text(t) => !t.is_empty() && !t.ends_with(char::is_whitespace),
1473 _ => true,
1474 }
1475 } else {
1476 false
1477 };
1478
1479 if !is_adjacent && should_insert_space_before_join(¤t_line) {
1481 current_line.push(' ');
1482 }
1483 current_line.push_str(&element_str);
1484 }
1485 }
1486
1487 if !current_line.is_empty() {
1489 lines.push(current_line.trim().to_string());
1490 }
1491 lines
1492}
1493
1494fn handle_emphasis_sentence_split(
1496 content: &str,
1497 marker: &str,
1498 abbreviations: &HashSet<String>,
1499 require_sentence_capital: bool,
1500 current_line: &mut String,
1501 lines: &mut Vec<String>,
1502) {
1503 let sentences = split_into_sentences_with_set(content, abbreviations, require_sentence_capital);
1505
1506 if sentences.len() <= 1 {
1507 if should_insert_space_before_join(current_line) {
1509 current_line.push(' ');
1510 }
1511 current_line.push_str(marker);
1512 current_line.push_str(content);
1513 current_line.push_str(marker);
1514
1515 let trimmed = content.trim();
1517 let ends_with_punct = ends_with_sentence_punct(trimmed);
1518 if ends_with_punct && !text_ends_with_abbreviation(trimmed, abbreviations) {
1519 lines.push(current_line.clone());
1520 current_line.clear();
1521 }
1522 } else {
1523 for (i, sentence) in sentences.iter().enumerate() {
1525 let trimmed = sentence.trim();
1526 if trimmed.is_empty() {
1527 continue;
1528 }
1529
1530 if i == 0 {
1531 if should_insert_space_before_join(current_line) {
1533 current_line.push(' ');
1534 }
1535 current_line.push_str(marker);
1536 current_line.push_str(trimmed);
1537 current_line.push_str(marker);
1538
1539 let ends_with_punct = ends_with_sentence_punct(trimmed);
1541 if ends_with_punct && !text_ends_with_abbreviation(trimmed, abbreviations) {
1542 lines.push(current_line.clone());
1543 current_line.clear();
1544 }
1545 } else if i == sentences.len() - 1 {
1546 let ends_with_punct = ends_with_sentence_punct(trimmed);
1548
1549 let mut line = String::new();
1550 line.push_str(marker);
1551 line.push_str(trimmed);
1552 line.push_str(marker);
1553
1554 if ends_with_punct && !text_ends_with_abbreviation(trimmed, abbreviations) {
1555 lines.push(line);
1556 } else {
1557 *current_line = line;
1559 }
1560 } else {
1561 let mut line = String::new();
1563 line.push_str(marker);
1564 line.push_str(trimmed);
1565 line.push_str(marker);
1566 lines.push(line);
1567 }
1568 }
1569 }
1570}
1571
1572const BREAK_WORDS: &[&str] = &[
1576 "and",
1577 "or",
1578 "but",
1579 "nor",
1580 "yet",
1581 "so",
1582 "for",
1583 "which",
1584 "that",
1585 "because",
1586 "when",
1587 "if",
1588 "while",
1589 "where",
1590 "although",
1591 "though",
1592 "unless",
1593 "since",
1594 "after",
1595 "before",
1596 "until",
1597 "as",
1598 "once",
1599 "whether",
1600 "however",
1601 "therefore",
1602 "moreover",
1603 "furthermore",
1604 "nevertheless",
1605 "whereas",
1606];
1607
1608fn is_clause_punctuation(c: char) -> bool {
1610 matches!(c, ',' | ';' | ':' | '\u{2014}') }
1612
1613fn clause_break_allowed_after(chars: &[char], i: usize) -> bool {
1621 if chars[i] == '\u{2014}' {
1622 return true;
1623 }
1624 match chars.get(i + 1) {
1625 None => true,
1626 Some(next) => next.is_whitespace(),
1627 }
1628}
1629
1630fn paren_group_end<'a>(slice: &'a str, element_spans: &[(usize, usize)], offset: usize) -> Option<(usize, &'a str)> {
1644 debug_assert!(slice.starts_with('('));
1645 let mut depth: i32 = 0;
1646 for (local_byte, c) in slice.char_indices() {
1647 let global_byte = offset + local_byte;
1648 if depth > 0 && is_inside_element(global_byte, element_spans) {
1653 continue;
1654 }
1655 match c {
1656 '(' => depth += 1,
1657 ')' => {
1658 depth -= 1;
1659 if depth == 0 {
1660 let end = local_byte + 1;
1661 let inner = &slice[1..local_byte];
1662 return Some((end, inner));
1663 }
1664 }
1665 _ => {}
1666 }
1667 }
1668 None
1669}
1670
1671fn split_at_parenthetical(
1688 text: &str,
1689 line_length: usize,
1690 element_spans: &[(usize, usize)],
1691 length_mode: ReflowLengthMode,
1692) -> Option<(String, String)> {
1693 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
1694
1695 if text.starts_with('(')
1697 && let Some((end_local, inner)) = paren_group_end(text, element_spans, 0)
1698 && inner.contains(' ')
1699 {
1700 let tail = &text[end_local..];
1704 let attached_len = tail
1705 .char_indices()
1706 .take_while(|(_, c)| is_closing_quote(*c) || is_clause_punctuation(*c))
1707 .last()
1708 .map_or(0, |(idx, c)| idx + c.len_utf8());
1709 let first_end = end_local + attached_len;
1710 let rest_start = first_end;
1711 let first = &text[..first_end];
1712 let first_len = display_len(first, length_mode);
1713 if first_len <= line_length {
1716 let rest = text[rest_start..].trim_start();
1717 if !rest.is_empty() {
1718 return Some((first.to_string(), rest.to_string()));
1719 }
1720 }
1721 }
1722
1723 let mut best_open_byte: Option<usize> = None;
1725 let mut pos = 0usize;
1726 while pos < text.len() {
1727 if text.as_bytes()[pos] != b'(' {
1729 let c = text[pos..].chars().next().unwrap();
1730 pos += c.len_utf8();
1731 continue;
1732 }
1733 if is_inside_element(pos, element_spans) {
1735 pos += 1;
1736 continue;
1737 }
1738 if let Some((end_local, inner)) = paren_group_end(&text[pos..], element_spans, pos) {
1739 let first = text[..pos].trim_end();
1740 let first_len = display_len(first, length_mode);
1741 if !first.is_empty()
1742 && first_len >= min_first_len
1743 && first_len <= line_length
1744 && inner.contains(' ')
1745 && best_open_byte.is_none_or(|prev| pos > prev)
1746 {
1747 best_open_byte = Some(pos);
1748 }
1749 pos += end_local;
1750 } else {
1751 pos += 1;
1752 }
1753 }
1754
1755 let open_byte = best_open_byte?;
1756 let first = text[..open_byte].trim_end().to_string();
1757 let rest = text[open_byte..].to_string();
1758 if first.is_empty() || rest.trim().is_empty() {
1759 return None;
1760 }
1761 Some((first, rest))
1762}
1763
1764fn compute_element_spans(elements: &[Element]) -> Vec<(usize, usize)> {
1768 let mut spans = Vec::new();
1769 let mut offset = 0;
1770 for element in elements {
1771 let rendered = format!("{element}");
1772 let len = rendered.len();
1773 if !matches!(element, Element::Text(_)) {
1774 spans.push((offset, offset + len));
1775 }
1776 offset += len;
1777 }
1778 spans
1779}
1780
1781fn is_inside_element(pos: usize, spans: &[(usize, usize)]) -> bool {
1783 spans.iter().any(|(start, end)| pos > *start && pos < *end)
1784}
1785
1786const MIN_SPLIT_RATIO: f64 = 0.3;
1789
1790fn split_at_clause_punctuation(
1794 text: &str,
1795 line_length: usize,
1796 element_spans: &[(usize, usize)],
1797 length_mode: ReflowLengthMode,
1798) -> Option<(String, String)> {
1799 let chars: Vec<char> = text.chars().collect();
1800 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
1801
1802 let mut width_acc = 0;
1804 let mut search_end_char = 0;
1805 for (idx, &c) in chars.iter().enumerate() {
1806 let c_width = display_len(&c.to_string(), length_mode);
1807 if width_acc + c_width > line_length {
1808 break;
1809 }
1810 width_acc += c_width;
1811 search_end_char = idx + 1;
1812 }
1813
1814 let mut paren_depth: i32 = 0;
1821 let mut best_pos = None;
1822 for i in (0..search_end_char).rev() {
1823 let byte_start: usize = chars[..i].iter().map(|c| c.len_utf8()).sum();
1825 let byte_after: usize = byte_start + chars[i].len_utf8();
1827
1828 if !is_inside_element(byte_start, element_spans) {
1829 match chars[i] {
1830 ')' => paren_depth += 1,
1831 '(' => paren_depth = paren_depth.saturating_sub(1),
1832 _ => {}
1833 }
1834 }
1835
1836 if paren_depth == 0
1837 && is_clause_punctuation(chars[i])
1838 && clause_break_allowed_after(&chars, i)
1839 && !is_inside_element(byte_after, element_spans)
1840 {
1841 best_pos = Some(i);
1842 break;
1843 }
1844 }
1845
1846 let pos = best_pos?;
1847
1848 let first: String = chars[..=pos].iter().collect();
1850 let first_display_len = display_len(&first, length_mode);
1851 if first_display_len < min_first_len {
1852 return None;
1853 }
1854
1855 let rest: String = chars[pos + 1..].iter().collect();
1857 let rest = rest.trim_start().to_string();
1858
1859 if rest.is_empty() {
1860 return None;
1861 }
1862
1863 Some((first, rest))
1864}
1865
1866fn paren_depth_map(text: &str, element_spans: &[(usize, usize)]) -> Vec<i32> {
1873 let mut map = vec![0i32; text.len()];
1874 let mut depth = 0i32;
1875 for (byte, c) in text.char_indices() {
1876 if !is_inside_element(byte, element_spans) {
1877 match c {
1878 '(' => depth += 1,
1879 ')' => depth = depth.saturating_sub(1),
1880 _ => {}
1881 }
1882 }
1883 let end = (byte + c.len_utf8()).min(map.len());
1885 for slot in &mut map[byte..end] {
1886 *slot = depth;
1887 }
1888 }
1889 map
1890}
1891
1892fn is_standalone_parenthetical(line: &str) -> bool {
1901 let trimmed = line.trim();
1902 if !trimmed.starts_with('(') {
1903 return false;
1904 }
1905 let core = trimmed.trim_end_matches(|c: char| is_clause_punctuation(c));
1907 if !core.ends_with(')') {
1908 return false;
1909 }
1910 let inner = &core[1..core.len() - 1];
1912 if !inner.contains(' ') {
1913 return false;
1914 }
1915 let mut depth = 0i32;
1917 for c in core.chars() {
1918 match c {
1919 '(' => depth += 1,
1920 ')' => depth -= 1,
1921 _ => {}
1922 }
1923 if depth < 0 {
1924 return false;
1925 }
1926 }
1927 depth == 0
1928}
1929
1930fn split_at_break_word(
1934 text: &str,
1935 line_length: usize,
1936 element_spans: &[(usize, usize)],
1937 length_mode: ReflowLengthMode,
1938) -> Option<(String, String)> {
1939 let lower = text.to_lowercase();
1940 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
1941 let mut best_split: Option<(usize, usize)> = None; let depth_map = paren_depth_map(text, element_spans);
1946
1947 for &word in BREAK_WORDS {
1948 let mut search_start = 0;
1949 while let Some(pos) = lower[search_start..].find(word) {
1950 let abs_pos = search_start + pos;
1951
1952 let preceded_by_space = abs_pos == 0 || text.as_bytes().get(abs_pos - 1) == Some(&b' ');
1954 let followed_by_space = text.as_bytes().get(abs_pos + word.len()) == Some(&b' ');
1955
1956 if preceded_by_space && followed_by_space {
1957 let first_part = text[..abs_pos].trim_end();
1959 let first_part_len = display_len(first_part, length_mode);
1960
1961 let inside_paren = depth_map.get(abs_pos).is_some_and(|&d| d > 0);
1963
1964 if first_part_len >= min_first_len
1965 && first_part_len <= line_length
1966 && !is_inside_element(abs_pos, element_spans)
1967 && !inside_paren
1968 {
1969 if best_split.is_none_or(|(prev_pos, _)| abs_pos > prev_pos) {
1971 best_split = Some((abs_pos, word.len()));
1972 }
1973 }
1974 }
1975
1976 search_start = abs_pos + word.len();
1977 }
1978 }
1979
1980 let (byte_start, _word_len) = best_split?;
1981
1982 let first = text[..byte_start].trim_end().to_string();
1983 let rest = text[byte_start..].to_string();
1984
1985 if first.is_empty() || rest.trim().is_empty() {
1986 return None;
1987 }
1988
1989 Some((first, rest))
1990}
1991
1992fn cascade_split_line(
2003 text: &str,
2004 line_length: usize,
2005 abbreviations: &Option<Vec<String>>,
2006 length_mode: ReflowLengthMode,
2007 attr_lists: bool,
2008 myst_roles: bool,
2009 defined_references: Option<&HashSet<String>>,
2010) -> Vec<String> {
2011 if line_length == 0 || display_len(text, length_mode) <= line_length {
2012 return vec![text.to_string()];
2013 }
2014
2015 let elements = parse_markdown_elements_inner(text, attr_lists, myst_roles, defined_references);
2016 let element_spans = compute_element_spans(&elements);
2017
2018 let rebased_spans = |start: usize| -> Vec<(usize, usize)> {
2022 if start == 0 {
2023 return element_spans.clone();
2024 }
2025 element_spans
2026 .iter()
2027 .filter(|&&(_, end)| end > start)
2028 .map(|&(s, e)| (s.saturating_sub(start), e.saturating_sub(start)))
2029 .collect()
2030 };
2031
2032 let mut result = Vec::new();
2033 let mut start = 0usize;
2034
2035 loop {
2036 let remaining = &text[start..];
2037 if display_len(remaining, length_mode) <= line_length {
2038 result.push(remaining.to_string());
2039 return result;
2040 }
2041
2042 let spans = rebased_spans(start);
2043
2044 let split = split_at_parenthetical(remaining, line_length, &spans, length_mode)
2048 .or_else(|| split_at_clause_punctuation(remaining, line_length, &spans, length_mode))
2049 .or_else(|| split_at_break_word(remaining, line_length, &spans, length_mode));
2050
2051 if let Some((first, rest)) = split {
2052 let consumed = remaining.len().saturating_sub(rest.len());
2053 if consumed == 0 {
2056 break;
2057 }
2058 result.push(first);
2059 start += consumed;
2060 continue;
2061 }
2062
2063 break;
2065 }
2066
2067 let options = ReflowOptions {
2069 line_length,
2070 break_on_sentences: false,
2071 preserve_breaks: false,
2072 sentence_per_line: false,
2073 semantic_line_breaks: false,
2074 abbreviations: abbreviations.clone(),
2075 length_mode,
2076 attr_lists,
2077 myst_roles,
2078 require_sentence_capital: true,
2079 max_list_continuation_indent: None,
2080 defined_references: None,
2083 };
2084 let remaining = &text[start..];
2085 let tail_elements = if start == 0 {
2086 elements
2087 } else {
2088 parse_markdown_elements_inner(remaining, attr_lists, myst_roles, defined_references)
2089 };
2090 result.extend(reflow_elements(&tail_elements, &options));
2091 result
2092}
2093
2094fn reflow_elements_semantic(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
2098 let sentence_lines =
2100 reflow_elements_sentence_per_line(elements, &options.abbreviations, options.require_sentence_capital);
2101
2102 if options.line_length == 0 {
2105 return sentence_lines;
2106 }
2107
2108 let length_mode = options.length_mode;
2109 let mut result = Vec::new();
2110 for line in sentence_lines {
2111 if display_len(&line, length_mode) <= options.line_length {
2112 result.push(line);
2113 } else {
2114 result.extend(cascade_split_line(
2115 &line,
2116 options.line_length,
2117 &options.abbreviations,
2118 length_mode,
2119 options.attr_lists,
2120 options.myst_roles,
2121 options.defined_references.as_ref(),
2122 ));
2123 }
2124 }
2125
2126 let min_line_len = ((options.line_length as f64) * MIN_SPLIT_RATIO) as usize;
2129 let mut merged: Vec<String> = Vec::with_capacity(result.len());
2130 for line in result {
2131 if !merged.is_empty() && display_len(&line, length_mode) < min_line_len && !line.trim().is_empty() {
2132 if is_standalone_parenthetical(&line) {
2135 merged.push(line);
2136 continue;
2137 }
2138
2139 let prev_ends_at_sentence = {
2141 let trimmed = merged.last().unwrap().trim_end();
2142 trimmed
2143 .chars()
2144 .rev()
2145 .find(|c| !matches!(c, '"' | '\'' | '\u{201D}' | '\u{2019}' | ')' | ']'))
2146 .is_some_and(|c| matches!(c, '.' | '!' | '?'))
2147 };
2148
2149 if !prev_ends_at_sentence {
2150 let prev = merged.last_mut().unwrap();
2151 let combined = format!("{prev} {line}");
2152 if display_len(&combined, length_mode) <= options.line_length {
2154 *prev = combined;
2155 continue;
2156 }
2157 }
2158 }
2159 merged.push(line);
2160 }
2161 merged
2162}
2163
2164fn rfind_safe_space(line: &str, element_spans: &[(usize, usize)]) -> Option<usize> {
2172 line.char_indices()
2173 .rev()
2174 .map(|(pos, _)| pos)
2175 .find(|&pos| line.as_bytes()[pos] == b' ' && !element_spans.iter().any(|(s, e)| pos > *s && pos < *e))
2176}
2177
2178fn reflow_elements(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
2180 let mut lines = Vec::new();
2181 let mut current_line = String::new();
2182 let mut current_length = 0;
2183 let mut current_line_element_spans: Vec<(usize, usize)> = Vec::new();
2185 let length_mode = options.length_mode;
2186
2187 for (idx, element) in elements.iter().enumerate() {
2188 let element_str = format!("{element}");
2191 let element_len = display_len(&element_str, length_mode);
2192
2193 let is_adjacent_to_prev = if idx > 0 {
2199 match (&elements[idx - 1], element) {
2200 (Element::Text(t), _) => !t.is_empty() && !t.ends_with(char::is_whitespace),
2201 (_, Element::Text(t)) => !t.is_empty() && !t.starts_with(char::is_whitespace),
2202 _ => true,
2203 }
2204 } else {
2205 false
2206 };
2207
2208 if let Element::Text(text) = element {
2210 let has_leading_space = text.starts_with(char::is_whitespace);
2212 let words: Vec<&str> = text.split_whitespace().collect();
2214
2215 for (i, word) in words.iter().enumerate() {
2216 let word_len = display_len(word, length_mode);
2217 let is_trailing_punct = word
2219 .chars()
2220 .all(|c| matches!(c, ',' | '.' | ':' | ';' | '!' | '?' | ')' | ']' | '}'));
2221
2222 let is_first_adjacent = i == 0 && is_adjacent_to_prev;
2225
2226 if is_first_adjacent {
2227 if current_length + word_len > options.line_length && current_length > 0 {
2229 if let Some(last_space) = rfind_safe_space(¤t_line, ¤t_line_element_spans) {
2232 let before = current_line[..last_space].trim_end().to_string();
2233 let after = current_line[last_space + 1..].to_string();
2234 lines.push(before);
2235 current_line = format!("{after}{word}");
2236 current_length = display_len(¤t_line, length_mode);
2237 current_line_element_spans.clear();
2238 } else {
2239 current_line.push_str(word);
2240 current_length += word_len;
2241 }
2242 } else {
2243 current_line.push_str(word);
2244 current_length += word_len;
2245 }
2246 } else if current_length > 0
2247 && current_length + 1 + word_len > options.line_length
2248 && !is_trailing_punct
2249 {
2250 lines.push(current_line.trim().to_string());
2252 current_line = word.to_string();
2253 current_length = word_len;
2254 current_line_element_spans.clear();
2255 } else {
2256 let add_space = current_length > 0 && if i == 0 { has_leading_space } else { !is_trailing_punct };
2266 if add_space {
2267 current_line.push(' ');
2268 current_length += 1;
2269 }
2270 current_line.push_str(word);
2271 current_length += word_len;
2272 }
2273 }
2274 } else if matches!(
2275 element,
2276 Element::Italic { .. } | Element::Bold { .. } | Element::Strikethrough { .. }
2277 ) && element_len > options.line_length
2278 {
2279 let (content, marker): (&str, &str) = match element {
2283 Element::Italic { content, underscore } => (content.as_str(), if *underscore { "_" } else { "*" }),
2284 Element::Bold { content, underscore } => (content.as_str(), if *underscore { "__" } else { "**" }),
2285 Element::Strikethrough { content, double } => (content.as_str(), if *double { "~~" } else { "~" }),
2286 _ => unreachable!(),
2287 };
2288
2289 let words: Vec<&str> = content.split_whitespace().collect();
2290 let n = words.len();
2291
2292 if n == 0 {
2293 let full = format!("{marker}{marker}");
2295 let full_len = display_len(&full, length_mode);
2296 if !is_adjacent_to_prev && current_length > 0 {
2297 current_line.push(' ');
2298 current_length += 1;
2299 }
2300 current_line.push_str(&full);
2301 current_length += full_len;
2302 } else {
2303 for (i, word) in words.iter().enumerate() {
2304 let is_first = i == 0;
2305 let is_last = i == n - 1;
2306 let word_str: String = match (is_first, is_last) {
2307 (true, true) => format!("{marker}{word}{marker}"),
2308 (true, false) => format!("{marker}{word}"),
2309 (false, true) => format!("{word}{marker}"),
2310 (false, false) => word.to_string(),
2311 };
2312 let word_len = display_len(&word_str, length_mode);
2313
2314 let needs_space = if is_first {
2315 !is_adjacent_to_prev && current_length > 0
2316 } else {
2317 current_length > 0
2318 };
2319
2320 if needs_space && current_length + 1 + word_len > options.line_length {
2321 lines.push(current_line.trim_end().to_string());
2322 current_line = word_str;
2323 current_length = word_len;
2324 current_line_element_spans.clear();
2325 } else {
2326 if needs_space {
2327 current_line.push(' ');
2328 current_length += 1;
2329 }
2330 current_line.push_str(&word_str);
2331 current_length += word_len;
2332 }
2333 }
2334 }
2335 } else {
2336 if is_adjacent_to_prev {
2340 if current_length + element_len > options.line_length {
2342 if let Some(last_space) = rfind_safe_space(¤t_line, ¤t_line_element_spans) {
2345 let before = current_line[..last_space].trim_end().to_string();
2346 let after = current_line[last_space + 1..].to_string();
2347 lines.push(before);
2348 current_line = format!("{after}{element_str}");
2349 current_length = display_len(¤t_line, length_mode);
2350 current_line_element_spans.clear();
2351 let start = after.len();
2353 current_line_element_spans.push((start, start + element_str.len()));
2354 } else {
2355 let start = current_line.len();
2357 current_line.push_str(&element_str);
2358 current_length += element_len;
2359 current_line_element_spans.push((start, current_line.len()));
2360 }
2361 } else {
2362 let start = current_line.len();
2363 current_line.push_str(&element_str);
2364 current_length += element_len;
2365 current_line_element_spans.push((start, current_line.len()));
2366 }
2367 } else if current_length > 0 && current_length + 1 + element_len > options.line_length {
2368 lines.push(current_line.trim().to_string());
2370 current_line.clone_from(&element_str);
2371 current_length = element_len;
2372 current_line_element_spans.clear();
2373 current_line_element_spans.push((0, element_str.len()));
2374 } else {
2375 let ends_with_opener =
2377 current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
2378 if current_length > 0 && !ends_with_opener {
2379 current_line.push(' ');
2380 current_length += 1;
2381 }
2382 let start = current_line.len();
2383 current_line.push_str(&element_str);
2384 current_length += element_len;
2385 current_line_element_spans.push((start, current_line.len()));
2386 }
2387 }
2388 }
2389
2390 if !current_line.is_empty() {
2392 lines.push(current_line.trim_end().to_string());
2393 }
2394
2395 lines
2396}
2397
2398pub fn reflow_markdown(content: &str, options: &ReflowOptions) -> String {
2400 let lines: Vec<&str> = content.lines().collect();
2401 let mut result = Vec::new();
2402 let mut i = 0;
2403
2404 while i < lines.len() {
2405 let line = lines[i];
2406 let trimmed = line.trim();
2407
2408 if trimmed.is_empty() {
2410 result.push(String::new());
2411 i += 1;
2412 continue;
2413 }
2414
2415 if trimmed.starts_with('#') {
2417 result.push(line.to_string());
2418 i += 1;
2419 continue;
2420 }
2421
2422 if trimmed.starts_with(":::") {
2424 result.push(line.to_string());
2425 i += 1;
2426 continue;
2427 }
2428
2429 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
2431 result.push(line.to_string());
2432 i += 1;
2433 while i < lines.len() {
2435 result.push(lines[i].to_string());
2436 if lines[i].trim().starts_with("```") || lines[i].trim().starts_with("~~~") {
2437 i += 1;
2438 break;
2439 }
2440 i += 1;
2441 }
2442 continue;
2443 }
2444
2445 if calculate_indentation_width_default(line) >= 4 {
2447 result.push(line.to_string());
2449 i += 1;
2450 while i < lines.len() {
2451 let next_line = lines[i];
2452 if calculate_indentation_width_default(next_line) >= 4 || next_line.trim().is_empty() {
2454 result.push(next_line.to_string());
2455 i += 1;
2456 } else {
2457 break;
2458 }
2459 }
2460 continue;
2461 }
2462
2463 if trimmed.starts_with('>') {
2465 let gt_pos = line.find('>').expect("'>' must exist since trimmed.starts_with('>')");
2468 let quote_prefix = line[0..=gt_pos].to_string();
2469 let quote_content = &line[quote_prefix.len()..].trim_start();
2470
2471 let reflowed = reflow_line(quote_content, options);
2472 for reflowed_line in &reflowed {
2473 result.push(format!("{quote_prefix} {reflowed_line}"));
2474 }
2475 i += 1;
2476 continue;
2477 }
2478
2479 if is_horizontal_rule(trimmed) {
2481 result.push(line.to_string());
2482 i += 1;
2483 continue;
2484 }
2485
2486 if is_unordered_list_marker(trimmed) || is_numbered_list_item(trimmed) {
2488 let indent = line.len() - line.trim_start().len();
2490 let indent_str = " ".repeat(indent);
2491
2492 let mut marker_end = indent;
2495 let mut content_start = indent;
2496
2497 if trimmed.chars().next().is_some_and(char::is_numeric) {
2498 if let Some(period_pos) = line[indent..].find('.') {
2500 marker_end = indent + period_pos + 1; content_start = marker_end;
2502 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
2506 content_start += 1;
2507 }
2508 }
2509 } else {
2510 marker_end = indent + 1; content_start = marker_end;
2513 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
2517 content_start += 1;
2518 }
2519 }
2520
2521 let min_continuation_indent = content_start;
2523
2524 let rest = &line[content_start..];
2527 if rest.starts_with("[ ] ") || rest.starts_with("[x] ") || rest.starts_with("[X] ") {
2528 marker_end = content_start + 3; content_start += 4; }
2531
2532 let marker = &line[indent..marker_end];
2533
2534 let mut list_content = vec![trim_preserving_hard_break(&line[content_start..])];
2537 i += 1;
2538
2539 while i < lines.len() {
2543 let next_line = lines[i];
2544 let next_trimmed = next_line.trim();
2545
2546 if is_block_boundary(next_trimmed) {
2548 break;
2549 }
2550
2551 let next_indent = next_line.len() - next_line.trim_start().len();
2553 if next_indent >= min_continuation_indent {
2554 let trimmed_start = next_line.trim_start();
2557 list_content.push(trim_preserving_hard_break(trimmed_start));
2558 i += 1;
2559 } else {
2560 break;
2562 }
2563 }
2564
2565 let combined_content = if options.preserve_breaks {
2568 list_content[0].clone()
2569 } else {
2570 let has_hard_breaks = list_content.iter().any(|line| has_hard_break(line));
2572 if has_hard_breaks {
2573 list_content.join("\n")
2575 } else {
2576 list_content.join(" ")
2578 }
2579 };
2580
2581 let trimmed_marker = marker;
2583 let continuation_spaces = if let Some(max_indent) = options.max_list_continuation_indent {
2584 indent + (content_start - indent).min(max_indent)
2587 } else {
2588 content_start
2589 };
2590
2591 let prefix_length = indent + trimmed_marker.len() + 1;
2593
2594 let adjusted_options = ReflowOptions {
2596 line_length: options.line_length.saturating_sub(prefix_length),
2597 ..options.clone()
2598 };
2599
2600 let reflowed = reflow_line(&combined_content, &adjusted_options);
2601 for (j, reflowed_line) in reflowed.iter().enumerate() {
2602 if j == 0 {
2603 result.push(format!("{indent_str}{trimmed_marker} {reflowed_line}"));
2604 } else {
2605 let continuation_indent = " ".repeat(continuation_spaces);
2607 result.push(format!("{continuation_indent}{reflowed_line}"));
2608 }
2609 }
2610 continue;
2611 }
2612
2613 if crate::utils::table_utils::TableUtils::is_potential_table_row(line) {
2615 result.push(line.to_string());
2616 i += 1;
2617 continue;
2618 }
2619
2620 if trimmed.starts_with('[') && line.contains("]:") {
2622 result.push(line.to_string());
2623 i += 1;
2624 continue;
2625 }
2626
2627 if is_definition_list_item(trimmed) {
2629 result.push(line.to_string());
2630 i += 1;
2631 continue;
2632 }
2633
2634 let mut is_single_line_paragraph = true;
2636 if i + 1 < lines.len() {
2637 let next_trimmed = lines[i + 1].trim();
2638 if !is_block_boundary(next_trimmed) {
2640 is_single_line_paragraph = false;
2641 }
2642 }
2643
2644 if is_single_line_paragraph && display_len(line, options.length_mode) <= options.line_length {
2646 result.push(line.to_string());
2647 i += 1;
2648 continue;
2649 }
2650
2651 let mut paragraph_parts = Vec::new();
2653 let mut current_part = vec![line];
2654 i += 1;
2655
2656 if options.preserve_breaks {
2658 let hard_break_type = if line.strip_suffix('\r').unwrap_or(line).ends_with('\\') {
2660 Some("\\")
2661 } else if line.ends_with(" ") {
2662 Some(" ")
2663 } else {
2664 None
2665 };
2666 let reflowed = reflow_line(line, options);
2667
2668 if let Some(break_marker) = hard_break_type {
2670 if !reflowed.is_empty() {
2671 let mut reflowed_with_break = reflowed;
2672 let last_idx = reflowed_with_break.len() - 1;
2673 if !has_hard_break(&reflowed_with_break[last_idx]) {
2674 reflowed_with_break[last_idx].push_str(break_marker);
2675 }
2676 result.extend(reflowed_with_break);
2677 }
2678 } else {
2679 result.extend(reflowed);
2680 }
2681 } else {
2682 while i < lines.len() {
2684 let prev_line = if !current_part.is_empty() {
2685 current_part.last().unwrap()
2686 } else {
2687 ""
2688 };
2689 let next_line = lines[i];
2690 let next_trimmed = next_line.trim();
2691
2692 if is_block_boundary(next_trimmed) {
2694 break;
2695 }
2696
2697 let prev_trimmed = prev_line.trim();
2700 let abbreviations = get_abbreviations(&options.abbreviations);
2701 let ends_with_sentence = (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("._")
2708 || prev_trimmed.ends_with("!_")
2709 || prev_trimmed.ends_with("?_")
2710 || prev_trimmed.ends_with(".\"")
2712 || prev_trimmed.ends_with("!\"")
2713 || prev_trimmed.ends_with("?\"")
2714 || prev_trimmed.ends_with(".'")
2715 || prev_trimmed.ends_with("!'")
2716 || prev_trimmed.ends_with("?'")
2717 || prev_trimmed.ends_with(".\u{201D}")
2718 || prev_trimmed.ends_with("!\u{201D}")
2719 || prev_trimmed.ends_with("?\u{201D}")
2720 || prev_trimmed.ends_with(".\u{2019}")
2721 || prev_trimmed.ends_with("!\u{2019}")
2722 || prev_trimmed.ends_with("?\u{2019}"))
2723 && !text_ends_with_abbreviation(
2724 prev_trimmed.trim_end_matches(['*', '_', '"', '\'', '\u{201D}', '\u{2019}']),
2725 &abbreviations,
2726 );
2727
2728 if has_hard_break(prev_line) || (options.sentence_per_line && ends_with_sentence) {
2729 paragraph_parts.push(current_part.join(" "));
2731 current_part = vec![next_line];
2732 } else {
2733 current_part.push(next_line);
2734 }
2735 i += 1;
2736 }
2737
2738 if !current_part.is_empty() {
2740 if current_part.len() == 1 {
2741 paragraph_parts.push(current_part[0].to_string());
2743 } else {
2744 paragraph_parts.push(current_part.join(" "));
2745 }
2746 }
2747
2748 for (j, part) in paragraph_parts.iter().enumerate() {
2750 let reflowed = reflow_line(part, options);
2751 result.extend(reflowed);
2752
2753 if j < paragraph_parts.len() - 1 && !result.is_empty() && !options.sentence_per_line {
2757 let last_idx = result.len() - 1;
2758 if !has_hard_break(&result[last_idx]) {
2759 result[last_idx].push_str(" ");
2760 }
2761 }
2762 }
2763 }
2764 }
2765
2766 let result_text = result.join("\n");
2768 if content.ends_with('\n') && !result_text.ends_with('\n') {
2769 format!("{result_text}\n")
2770 } else {
2771 result_text
2772 }
2773}
2774
2775#[derive(Debug, Clone)]
2777pub struct ParagraphReflow {
2778 pub start_byte: usize,
2780 pub end_byte: usize,
2782 pub reflowed_text: String,
2784}
2785
2786#[derive(Debug, Clone)]
2792pub struct BlockquoteLineData {
2793 pub(crate) content: String,
2795 pub(crate) is_explicit: bool,
2797 pub(crate) prefix: Option<String>,
2799}
2800
2801impl BlockquoteLineData {
2802 pub fn explicit(content: String, prefix: String) -> Self {
2804 Self {
2805 content,
2806 is_explicit: true,
2807 prefix: Some(prefix),
2808 }
2809 }
2810
2811 pub fn lazy(content: String) -> Self {
2813 Self {
2814 content,
2815 is_explicit: false,
2816 prefix: None,
2817 }
2818 }
2819}
2820
2821#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2823pub enum BlockquoteContinuationStyle {
2824 Explicit,
2825 Lazy,
2826}
2827
2828pub fn blockquote_continuation_style(lines: &[BlockquoteLineData]) -> BlockquoteContinuationStyle {
2836 let mut explicit_count = 0usize;
2837 let mut lazy_count = 0usize;
2838
2839 for line in lines.iter().skip(1) {
2840 if line.is_explicit {
2841 explicit_count += 1;
2842 } else {
2843 lazy_count += 1;
2844 }
2845 }
2846
2847 if explicit_count > 0 && lazy_count == 0 {
2848 BlockquoteContinuationStyle::Explicit
2849 } else if lazy_count > 0 && explicit_count == 0 {
2850 BlockquoteContinuationStyle::Lazy
2851 } else if explicit_count >= lazy_count {
2852 BlockquoteContinuationStyle::Explicit
2853 } else {
2854 BlockquoteContinuationStyle::Lazy
2855 }
2856}
2857
2858pub fn dominant_blockquote_prefix(lines: &[BlockquoteLineData], fallback: &str) -> String {
2863 let mut counts: std::collections::HashMap<String, (usize, usize)> = std::collections::HashMap::new();
2864
2865 for (idx, line) in lines.iter().enumerate() {
2866 let Some(prefix) = line.prefix.as_ref() else {
2867 continue;
2868 };
2869 counts
2870 .entry(prefix.clone())
2871 .and_modify(|entry| entry.0 += 1)
2872 .or_insert((1, idx));
2873 }
2874
2875 counts
2876 .into_iter()
2877 .max_by(|(_, (count_a, first_idx_a)), (_, (count_b, first_idx_b))| {
2878 count_a.cmp(count_b).then_with(|| first_idx_b.cmp(first_idx_a))
2879 })
2880 .map_or_else(|| fallback.to_string(), |(prefix, _)| prefix)
2881}
2882
2883pub(crate) fn should_force_explicit_blockquote_line(content_line: &str) -> bool {
2888 let trimmed = content_line.trim_start();
2889 trimmed.starts_with('>')
2890 || trimmed.starts_with('#')
2891 || trimmed.starts_with("```")
2892 || trimmed.starts_with("~~~")
2893 || is_unordered_list_marker(trimmed)
2894 || is_numbered_list_item(trimmed)
2895 || is_horizontal_rule(trimmed)
2896 || is_definition_list_item(trimmed)
2897 || (trimmed.starts_with('[') && trimmed.contains("]:"))
2898 || trimmed.starts_with(":::")
2899 || (trimmed.starts_with('<')
2900 && !trimmed.starts_with("<http")
2901 && !trimmed.starts_with("<https")
2902 && !trimmed.starts_with("<mailto:"))
2903}
2904
2905pub fn reflow_blockquote_content(
2914 lines: &[BlockquoteLineData],
2915 explicit_prefix: &str,
2916 continuation_style: BlockquoteContinuationStyle,
2917 options: &ReflowOptions,
2918) -> Vec<String> {
2919 let content_strs: Vec<&str> = lines.iter().map(|l| l.content.as_str()).collect();
2920 let segments = split_into_segments_strs(&content_strs);
2921 let mut reflowed_content_lines: Vec<String> = Vec::new();
2922
2923 for segment in segments {
2924 let hard_break_type = segment.last().and_then(|&line| {
2925 let line = line.strip_suffix('\r').unwrap_or(line);
2926 if line.ends_with('\\') {
2927 Some("\\")
2928 } else if line.ends_with(" ") {
2929 Some(" ")
2930 } else {
2931 None
2932 }
2933 });
2934
2935 let pieces: Vec<&str> = segment
2936 .iter()
2937 .map(|&line| {
2938 if let Some(l) = line.strip_suffix('\\') {
2939 l.trim_end()
2940 } else if let Some(l) = line.strip_suffix(" ") {
2941 l.trim_end()
2942 } else {
2943 line.trim_end()
2944 }
2945 })
2946 .collect();
2947
2948 let segment_text = pieces.join(" ");
2949 let segment_text = segment_text.trim();
2950 if segment_text.is_empty() {
2951 continue;
2952 }
2953
2954 let mut reflowed = reflow_line(segment_text, options);
2955 if let Some(break_marker) = hard_break_type
2956 && !reflowed.is_empty()
2957 {
2958 let last_idx = reflowed.len() - 1;
2959 if !has_hard_break(&reflowed[last_idx]) {
2960 reflowed[last_idx].push_str(break_marker);
2961 }
2962 }
2963 reflowed_content_lines.extend(reflowed);
2964 }
2965
2966 let mut styled_lines: Vec<String> = Vec::new();
2967 for (idx, line) in reflowed_content_lines.iter().enumerate() {
2968 let force_explicit = idx == 0
2969 || continuation_style == BlockquoteContinuationStyle::Explicit
2970 || should_force_explicit_blockquote_line(line);
2971 if force_explicit {
2972 styled_lines.push(format!("{explicit_prefix}{line}"));
2973 } else {
2974 styled_lines.push(line.clone());
2975 }
2976 }
2977
2978 styled_lines
2979}
2980
2981fn is_blockquote_content_boundary(content: &str) -> bool {
2982 let trimmed = content.trim();
2983 trimmed.is_empty()
2984 || is_block_boundary(trimmed)
2985 || crate::utils::table_utils::TableUtils::is_potential_table_row(content)
2986 || trimmed.starts_with(":::")
2987 || crate::utils::is_template_directive_only(content)
2988 || is_standalone_attr_list(content)
2989 || is_snippet_block_delimiter(content)
2990}
2991
2992fn split_into_segments_strs<'a>(lines: &[&'a str]) -> Vec<Vec<&'a str>> {
2993 let mut segments = Vec::new();
2994 let mut current = Vec::new();
2995
2996 for &line in lines {
2997 current.push(line);
2998 if has_hard_break(line) {
2999 segments.push(current);
3000 current = Vec::new();
3001 }
3002 }
3003
3004 if !current.is_empty() {
3005 segments.push(current);
3006 }
3007
3008 segments
3009}
3010
3011fn reflow_blockquote_paragraph_at_line(
3012 content: &str,
3013 lines: &[&str],
3014 target_idx: usize,
3015 options: &ReflowOptions,
3016) -> Option<ParagraphReflow> {
3017 let mut anchor_idx = target_idx;
3018 let mut target_level = if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[target_idx]) {
3019 parsed.nesting_level
3020 } else {
3021 let mut found = None;
3022 let mut idx = target_idx;
3023 loop {
3024 if lines[idx].trim().is_empty() {
3025 break;
3026 }
3027 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[idx]) {
3028 found = Some((idx, parsed.nesting_level));
3029 break;
3030 }
3031 if idx == 0 {
3032 break;
3033 }
3034 idx -= 1;
3035 }
3036 let (idx, level) = found?;
3037 anchor_idx = idx;
3038 level
3039 };
3040
3041 let mut para_start = anchor_idx;
3043 while para_start > 0 {
3044 let prev_idx = para_start - 1;
3045 let prev_line = lines[prev_idx];
3046
3047 if prev_line.trim().is_empty() {
3048 break;
3049 }
3050
3051 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(prev_line) {
3052 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
3053 break;
3054 }
3055 para_start = prev_idx;
3056 continue;
3057 }
3058
3059 let prev_lazy = prev_line.trim_start();
3060 if is_blockquote_content_boundary(prev_lazy) {
3061 break;
3062 }
3063 para_start = prev_idx;
3064 }
3065
3066 while para_start < lines.len() {
3068 let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[para_start]) else {
3069 para_start += 1;
3070 continue;
3071 };
3072 target_level = parsed.nesting_level;
3073 break;
3074 }
3075
3076 if para_start >= lines.len() || para_start > target_idx {
3077 return None;
3078 }
3079
3080 let mut collected: Vec<(usize, BlockquoteLineData)> = Vec::new();
3083 let mut idx = para_start;
3084 while idx < lines.len() {
3085 if !collected.is_empty() && has_hard_break(&collected[collected.len() - 1].1.content) {
3086 break;
3087 }
3088
3089 let line = lines[idx];
3090 if line.trim().is_empty() {
3091 break;
3092 }
3093
3094 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(line) {
3095 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
3096 break;
3097 }
3098 collected.push((
3099 idx,
3100 BlockquoteLineData::explicit(trim_preserving_hard_break(parsed.content), parsed.prefix.to_string()),
3101 ));
3102 idx += 1;
3103 continue;
3104 }
3105
3106 let lazy_content = line.trim_start();
3107 if is_blockquote_content_boundary(lazy_content) {
3108 break;
3109 }
3110
3111 collected.push((idx, BlockquoteLineData::lazy(trim_preserving_hard_break(lazy_content))));
3112 idx += 1;
3113 }
3114
3115 if collected.is_empty() {
3116 return None;
3117 }
3118
3119 let para_end = collected[collected.len() - 1].0;
3120 if target_idx < para_start || target_idx > para_end {
3121 return None;
3122 }
3123
3124 let line_data: Vec<BlockquoteLineData> = collected.iter().map(|(_, d)| d.clone()).collect();
3125
3126 let fallback_prefix = line_data
3127 .iter()
3128 .find_map(|d| d.prefix.clone())
3129 .unwrap_or_else(|| "> ".to_string());
3130 let explicit_prefix = dominant_blockquote_prefix(&line_data, &fallback_prefix);
3131 let continuation_style = blockquote_continuation_style(&line_data);
3132
3133 let adjusted_line_length = options
3134 .line_length
3135 .saturating_sub(display_len(&explicit_prefix, options.length_mode))
3136 .max(1);
3137
3138 let adjusted_options = ReflowOptions {
3139 line_length: adjusted_line_length,
3140 ..options.clone()
3141 };
3142
3143 let styled_lines = reflow_blockquote_content(&line_data, &explicit_prefix, continuation_style, &adjusted_options);
3144
3145 if styled_lines.is_empty() {
3146 return None;
3147 }
3148
3149 let mut start_byte = 0;
3151 for line in lines.iter().take(para_start) {
3152 start_byte += line.len() + 1;
3153 }
3154
3155 let mut end_byte = start_byte;
3156 for line in lines.iter().take(para_end + 1).skip(para_start) {
3157 end_byte += line.len() + 1;
3158 }
3159
3160 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
3161 if !includes_trailing_newline {
3162 end_byte -= 1;
3163 }
3164
3165 let reflowed_joined = styled_lines.join("\n");
3166 let reflowed_text = if includes_trailing_newline {
3167 if reflowed_joined.ends_with('\n') {
3168 reflowed_joined
3169 } else {
3170 format!("{reflowed_joined}\n")
3171 }
3172 } else if reflowed_joined.ends_with('\n') {
3173 reflowed_joined.trim_end_matches('\n').to_string()
3174 } else {
3175 reflowed_joined
3176 };
3177
3178 Some(ParagraphReflow {
3179 start_byte,
3180 end_byte,
3181 reflowed_text,
3182 })
3183}
3184
3185pub fn reflow_paragraph_at_line(content: &str, line_number: usize, line_length: usize) -> Option<ParagraphReflow> {
3203 reflow_paragraph_at_line_with_mode(content, line_number, line_length, ReflowLengthMode::default())
3204}
3205
3206pub fn reflow_paragraph_at_line_with_mode(
3208 content: &str,
3209 line_number: usize,
3210 line_length: usize,
3211 length_mode: ReflowLengthMode,
3212) -> Option<ParagraphReflow> {
3213 let options = ReflowOptions {
3214 line_length,
3215 length_mode,
3216 ..Default::default()
3217 };
3218 reflow_paragraph_at_line_with_options(content, line_number, &options)
3219}
3220
3221pub fn reflow_paragraph_at_line_with_options(
3232 content: &str,
3233 line_number: usize,
3234 options: &ReflowOptions,
3235) -> Option<ParagraphReflow> {
3236 if line_number == 0 {
3237 return None;
3238 }
3239
3240 let lines: Vec<&str> = content.lines().collect();
3241
3242 if line_number > lines.len() {
3244 return None;
3245 }
3246
3247 let target_idx = line_number - 1; let target_line = lines[target_idx];
3249 let trimmed = target_line.trim();
3250
3251 if let Some(blockquote_reflow) = reflow_blockquote_paragraph_at_line(content, &lines, target_idx, options) {
3254 return Some(blockquote_reflow);
3255 }
3256
3257 if is_paragraph_boundary(trimmed, target_line) {
3259 return None;
3260 }
3261
3262 let mut para_start = target_idx;
3264 while para_start > 0 {
3265 let prev_idx = para_start - 1;
3266 let prev_line = lines[prev_idx];
3267 let prev_trimmed = prev_line.trim();
3268
3269 if is_paragraph_boundary(prev_trimmed, prev_line) {
3271 break;
3272 }
3273
3274 para_start = prev_idx;
3275 }
3276
3277 let mut para_end = target_idx;
3279 while para_end + 1 < lines.len() {
3280 let next_idx = para_end + 1;
3281 let next_line = lines[next_idx];
3282 let next_trimmed = next_line.trim();
3283
3284 if is_paragraph_boundary(next_trimmed, next_line) {
3286 break;
3287 }
3288
3289 para_end = next_idx;
3290 }
3291
3292 let paragraph_lines = &lines[para_start..=para_end];
3294
3295 let mut start_byte = 0;
3297 for line in lines.iter().take(para_start) {
3298 start_byte += line.len() + 1; }
3300
3301 let mut end_byte = start_byte;
3302 for line in paragraph_lines {
3303 end_byte += line.len() + 1; }
3305
3306 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
3309
3310 if !includes_trailing_newline {
3312 end_byte -= 1;
3313 }
3314
3315 let paragraph_text = paragraph_lines.join("\n");
3317
3318 let reflowed = reflow_markdown(¶graph_text, options);
3320
3321 let reflowed_text = if includes_trailing_newline {
3325 if reflowed.ends_with('\n') {
3327 reflowed
3328 } else {
3329 format!("{reflowed}\n")
3330 }
3331 } else {
3332 if reflowed.ends_with('\n') {
3334 reflowed.trim_end_matches('\n').to_string()
3335 } else {
3336 reflowed
3337 }
3338 };
3339
3340 Some(ParagraphReflow {
3341 start_byte,
3342 end_byte,
3343 reflowed_text,
3344 })
3345}
3346
3347#[cfg(test)]
3348mod tests {
3349 use super::*;
3350
3351 #[test]
3352 fn cascade_split_line_handles_a_very_long_line_without_overflowing() {
3353 let words: Vec<String> = (0..4000).map(|i| format!("word{i}")).collect();
3359 let line = words.join(" ");
3360
3361 let out = cascade_split_line(&line, 80, &None, ReflowLengthMode::Chars, false, false, None);
3362
3363 assert!(out.len() > 1, "a very long line should split into many lines");
3364 for segment in &out {
3365 assert!(
3366 display_len(segment, ReflowLengthMode::Chars) <= 80 || !segment.contains(' '),
3367 "each wrapped line should fit the width (or be a single unbreakable token)"
3368 );
3369 }
3370 let rejoined = out.join(" ");
3372 let original_words: Vec<&str> = line.split(' ').collect();
3373 let result_words: Vec<&str> = rejoined.split_whitespace().collect();
3374 assert_eq!(original_words, result_words, "reflow must preserve all words in order");
3375 }
3376
3377 #[test]
3382 fn test_helper_function_text_ends_with_abbreviation() {
3383 let abbreviations = get_abbreviations(&None);
3385
3386 assert!(text_ends_with_abbreviation("Dr.", &abbreviations));
3388 assert!(text_ends_with_abbreviation("word Dr.", &abbreviations));
3389 assert!(text_ends_with_abbreviation("e.g.", &abbreviations));
3390 assert!(text_ends_with_abbreviation("i.e.", &abbreviations));
3391 assert!(text_ends_with_abbreviation("Mr.", &abbreviations));
3392 assert!(text_ends_with_abbreviation("Mrs.", &abbreviations));
3393 assert!(text_ends_with_abbreviation("Ms.", &abbreviations));
3394 assert!(text_ends_with_abbreviation("Prof.", &abbreviations));
3395
3396 assert!(!text_ends_with_abbreviation("etc.", &abbreviations));
3398 assert!(!text_ends_with_abbreviation("paradigms.", &abbreviations));
3399 assert!(!text_ends_with_abbreviation("programs.", &abbreviations));
3400 assert!(!text_ends_with_abbreviation("items.", &abbreviations));
3401 assert!(!text_ends_with_abbreviation("systems.", &abbreviations));
3402 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)); }
3408
3409 #[test]
3410 fn test_is_unordered_list_marker() {
3411 assert!(is_unordered_list_marker("- item"));
3413 assert!(is_unordered_list_marker("* item"));
3414 assert!(is_unordered_list_marker("+ item"));
3415 assert!(is_unordered_list_marker("-")); assert!(is_unordered_list_marker("*"));
3417 assert!(is_unordered_list_marker("+"));
3418
3419 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")); }
3430
3431 #[test]
3432 fn test_is_block_boundary() {
3433 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"));
3455 assert!(!is_block_boundary("*emphasis*")); assert!(!is_block_boundary("[link](url)")); assert!(!is_block_boundary("some words here"));
3458 }
3459
3460 #[test]
3461 fn test_definition_list_boundary_in_single_line_paragraph() {
3462 let options = ReflowOptions {
3465 line_length: 80,
3466 ..Default::default()
3467 };
3468 let input = "Term\n: Definition of the term";
3469 let result = reflow_markdown(input, &options);
3470 assert!(
3472 result.contains(": Definition"),
3473 "Definition list item should not be merged into previous line. Got: {result:?}"
3474 );
3475 let lines: Vec<&str> = result.lines().collect();
3476 assert_eq!(lines.len(), 2, "Should remain two separate lines. Got: {lines:?}");
3477 assert_eq!(lines[0], "Term");
3478 assert_eq!(lines[1], ": Definition of the term");
3479 }
3480
3481 #[test]
3482 fn test_is_paragraph_boundary() {
3483 assert!(is_paragraph_boundary("# Heading", "# Heading"));
3485 assert!(is_paragraph_boundary("- item", "- item"));
3486 assert!(is_paragraph_boundary(":::", ":::"));
3487 assert!(is_paragraph_boundary(": definition", ": definition"));
3488
3489 assert!(is_paragraph_boundary("code", " code"));
3491 assert!(is_paragraph_boundary("code", "\tcode"));
3492
3493 assert!(is_paragraph_boundary("| a | b |", "| a | b |"));
3495 assert!(is_paragraph_boundary("a | b", "a | b")); assert!(!is_paragraph_boundary("regular text", "regular text"));
3499 assert!(!is_paragraph_boundary("text", " text")); }
3501
3502 #[test]
3503 fn test_div_marker_boundary_in_reflow_paragraph_at_line() {
3504 let content = "Some paragraph text here.\n\n::: {.callout-note}\nThis is a callout.\n:::\n";
3507 let result = reflow_paragraph_at_line(content, 3, 80);
3509 assert!(result.is_none(), "Div marker line should not be reflowed");
3510 }
3511}