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::{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}
79
80impl Default for ReflowOptions {
81 fn default() -> Self {
82 Self {
83 line_length: 80,
84 break_on_sentences: true,
85 preserve_breaks: false,
86 sentence_per_line: false,
87 semantic_line_breaks: false,
88 abbreviations: None,
89 length_mode: ReflowLengthMode::default(),
90 attr_lists: false,
91 myst_roles: false,
92 require_sentence_capital: true,
93 max_list_continuation_indent: None,
94 }
95 }
96}
97
98fn compute_inline_code_mask(text: &str) -> Vec<bool> {
101 let chars: Vec<char> = text.chars().collect();
102 let len = chars.len();
103 let mut mask = vec![false; len];
104 let mut i = 0;
105
106 while i < len {
107 if chars[i] == '`' {
108 let open_start = i;
110 let mut backtick_count = 0;
111 while i < len && chars[i] == '`' {
112 backtick_count += 1;
113 i += 1;
114 }
115
116 let mut found_close = false;
118 let content_start = i;
119 while i < len {
120 if chars[i] == '`' {
121 let close_start = i;
122 let mut close_count = 0;
123 while i < len && chars[i] == '`' {
124 close_count += 1;
125 i += 1;
126 }
127 if close_count == backtick_count {
128 for item in mask.iter_mut().take(close_start).skip(content_start) {
130 *item = true;
131 }
132 for item in mask.iter_mut().take(content_start).skip(open_start) {
134 *item = true;
135 }
136 for item in mask.iter_mut().take(i).skip(close_start) {
137 *item = true;
138 }
139 found_close = true;
140 break;
141 }
142 } else {
143 i += 1;
144 }
145 }
146
147 if !found_close {
148 i = open_start + backtick_count;
150 }
151 } else {
152 i += 1;
153 }
154 }
155
156 mask
157}
158
159fn is_sentence_boundary(
163 text: &str,
164 chars: &[char],
165 pos: usize,
166 abbreviations: &HashSet<String>,
167 require_sentence_capital: bool,
168) -> bool {
169 if pos + 1 >= chars.len() {
170 return false;
171 }
172
173 let c = chars[pos];
174 let next_char = chars[pos + 1];
175
176 if is_cjk_sentence_ending(c) {
179 let mut after_punct_pos = pos + 1;
181 while after_punct_pos < chars.len()
182 && (chars[after_punct_pos] == '*' || chars[after_punct_pos] == '_' || chars[after_punct_pos] == '~')
183 {
184 after_punct_pos += 1;
185 }
186
187 while after_punct_pos < chars.len() && chars[after_punct_pos].is_whitespace() {
189 after_punct_pos += 1;
190 }
191
192 if after_punct_pos >= chars.len() {
194 return false;
195 }
196
197 while after_punct_pos < chars.len()
199 && (chars[after_punct_pos] == '*' || chars[after_punct_pos] == '_' || chars[after_punct_pos] == '~')
200 {
201 after_punct_pos += 1;
202 }
203
204 if after_punct_pos >= chars.len() {
205 return false;
206 }
207
208 return true;
211 }
212
213 if c != '.' && c != '!' && c != '?' {
215 return false;
216 }
217
218 let (_space_pos, after_space_pos) = if next_char == ' ' {
220 (pos + 1, pos + 2)
222 } else if is_closing_quote(next_char) && pos + 2 < chars.len() {
223 if chars[pos + 2] == ' ' {
225 (pos + 2, pos + 3)
227 } else if (chars[pos + 2] == '*' || chars[pos + 2] == '_') && pos + 3 < chars.len() && chars[pos + 3] == ' ' {
228 (pos + 3, pos + 4)
230 } else if (chars[pos + 2] == '*' || chars[pos + 2] == '_')
231 && pos + 4 < chars.len()
232 && chars[pos + 3] == chars[pos + 2]
233 && chars[pos + 4] == ' '
234 {
235 (pos + 4, pos + 5)
237 } else {
238 return false;
239 }
240 } else if (next_char == '*' || next_char == '_') && pos + 2 < chars.len() && chars[pos + 2] == ' ' {
241 (pos + 2, pos + 3)
243 } else if (next_char == '*' || next_char == '_')
244 && pos + 3 < chars.len()
245 && chars[pos + 2] == next_char
246 && chars[pos + 3] == ' '
247 {
248 (pos + 3, pos + 4)
250 } else if next_char == '~' && pos + 3 < chars.len() && chars[pos + 2] == '~' && chars[pos + 3] == ' ' {
251 (pos + 3, pos + 4)
253 } else {
254 return false;
255 };
256
257 let mut next_char_pos = after_space_pos;
259 while next_char_pos < chars.len() && chars[next_char_pos].is_whitespace() {
260 next_char_pos += 1;
261 }
262
263 if next_char_pos >= chars.len() {
265 return false;
266 }
267
268 let mut first_letter_pos = next_char_pos;
270 while first_letter_pos < chars.len()
271 && (chars[first_letter_pos] == '*'
272 || chars[first_letter_pos] == '_'
273 || chars[first_letter_pos] == '~'
274 || is_opening_quote(chars[first_letter_pos]))
275 {
276 first_letter_pos += 1;
277 }
278
279 if first_letter_pos >= chars.len() {
281 return false;
282 }
283
284 let first_char = chars[first_letter_pos];
285
286 if c == '!' || c == '?' {
288 return true;
289 }
290
291 if pos > 0 {
295 let byte_offset: usize = chars[..=pos].iter().map(|ch| ch.len_utf8()).sum();
297 if text_ends_with_abbreviation(&text[..byte_offset], abbreviations) {
298 return false;
299 }
300
301 if chars[pos - 1].is_numeric() && first_char.is_ascii_digit() {
303 return false;
304 }
305
306 if chars[pos - 1].is_ascii_uppercase() && (pos == 1 || (pos >= 2 && chars[pos - 2].is_whitespace())) {
310 return false;
311 }
312 }
313
314 if require_sentence_capital && !first_char.is_uppercase() && !is_cjk_char(first_char) {
317 return false;
318 }
319
320 true
321}
322
323pub fn split_into_sentences(text: &str) -> Vec<String> {
325 split_into_sentences_custom(text, &None)
326}
327
328pub fn split_into_sentences_custom(text: &str, custom_abbreviations: &Option<Vec<String>>) -> Vec<String> {
330 let abbreviations = get_abbreviations(custom_abbreviations);
331 split_into_sentences_with_set(text, &abbreviations, true)
332}
333
334fn split_into_sentences_with_set(
337 text: &str,
338 abbreviations: &HashSet<String>,
339 require_sentence_capital: bool,
340) -> Vec<String> {
341 let in_code = compute_inline_code_mask(text);
343 let char_vec: Vec<char> = text.chars().collect();
346
347 let mut sentences = Vec::new();
348 let mut current_sentence = String::new();
349 let mut chars = text.chars().peekable();
350 let mut pos = 0;
351
352 while let Some(c) = chars.next() {
353 current_sentence.push(c);
354
355 if !in_code[pos] && is_sentence_boundary(text, &char_vec, pos, abbreviations, require_sentence_capital) {
356 while let Some(&next) = chars.peek() {
358 if next == '*' || next == '_' || next == '~' || is_closing_quote(next) {
359 current_sentence.push(chars.next().unwrap());
360 pos += 1;
361 } else {
362 break;
363 }
364 }
365
366 if chars.peek() == Some(&' ') {
368 chars.next();
369 pos += 1;
370 }
371
372 sentences.push(current_sentence.trim().to_string());
373 current_sentence.clear();
374 }
375
376 pos += 1;
377 }
378
379 if !current_sentence.trim().is_empty() {
381 sentences.push(current_sentence.trim().to_string());
382 }
383 sentences
384}
385
386fn is_horizontal_rule(line: &str) -> bool {
388 if line.len() < 3 {
389 return false;
390 }
391
392 let mut chars = line.chars();
395 let Some(first_char) = chars.next() else {
396 return false;
397 };
398 if first_char != '-' && first_char != '_' && first_char != '*' {
399 return false;
400 }
401
402 let mut non_space_count = 1usize; for c in chars {
404 if c == ' ' {
405 continue;
406 }
407 if c != first_char {
408 return false;
409 }
410 non_space_count += 1;
411 }
412 non_space_count >= 3
413}
414
415fn is_numbered_list_item(line: &str) -> bool {
417 let mut chars = line.chars();
418
419 if !chars.next().is_some_and(char::is_numeric) {
421 return false;
422 }
423
424 while let Some(c) = chars.next() {
426 if c == '.' {
427 return chars.next() == Some(' ');
430 }
431 if !c.is_numeric() {
432 return false;
433 }
434 }
435
436 false
437}
438
439fn is_unordered_list_marker(s: &str) -> bool {
441 matches!(s.as_bytes().first(), Some(b'-' | b'*' | b'+'))
442 && !is_horizontal_rule(s)
443 && (s.len() == 1 || s.as_bytes().get(1) == Some(&b' '))
444}
445
446fn is_block_boundary_core(trimmed: &str) -> bool {
449 trimmed.is_empty()
450 || trimmed.starts_with('#')
451 || trimmed.starts_with("```")
452 || trimmed.starts_with("~~~")
453 || trimmed.starts_with('>')
454 || (trimmed.starts_with('[') && trimmed.contains("]:"))
455 || is_horizontal_rule(trimmed)
456 || is_unordered_list_marker(trimmed)
457 || is_numbered_list_item(trimmed)
458 || is_definition_list_item(trimmed)
459 || trimmed.starts_with(":::")
460}
461
462fn is_block_boundary(trimmed: &str) -> bool {
465 is_block_boundary_core(trimmed) || trimmed.starts_with('|')
466}
467
468fn is_paragraph_boundary(trimmed: &str, line: &str) -> bool {
472 is_block_boundary_core(trimmed)
473 || calculate_indentation_width_default(line) >= 4
474 || crate::utils::table_utils::TableUtils::is_potential_table_row(line)
475}
476
477fn has_hard_break(line: &str) -> bool {
483 let line = line.strip_suffix('\r').unwrap_or(line);
484 line.ends_with(" ") || line.ends_with('\\')
485}
486
487fn ends_with_sentence_punct(text: &str) -> bool {
489 text.ends_with('.') || text.ends_with('!') || text.ends_with('?')
490}
491
492fn trim_preserving_hard_break(s: &str) -> String {
498 let s = s.strip_suffix('\r').unwrap_or(s);
500
501 if s.ends_with('\\') {
503 return s.to_string();
505 }
506
507 if s.ends_with(" ") {
509 let content_end = s.trim_end().len();
511 if content_end == 0 {
512 return String::new();
514 }
515 format!("{} ", &s[..content_end])
517 } else {
518 s.trim_end().to_string()
520 }
521}
522
523fn parse_elements(text: &str, options: &ReflowOptions) -> Vec<Element> {
525 parse_markdown_elements_inner(text, options.attr_lists, options.myst_roles)
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 LinkSpan {
798 start: usize,
799 end: usize,
800 link_type: Option<LinkType>,
801 is_image: bool,
802 is_footnote: bool,
803}
804
805fn extract_link_spans(text: &str) -> Vec<LinkSpan> {
806 let mut spans = Vec::new();
807 let mut options = Options::empty();
808 options.insert(Options::ENABLE_FOOTNOTES);
809
810 let parser = Parser::new_ext(text, options).into_offset_iter();
811 let mut stack = Vec::new();
812
813 for (event, range) in parser {
814 match event {
815 Event::Start(Tag::Link { link_type, .. }) => {
816 stack.push((range.start, Some(link_type), false));
817 }
818 Event::Start(Tag::Image { link_type, .. }) => {
819 stack.push((range.start, Some(link_type), true));
820 }
821 Event::End(TagEnd::Link) => {
822 if let Some((start_byte, link_type, is_image)) = stack.pop()
823 && stack.is_empty()
824 {
825 spans.push(LinkSpan {
826 start: start_byte,
827 end: range.end,
828 link_type,
829 is_image,
830 is_footnote: false,
831 });
832 }
833 }
834 Event::End(TagEnd::Image) => {
835 if let Some((start_byte, link_type, is_image)) = stack.pop()
836 && stack.is_empty()
837 {
838 spans.push(LinkSpan {
839 start: start_byte,
840 end: range.end,
841 link_type,
842 is_image,
843 is_footnote: false,
844 });
845 }
846 }
847 Event::FootnoteReference(_) if stack.is_empty() => {
848 spans.push(LinkSpan {
849 start: range.start,
850 end: range.end,
851 link_type: None,
852 is_image: false,
853 is_footnote: true,
854 });
855 }
856 _ => {}
857 }
858 }
859
860 spans.sort_by_key(|s| s.start);
861 spans
862}
863
864fn myst_role_len_at(text: &str) -> Option<usize> {
872 let bytes = text.as_bytes();
873 if bytes.first() != Some(&b'{') {
874 return None;
875 }
876
877 let mut j = 1;
879 match bytes.get(j) {
880 Some(&b) if b.is_ascii_alphabetic() || b == b'_' => {}
881 _ => return None,
882 }
883 while let Some(&b) = bytes.get(j) {
884 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b':' | b'.') {
885 j += 1;
886 } else {
887 break;
888 }
889 }
890 if bytes.get(j) != Some(&b'}') {
891 return None;
892 }
893 j += 1; if bytes.get(j) != Some(&b'`') {
897 return None;
898 }
899 let backtick_start = j;
900 while bytes.get(j) == Some(&b'`') {
901 j += 1;
902 }
903 let backtick_count = j - backtick_start;
904
905 while j + backtick_count <= bytes.len() {
907 if bytes[j] == b'`' {
908 let close_count = bytes[j..].iter().take_while(|&&b| b == b'`').count();
909 if close_count == backtick_count {
910 return Some(j + close_count);
911 }
912 j += close_count;
913 } else {
914 j += 1;
915 }
916 }
917
918 None
919}
920
921fn parse_markdown_elements_inner(text: &str, attr_lists: bool, myst_roles: bool) -> Vec<Element> {
932 let mut elements = Vec::new();
933 let mut remaining = text;
934
935 let emphasis_spans = extract_emphasis_spans(text);
937 let link_spans = extract_link_spans(text);
938
939 while !remaining.is_empty() {
940 let current_offset = text.len() - remaining.len();
942 let mut earliest_match: Option<(usize, usize, &str)> = None;
945
946 let mut next_link: Option<&LinkSpan> = None;
948 for span in &link_spans {
949 if span.start >= current_offset {
950 next_link = Some(span);
951 break;
952 }
953 }
954
955 if let Some(span) = next_link {
956 let pos_in_remaining = span.start - current_offset;
957 if earliest_match
958 .as_ref()
959 .is_none_or(|(start, _, _)| pos_in_remaining < *start)
960 {
961 let match_end = span.end - current_offset;
962 earliest_match = Some((pos_in_remaining, match_end, "link_span"));
963 }
964 }
965
966 if let Some(m) = WIKI_LINK_REGEX.find(remaining)
968 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
969 {
970 earliest_match = Some((m.start(), m.end(), "wiki_link"));
971 }
972
973 if let Some(m) = DISPLAY_MATH_REGEX.find(remaining)
975 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
976 {
977 earliest_match = Some((m.start(), m.end(), "display_math"));
978 }
979
980 if let Ok(Some(m)) = INLINE_MATH_REGEX.find(remaining)
982 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
983 {
984 earliest_match = Some((m.start(), m.end(), "inline_math"));
985 }
986
987 if let Some(m) = EMOJI_SHORTCODE_REGEX.find(remaining)
989 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
990 {
991 earliest_match = Some((m.start(), m.end(), "emoji"));
992 }
993
994 if let Some(m) = HTML_ENTITY_REGEX.find(remaining)
996 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
997 {
998 earliest_match = Some((m.start(), m.end(), "html_entity"));
999 }
1000
1001 if let Some(m) = HUGO_SHORTCODE_REGEX.find(remaining)
1004 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
1005 {
1006 earliest_match = Some((m.start(), m.end(), "hugo_shortcode"));
1007 }
1008
1009 if let Some(m) = HTML_TAG_PATTERN.find(remaining)
1012 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
1013 {
1014 let matched_text = &remaining[m.start()..m.end()];
1016 let is_url_autolink = matched_text.starts_with("<http://")
1017 || matched_text.starts_with("<https://")
1018 || matched_text.starts_with("<mailto:")
1019 || matched_text.starts_with("<ftp://")
1020 || matched_text.starts_with("<ftps://");
1021
1022 let is_email_autolink = {
1025 let content = matched_text.trim_start_matches('<').trim_end_matches('>');
1026 EMAIL_PATTERN.is_match(content)
1027 };
1028
1029 if is_url_autolink || is_email_autolink {
1030 } else {
1032 earliest_match = Some((m.start(), m.end(), "html_tag"));
1033 }
1034 }
1035
1036 let mut next_special = remaining.len();
1038 let mut special_type = "";
1039 let mut pulldown_emphasis: Option<&EmphasisSpan> = None;
1040 let mut attr_list_len: usize = 0;
1041 let mut myst_role_len: usize = 0;
1042
1043 if let Some(pos) = remaining.find('`')
1045 && pos < next_special
1046 {
1047 next_special = pos;
1048 special_type = "code";
1049 }
1050
1051 if myst_roles
1056 && let Some(pos) = remaining.find('{')
1057 && pos < next_special
1058 && let Some(role_len) = myst_role_len_at(&remaining[pos..])
1059 {
1060 next_special = pos;
1061 special_type = "myst_role";
1062 myst_role_len = role_len;
1063 }
1064
1065 if attr_lists
1067 && let Some(pos) = remaining.find('{')
1068 && pos < next_special
1069 && let Some(m) = ATTR_LIST_PATTERN.find(&remaining[pos..])
1070 && m.start() == 0
1071 {
1072 next_special = pos;
1073 special_type = "attr_list";
1074 attr_list_len = m.end();
1075 }
1076
1077 for span in &emphasis_spans {
1080 if span.start >= current_offset && span.start < current_offset + remaining.len() {
1081 let pos_in_remaining = span.start - current_offset;
1082 if pos_in_remaining < next_special {
1083 next_special = pos_in_remaining;
1084 special_type = "pulldown_emphasis";
1085 pulldown_emphasis = Some(span);
1086 }
1087 break; }
1089 }
1090
1091 let should_process_markdown_link = if let Some((pos, _, _)) = earliest_match {
1093 pos < next_special
1094 } else {
1095 false
1096 };
1097
1098 if should_process_markdown_link {
1099 let (pos, match_end, pattern_type) = earliest_match.unwrap();
1100
1101 if pos > 0 {
1103 elements.push(Element::Text(remaining[..pos].to_string()));
1104 }
1105
1106 match pattern_type {
1108 "link_span" => {
1109 let span = next_link.unwrap();
1110 let raw_text = remaining[pos..match_end].to_string();
1111 if span.is_footnote {
1112 elements.push(Element::FootnoteReference(raw_text));
1113 } else if span.is_image {
1114 match span.link_type {
1115 Some(LinkType::Inline) => elements.push(Element::InlineImage(raw_text)),
1116 Some(LinkType::Reference) | Some(LinkType::Shortcut) => {
1117 elements.push(Element::ReferenceImage(raw_text))
1118 }
1119 Some(LinkType::Collapsed) => elements.push(Element::EmptyReferenceImage(raw_text)),
1120 _ => elements.push(Element::InlineImage(raw_text)),
1121 }
1122 } else {
1123 match span.link_type {
1124 Some(LinkType::Inline) => {
1125 if raw_text.starts_with('[') && raw_text.contains("![") {
1126 elements.push(Element::LinkedImage(raw_text));
1127 } else {
1128 elements.push(Element::Link(raw_text));
1129 }
1130 }
1131 Some(LinkType::Reference) => elements.push(Element::ReferenceLink(raw_text)),
1132 Some(LinkType::Collapsed) => elements.push(Element::EmptyReferenceLink(raw_text)),
1133 Some(LinkType::Shortcut) => elements.push(Element::ShortcutReference(raw_text)),
1134 Some(LinkType::Autolink) | Some(LinkType::Email) => {
1135 elements.push(Element::Autolink(raw_text))
1136 }
1137 _ => elements.push(Element::Link(raw_text)),
1138 }
1139 }
1140 remaining = &remaining[match_end..];
1141 }
1142 "wiki_link" => {
1143 if let Some(caps) = WIKI_LINK_REGEX.captures(remaining) {
1144 let content = caps.get(1).map_or("", |m| m.as_str());
1145 elements.push(Element::WikiLink(content.to_string()));
1146 remaining = &remaining[match_end..];
1147 } else {
1148 elements.push(Element::Text("[[".to_string()));
1149 remaining = &remaining[2..];
1150 }
1151 }
1152 "display_math" => {
1153 if let Some(caps) = DISPLAY_MATH_REGEX.captures(remaining) {
1154 let math = caps.get(1).map_or("", |m| m.as_str());
1155 elements.push(Element::DisplayMath(math.to_string()));
1156 remaining = &remaining[match_end..];
1157 } else {
1158 elements.push(Element::Text("$$".to_string()));
1159 remaining = &remaining[2..];
1160 }
1161 }
1162 "inline_math" => {
1163 if let Ok(Some(caps)) = INLINE_MATH_REGEX.captures(remaining) {
1164 let math = caps.get(1).map_or("", |m| m.as_str());
1165 elements.push(Element::InlineMath(math.to_string()));
1166 remaining = &remaining[match_end..];
1167 } else {
1168 elements.push(Element::Text("$".to_string()));
1169 remaining = &remaining[1..];
1170 }
1171 }
1172 "emoji" => {
1173 if let Some(caps) = EMOJI_SHORTCODE_REGEX.captures(remaining) {
1174 let emoji = caps.get(1).map_or("", |m| m.as_str());
1175 elements.push(Element::EmojiShortcode(emoji.to_string()));
1176 remaining = &remaining[match_end..];
1177 } else {
1178 elements.push(Element::Text(":".to_string()));
1179 remaining = &remaining[1..];
1180 }
1181 }
1182 "html_entity" => {
1183 elements.push(Element::HtmlEntity(remaining[pos..match_end].to_string()));
1185 remaining = &remaining[match_end..];
1186 }
1187 "hugo_shortcode" => {
1188 elements.push(Element::HugoShortcode(remaining[pos..match_end].to_string()));
1190 remaining = &remaining[match_end..];
1191 }
1192 "html_tag" => {
1193 elements.push(Element::HtmlTag(remaining[pos..match_end].to_string()));
1195 remaining = &remaining[match_end..];
1196 }
1197 _ => {
1198 elements.push(Element::Text("[".to_string()));
1200 remaining = &remaining[1..];
1201 }
1202 }
1203 } else {
1204 if next_special > 0 && next_special < remaining.len() {
1208 elements.push(Element::Text(remaining[..next_special].to_string()));
1209 remaining = &remaining[next_special..];
1210 }
1211
1212 match special_type {
1214 "code" => {
1215 if let Some(code_end) = remaining[1..].find('`') {
1217 let code = &remaining[1..=code_end];
1218 elements.push(Element::Code(code.to_string()));
1219 remaining = &remaining[1 + code_end + 1..];
1220 } else {
1221 elements.push(Element::Text(remaining.to_string()));
1223 break;
1224 }
1225 }
1226 "attr_list" => {
1227 elements.push(Element::AttrList(remaining[..attr_list_len].to_string()));
1228 remaining = &remaining[attr_list_len..];
1229 }
1230 "myst_role" => {
1231 elements.push(Element::MystRole(remaining[..myst_role_len].to_string()));
1232 remaining = &remaining[myst_role_len..];
1233 }
1234 "pulldown_emphasis" => {
1235 if let Some(span) = pulldown_emphasis {
1237 let span_len = span.end - span.start;
1238 if span.is_strikethrough {
1239 elements.push(Element::Strikethrough {
1240 content: span.content.clone(),
1241 double: span.strikethrough_double,
1242 });
1243 } else if span.is_strong {
1244 elements.push(Element::Bold {
1245 content: span.content.clone(),
1246 underscore: span.uses_underscore,
1247 });
1248 } else {
1249 elements.push(Element::Italic {
1250 content: span.content.clone(),
1251 underscore: span.uses_underscore,
1252 });
1253 }
1254 remaining = &remaining[span_len..];
1255 } else {
1256 elements.push(Element::Text(remaining[..1].to_string()));
1258 remaining = &remaining[1..];
1259 }
1260 }
1261 _ => {
1262 elements.push(Element::Text(remaining.to_string()));
1264 break;
1265 }
1266 }
1267 }
1268 }
1269
1270 elements
1271}
1272
1273fn should_insert_space_before_join(current: &str) -> bool {
1274 !current.is_empty()
1275 && !current.ends_with(' ')
1276 && !current.ends_with('(')
1277 && !current.ends_with('[')
1278 && !current.ends_with('-')
1279}
1280
1281fn reflow_elements_sentence_per_line(
1283 elements: &[Element],
1284 custom_abbreviations: &Option<Vec<String>>,
1285 require_sentence_capital: bool,
1286) -> Vec<String> {
1287 let abbreviations = get_abbreviations(custom_abbreviations);
1288 let mut lines = Vec::new();
1289 let mut current_line = String::new();
1290
1291 for (idx, element) in elements.iter().enumerate() {
1292 let element_str = format!("{element}");
1293
1294 if let Element::Text(text) = element {
1296 let combined = format!("{current_line}{text}");
1298 let sentences = split_into_sentences_with_set(&combined, &abbreviations, require_sentence_capital);
1300
1301 if sentences.len() > 1 {
1302 for (i, sentence) in sentences.iter().enumerate() {
1304 if i == 0 {
1305 let trimmed = sentence.trim();
1308
1309 if text_ends_with_abbreviation(trimmed, &abbreviations) {
1310 current_line.clone_from(sentence);
1312 } else {
1313 lines.push(sentence.clone());
1315 current_line.clear();
1316 }
1317 } else if i == sentences.len() - 1 {
1318 let trimmed = sentence.trim();
1320 let ends_with_sentence_punct = ends_with_sentence_punct(trimmed);
1321
1322 if ends_with_sentence_punct && !text_ends_with_abbreviation(trimmed, &abbreviations) {
1323 lines.push(sentence.clone());
1325 current_line.clear();
1326 } else {
1327 current_line.clone_from(sentence);
1329 }
1330 } else {
1331 lines.push(sentence.clone());
1333 }
1334 }
1335 } else {
1336 let trimmed = combined.trim();
1338
1339 if trimmed.is_empty() {
1343 continue;
1344 }
1345
1346 let ends_with_sentence_punct = ends_with_sentence_punct(trimmed);
1347
1348 if ends_with_sentence_punct && !text_ends_with_abbreviation(trimmed, &abbreviations) {
1349 lines.push(trimmed.to_string());
1351 current_line.clear();
1352 } else {
1353 current_line = combined;
1355 }
1356 }
1357 } else if let Element::Italic { content, underscore } = element {
1358 let marker = if *underscore { "_" } else { "*" };
1360 handle_emphasis_sentence_split(
1361 content,
1362 marker,
1363 &abbreviations,
1364 require_sentence_capital,
1365 &mut current_line,
1366 &mut lines,
1367 );
1368 } else if let Element::Bold { content, underscore } = element {
1369 let marker = if *underscore { "__" } else { "**" };
1371 handle_emphasis_sentence_split(
1372 content,
1373 marker,
1374 &abbreviations,
1375 require_sentence_capital,
1376 &mut current_line,
1377 &mut lines,
1378 );
1379 } else if let Element::Strikethrough { content, double } = element {
1380 handle_emphasis_sentence_split(
1382 content,
1383 if *double { "~~" } else { "~" },
1384 &abbreviations,
1385 require_sentence_capital,
1386 &mut current_line,
1387 &mut lines,
1388 );
1389 } else {
1390 let is_adjacent = if idx > 0 {
1393 match &elements[idx - 1] {
1394 Element::Text(t) => !t.is_empty() && !t.ends_with(char::is_whitespace),
1395 _ => true,
1396 }
1397 } else {
1398 false
1399 };
1400
1401 if !is_adjacent && should_insert_space_before_join(¤t_line) {
1403 current_line.push(' ');
1404 }
1405 current_line.push_str(&element_str);
1406 }
1407 }
1408
1409 if !current_line.is_empty() {
1411 lines.push(current_line.trim().to_string());
1412 }
1413 lines
1414}
1415
1416fn handle_emphasis_sentence_split(
1418 content: &str,
1419 marker: &str,
1420 abbreviations: &HashSet<String>,
1421 require_sentence_capital: bool,
1422 current_line: &mut String,
1423 lines: &mut Vec<String>,
1424) {
1425 let sentences = split_into_sentences_with_set(content, abbreviations, require_sentence_capital);
1427
1428 if sentences.len() <= 1 {
1429 if should_insert_space_before_join(current_line) {
1431 current_line.push(' ');
1432 }
1433 current_line.push_str(marker);
1434 current_line.push_str(content);
1435 current_line.push_str(marker);
1436
1437 let trimmed = content.trim();
1439 let ends_with_punct = ends_with_sentence_punct(trimmed);
1440 if ends_with_punct && !text_ends_with_abbreviation(trimmed, abbreviations) {
1441 lines.push(current_line.clone());
1442 current_line.clear();
1443 }
1444 } else {
1445 for (i, sentence) in sentences.iter().enumerate() {
1447 let trimmed = sentence.trim();
1448 if trimmed.is_empty() {
1449 continue;
1450 }
1451
1452 if i == 0 {
1453 if should_insert_space_before_join(current_line) {
1455 current_line.push(' ');
1456 }
1457 current_line.push_str(marker);
1458 current_line.push_str(trimmed);
1459 current_line.push_str(marker);
1460
1461 let ends_with_punct = ends_with_sentence_punct(trimmed);
1463 if ends_with_punct && !text_ends_with_abbreviation(trimmed, abbreviations) {
1464 lines.push(current_line.clone());
1465 current_line.clear();
1466 }
1467 } else if i == sentences.len() - 1 {
1468 let ends_with_punct = ends_with_sentence_punct(trimmed);
1470
1471 let mut line = String::new();
1472 line.push_str(marker);
1473 line.push_str(trimmed);
1474 line.push_str(marker);
1475
1476 if ends_with_punct && !text_ends_with_abbreviation(trimmed, abbreviations) {
1477 lines.push(line);
1478 } else {
1479 *current_line = line;
1481 }
1482 } else {
1483 let mut line = String::new();
1485 line.push_str(marker);
1486 line.push_str(trimmed);
1487 line.push_str(marker);
1488 lines.push(line);
1489 }
1490 }
1491 }
1492}
1493
1494const BREAK_WORDS: &[&str] = &[
1498 "and",
1499 "or",
1500 "but",
1501 "nor",
1502 "yet",
1503 "so",
1504 "for",
1505 "which",
1506 "that",
1507 "because",
1508 "when",
1509 "if",
1510 "while",
1511 "where",
1512 "although",
1513 "though",
1514 "unless",
1515 "since",
1516 "after",
1517 "before",
1518 "until",
1519 "as",
1520 "once",
1521 "whether",
1522 "however",
1523 "therefore",
1524 "moreover",
1525 "furthermore",
1526 "nevertheless",
1527 "whereas",
1528];
1529
1530fn is_clause_punctuation(c: char) -> bool {
1532 matches!(c, ',' | ';' | ':' | '\u{2014}') }
1534
1535fn clause_break_allowed_after(chars: &[char], i: usize) -> bool {
1543 if chars[i] == '\u{2014}' {
1544 return true;
1545 }
1546 match chars.get(i + 1) {
1547 None => true,
1548 Some(next) => next.is_whitespace(),
1549 }
1550}
1551
1552fn paren_group_end<'a>(slice: &'a str, element_spans: &[(usize, usize)], offset: usize) -> Option<(usize, &'a str)> {
1566 debug_assert!(slice.starts_with('('));
1567 let mut depth: i32 = 0;
1568 for (local_byte, c) in slice.char_indices() {
1569 let global_byte = offset + local_byte;
1570 if depth > 0 && is_inside_element(global_byte, element_spans) {
1575 continue;
1576 }
1577 match c {
1578 '(' => depth += 1,
1579 ')' => {
1580 depth -= 1;
1581 if depth == 0 {
1582 let end = local_byte + 1;
1583 let inner = &slice[1..local_byte];
1584 return Some((end, inner));
1585 }
1586 }
1587 _ => {}
1588 }
1589 }
1590 None
1591}
1592
1593fn split_at_parenthetical(
1610 text: &str,
1611 line_length: usize,
1612 element_spans: &[(usize, usize)],
1613 length_mode: ReflowLengthMode,
1614) -> Option<(String, String)> {
1615 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
1616
1617 if text.starts_with('(')
1619 && let Some((end_local, inner)) = paren_group_end(text, element_spans, 0)
1620 && inner.contains(' ')
1621 {
1622 let tail = &text[end_local..];
1626 let attached_len = tail
1627 .char_indices()
1628 .take_while(|(_, c)| is_closing_quote(*c) || is_clause_punctuation(*c))
1629 .last()
1630 .map_or(0, |(idx, c)| idx + c.len_utf8());
1631 let first_end = end_local + attached_len;
1632 let rest_start = first_end;
1633 let first = &text[..first_end];
1634 let first_len = display_len(first, length_mode);
1635 if first_len <= line_length {
1638 let rest = text[rest_start..].trim_start();
1639 if !rest.is_empty() {
1640 return Some((first.to_string(), rest.to_string()));
1641 }
1642 }
1643 }
1644
1645 let mut best_open_byte: Option<usize> = None;
1647 let mut pos = 0usize;
1648 while pos < text.len() {
1649 if text.as_bytes()[pos] != b'(' {
1651 let c = text[pos..].chars().next().unwrap();
1652 pos += c.len_utf8();
1653 continue;
1654 }
1655 if is_inside_element(pos, element_spans) {
1657 pos += 1;
1658 continue;
1659 }
1660 if let Some((end_local, inner)) = paren_group_end(&text[pos..], element_spans, pos) {
1661 let first = text[..pos].trim_end();
1662 let first_len = display_len(first, length_mode);
1663 if !first.is_empty()
1664 && first_len >= min_first_len
1665 && first_len <= line_length
1666 && inner.contains(' ')
1667 && best_open_byte.is_none_or(|prev| pos > prev)
1668 {
1669 best_open_byte = Some(pos);
1670 }
1671 pos += end_local;
1672 } else {
1673 pos += 1;
1674 }
1675 }
1676
1677 let open_byte = best_open_byte?;
1678 let first = text[..open_byte].trim_end().to_string();
1679 let rest = text[open_byte..].to_string();
1680 if first.is_empty() || rest.trim().is_empty() {
1681 return None;
1682 }
1683 Some((first, rest))
1684}
1685
1686fn compute_element_spans(elements: &[Element]) -> Vec<(usize, usize)> {
1690 let mut spans = Vec::new();
1691 let mut offset = 0;
1692 for element in elements {
1693 let rendered = format!("{element}");
1694 let len = rendered.len();
1695 if !matches!(element, Element::Text(_)) {
1696 spans.push((offset, offset + len));
1697 }
1698 offset += len;
1699 }
1700 spans
1701}
1702
1703fn is_inside_element(pos: usize, spans: &[(usize, usize)]) -> bool {
1705 spans.iter().any(|(start, end)| pos > *start && pos < *end)
1706}
1707
1708const MIN_SPLIT_RATIO: f64 = 0.3;
1711
1712fn split_at_clause_punctuation(
1716 text: &str,
1717 line_length: usize,
1718 element_spans: &[(usize, usize)],
1719 length_mode: ReflowLengthMode,
1720) -> Option<(String, String)> {
1721 let chars: Vec<char> = text.chars().collect();
1722 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
1723
1724 let mut width_acc = 0;
1726 let mut search_end_char = 0;
1727 for (idx, &c) in chars.iter().enumerate() {
1728 let c_width = display_len(&c.to_string(), length_mode);
1729 if width_acc + c_width > line_length {
1730 break;
1731 }
1732 width_acc += c_width;
1733 search_end_char = idx + 1;
1734 }
1735
1736 let mut paren_depth: i32 = 0;
1743 let mut best_pos = None;
1744 for i in (0..search_end_char).rev() {
1745 let byte_start: usize = chars[..i].iter().map(|c| c.len_utf8()).sum();
1747 let byte_after: usize = byte_start + chars[i].len_utf8();
1749
1750 if !is_inside_element(byte_start, element_spans) {
1751 match chars[i] {
1752 ')' => paren_depth += 1,
1753 '(' => paren_depth = paren_depth.saturating_sub(1),
1754 _ => {}
1755 }
1756 }
1757
1758 if paren_depth == 0
1759 && is_clause_punctuation(chars[i])
1760 && clause_break_allowed_after(&chars, i)
1761 && !is_inside_element(byte_after, element_spans)
1762 {
1763 best_pos = Some(i);
1764 break;
1765 }
1766 }
1767
1768 let pos = best_pos?;
1769
1770 let first: String = chars[..=pos].iter().collect();
1772 let first_display_len = display_len(&first, length_mode);
1773 if first_display_len < min_first_len {
1774 return None;
1775 }
1776
1777 let rest: String = chars[pos + 1..].iter().collect();
1779 let rest = rest.trim_start().to_string();
1780
1781 if rest.is_empty() {
1782 return None;
1783 }
1784
1785 Some((first, rest))
1786}
1787
1788fn paren_depth_map(text: &str, element_spans: &[(usize, usize)]) -> Vec<i32> {
1795 let mut map = vec![0i32; text.len()];
1796 let mut depth = 0i32;
1797 for (byte, c) in text.char_indices() {
1798 if !is_inside_element(byte, element_spans) {
1799 match c {
1800 '(' => depth += 1,
1801 ')' => depth = depth.saturating_sub(1),
1802 _ => {}
1803 }
1804 }
1805 let end = (byte + c.len_utf8()).min(map.len());
1807 for slot in &mut map[byte..end] {
1808 *slot = depth;
1809 }
1810 }
1811 map
1812}
1813
1814fn is_standalone_parenthetical(line: &str) -> bool {
1823 let trimmed = line.trim();
1824 if !trimmed.starts_with('(') {
1825 return false;
1826 }
1827 let core = trimmed.trim_end_matches(|c: char| is_clause_punctuation(c));
1829 if !core.ends_with(')') {
1830 return false;
1831 }
1832 let inner = &core[1..core.len() - 1];
1834 if !inner.contains(' ') {
1835 return false;
1836 }
1837 let mut depth = 0i32;
1839 for c in core.chars() {
1840 match c {
1841 '(' => depth += 1,
1842 ')' => depth -= 1,
1843 _ => {}
1844 }
1845 if depth < 0 {
1846 return false;
1847 }
1848 }
1849 depth == 0
1850}
1851
1852fn split_at_break_word(
1856 text: &str,
1857 line_length: usize,
1858 element_spans: &[(usize, usize)],
1859 length_mode: ReflowLengthMode,
1860) -> Option<(String, String)> {
1861 let lower = text.to_lowercase();
1862 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
1863 let mut best_split: Option<(usize, usize)> = None; let depth_map = paren_depth_map(text, element_spans);
1868
1869 for &word in BREAK_WORDS {
1870 let mut search_start = 0;
1871 while let Some(pos) = lower[search_start..].find(word) {
1872 let abs_pos = search_start + pos;
1873
1874 let preceded_by_space = abs_pos == 0 || text.as_bytes().get(abs_pos - 1) == Some(&b' ');
1876 let followed_by_space = text.as_bytes().get(abs_pos + word.len()) == Some(&b' ');
1877
1878 if preceded_by_space && followed_by_space {
1879 let first_part = text[..abs_pos].trim_end();
1881 let first_part_len = display_len(first_part, length_mode);
1882
1883 let inside_paren = depth_map.get(abs_pos).is_some_and(|&d| d > 0);
1885
1886 if first_part_len >= min_first_len
1887 && first_part_len <= line_length
1888 && !is_inside_element(abs_pos, element_spans)
1889 && !inside_paren
1890 {
1891 if best_split.is_none_or(|(prev_pos, _)| abs_pos > prev_pos) {
1893 best_split = Some((abs_pos, word.len()));
1894 }
1895 }
1896 }
1897
1898 search_start = abs_pos + word.len();
1899 }
1900 }
1901
1902 let (byte_start, _word_len) = best_split?;
1903
1904 let first = text[..byte_start].trim_end().to_string();
1905 let rest = text[byte_start..].to_string();
1906
1907 if first.is_empty() || rest.trim().is_empty() {
1908 return None;
1909 }
1910
1911 Some((first, rest))
1912}
1913
1914fn cascade_split_line(
1917 text: &str,
1918 line_length: usize,
1919 abbreviations: &Option<Vec<String>>,
1920 length_mode: ReflowLengthMode,
1921 attr_lists: bool,
1922 myst_roles: bool,
1923) -> Vec<String> {
1924 if line_length == 0 || display_len(text, length_mode) <= line_length {
1925 return vec![text.to_string()];
1926 }
1927
1928 let elements = parse_markdown_elements_inner(text, attr_lists, myst_roles);
1929 let element_spans = compute_element_spans(&elements);
1930
1931 if let Some((first, rest)) = split_at_parenthetical(text, line_length, &element_spans, length_mode) {
1934 let mut result = vec![first];
1935 result.extend(cascade_split_line(
1936 &rest,
1937 line_length,
1938 abbreviations,
1939 length_mode,
1940 attr_lists,
1941 myst_roles,
1942 ));
1943 return result;
1944 }
1945
1946 if let Some((first, rest)) = split_at_clause_punctuation(text, line_length, &element_spans, length_mode) {
1948 let mut result = vec![first];
1949 result.extend(cascade_split_line(
1950 &rest,
1951 line_length,
1952 abbreviations,
1953 length_mode,
1954 attr_lists,
1955 myst_roles,
1956 ));
1957 return result;
1958 }
1959
1960 if let Some((first, rest)) = split_at_break_word(text, line_length, &element_spans, length_mode) {
1962 let mut result = vec![first];
1963 result.extend(cascade_split_line(
1964 &rest,
1965 line_length,
1966 abbreviations,
1967 length_mode,
1968 attr_lists,
1969 myst_roles,
1970 ));
1971 return result;
1972 }
1973
1974 let options = ReflowOptions {
1976 line_length,
1977 break_on_sentences: false,
1978 preserve_breaks: false,
1979 sentence_per_line: false,
1980 semantic_line_breaks: false,
1981 abbreviations: abbreviations.clone(),
1982 length_mode,
1983 attr_lists,
1984 myst_roles,
1985 require_sentence_capital: true,
1986 max_list_continuation_indent: None,
1987 };
1988 reflow_elements(&elements, &options)
1989}
1990
1991fn reflow_elements_semantic(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
1995 let sentence_lines =
1997 reflow_elements_sentence_per_line(elements, &options.abbreviations, options.require_sentence_capital);
1998
1999 if options.line_length == 0 {
2002 return sentence_lines;
2003 }
2004
2005 let length_mode = options.length_mode;
2006 let mut result = Vec::new();
2007 for line in sentence_lines {
2008 if display_len(&line, length_mode) <= options.line_length {
2009 result.push(line);
2010 } else {
2011 result.extend(cascade_split_line(
2012 &line,
2013 options.line_length,
2014 &options.abbreviations,
2015 length_mode,
2016 options.attr_lists,
2017 options.myst_roles,
2018 ));
2019 }
2020 }
2021
2022 let min_line_len = ((options.line_length as f64) * MIN_SPLIT_RATIO) as usize;
2025 let mut merged: Vec<String> = Vec::with_capacity(result.len());
2026 for line in result {
2027 if !merged.is_empty() && display_len(&line, length_mode) < min_line_len && !line.trim().is_empty() {
2028 if is_standalone_parenthetical(&line) {
2031 merged.push(line);
2032 continue;
2033 }
2034
2035 let prev_ends_at_sentence = {
2037 let trimmed = merged.last().unwrap().trim_end();
2038 trimmed
2039 .chars()
2040 .rev()
2041 .find(|c| !matches!(c, '"' | '\'' | '\u{201D}' | '\u{2019}' | ')' | ']'))
2042 .is_some_and(|c| matches!(c, '.' | '!' | '?'))
2043 };
2044
2045 if !prev_ends_at_sentence {
2046 let prev = merged.last_mut().unwrap();
2047 let combined = format!("{prev} {line}");
2048 if display_len(&combined, length_mode) <= options.line_length {
2050 *prev = combined;
2051 continue;
2052 }
2053 }
2054 }
2055 merged.push(line);
2056 }
2057 merged
2058}
2059
2060fn rfind_safe_space(line: &str, element_spans: &[(usize, usize)]) -> Option<usize> {
2068 line.char_indices()
2069 .rev()
2070 .map(|(pos, _)| pos)
2071 .find(|&pos| line.as_bytes()[pos] == b' ' && !element_spans.iter().any(|(s, e)| pos > *s && pos < *e))
2072}
2073
2074fn reflow_elements(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
2076 let mut lines = Vec::new();
2077 let mut current_line = String::new();
2078 let mut current_length = 0;
2079 let mut current_line_element_spans: Vec<(usize, usize)> = Vec::new();
2081 let length_mode = options.length_mode;
2082
2083 for (idx, element) in elements.iter().enumerate() {
2084 let element_str = format!("{element}");
2087 let element_len = display_len(&element_str, length_mode);
2088
2089 let is_adjacent_to_prev = if idx > 0 {
2095 match (&elements[idx - 1], element) {
2096 (Element::Text(t), _) => !t.is_empty() && !t.ends_with(char::is_whitespace),
2097 (_, Element::Text(t)) => !t.is_empty() && !t.starts_with(char::is_whitespace),
2098 _ => true,
2099 }
2100 } else {
2101 false
2102 };
2103
2104 if let Element::Text(text) = element {
2106 let has_leading_space = text.starts_with(char::is_whitespace);
2108 let words: Vec<&str> = text.split_whitespace().collect();
2110
2111 for (i, word) in words.iter().enumerate() {
2112 let word_len = display_len(word, length_mode);
2113 let is_trailing_punct = word
2115 .chars()
2116 .all(|c| matches!(c, ',' | '.' | ':' | ';' | '!' | '?' | ')' | ']' | '}'));
2117
2118 let is_first_adjacent = i == 0 && is_adjacent_to_prev;
2121
2122 if is_first_adjacent {
2123 if current_length + word_len > options.line_length && current_length > 0 {
2125 if let Some(last_space) = rfind_safe_space(¤t_line, ¤t_line_element_spans) {
2128 let before = current_line[..last_space].trim_end().to_string();
2129 let after = current_line[last_space + 1..].to_string();
2130 lines.push(before);
2131 current_line = format!("{after}{word}");
2132 current_length = display_len(¤t_line, length_mode);
2133 current_line_element_spans.clear();
2134 } else {
2135 current_line.push_str(word);
2136 current_length += word_len;
2137 }
2138 } else {
2139 current_line.push_str(word);
2140 current_length += word_len;
2141 }
2142 } else if current_length > 0
2143 && current_length + 1 + word_len > options.line_length
2144 && !is_trailing_punct
2145 {
2146 lines.push(current_line.trim().to_string());
2148 current_line = word.to_string();
2149 current_length = word_len;
2150 current_line_element_spans.clear();
2151 } else {
2152 let add_space = current_length > 0 && if i == 0 { has_leading_space } else { !is_trailing_punct };
2162 if add_space {
2163 current_line.push(' ');
2164 current_length += 1;
2165 }
2166 current_line.push_str(word);
2167 current_length += word_len;
2168 }
2169 }
2170 } else if matches!(
2171 element,
2172 Element::Italic { .. } | Element::Bold { .. } | Element::Strikethrough { .. }
2173 ) && element_len > options.line_length
2174 {
2175 let (content, marker): (&str, &str) = match element {
2179 Element::Italic { content, underscore } => (content.as_str(), if *underscore { "_" } else { "*" }),
2180 Element::Bold { content, underscore } => (content.as_str(), if *underscore { "__" } else { "**" }),
2181 Element::Strikethrough { content, double } => (content.as_str(), if *double { "~~" } else { "~" }),
2182 _ => unreachable!(),
2183 };
2184
2185 let words: Vec<&str> = content.split_whitespace().collect();
2186 let n = words.len();
2187
2188 if n == 0 {
2189 let full = format!("{marker}{marker}");
2191 let full_len = display_len(&full, length_mode);
2192 if !is_adjacent_to_prev && current_length > 0 {
2193 current_line.push(' ');
2194 current_length += 1;
2195 }
2196 current_line.push_str(&full);
2197 current_length += full_len;
2198 } else {
2199 for (i, word) in words.iter().enumerate() {
2200 let is_first = i == 0;
2201 let is_last = i == n - 1;
2202 let word_str: String = match (is_first, is_last) {
2203 (true, true) => format!("{marker}{word}{marker}"),
2204 (true, false) => format!("{marker}{word}"),
2205 (false, true) => format!("{word}{marker}"),
2206 (false, false) => word.to_string(),
2207 };
2208 let word_len = display_len(&word_str, length_mode);
2209
2210 let needs_space = if is_first {
2211 !is_adjacent_to_prev && current_length > 0
2212 } else {
2213 current_length > 0
2214 };
2215
2216 if needs_space && current_length + 1 + word_len > options.line_length {
2217 lines.push(current_line.trim_end().to_string());
2218 current_line = word_str;
2219 current_length = word_len;
2220 current_line_element_spans.clear();
2221 } else {
2222 if needs_space {
2223 current_line.push(' ');
2224 current_length += 1;
2225 }
2226 current_line.push_str(&word_str);
2227 current_length += word_len;
2228 }
2229 }
2230 }
2231 } else {
2232 if is_adjacent_to_prev {
2236 if current_length + element_len > options.line_length {
2238 if let Some(last_space) = rfind_safe_space(¤t_line, ¤t_line_element_spans) {
2241 let before = current_line[..last_space].trim_end().to_string();
2242 let after = current_line[last_space + 1..].to_string();
2243 lines.push(before);
2244 current_line = format!("{after}{element_str}");
2245 current_length = display_len(¤t_line, length_mode);
2246 current_line_element_spans.clear();
2247 let start = after.len();
2249 current_line_element_spans.push((start, start + element_str.len()));
2250 } else {
2251 let start = current_line.len();
2253 current_line.push_str(&element_str);
2254 current_length += element_len;
2255 current_line_element_spans.push((start, current_line.len()));
2256 }
2257 } else {
2258 let start = current_line.len();
2259 current_line.push_str(&element_str);
2260 current_length += element_len;
2261 current_line_element_spans.push((start, current_line.len()));
2262 }
2263 } else if current_length > 0 && current_length + 1 + element_len > options.line_length {
2264 lines.push(current_line.trim().to_string());
2266 current_line.clone_from(&element_str);
2267 current_length = element_len;
2268 current_line_element_spans.clear();
2269 current_line_element_spans.push((0, element_str.len()));
2270 } else {
2271 let ends_with_opener =
2273 current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
2274 if current_length > 0 && !ends_with_opener {
2275 current_line.push(' ');
2276 current_length += 1;
2277 }
2278 let start = current_line.len();
2279 current_line.push_str(&element_str);
2280 current_length += element_len;
2281 current_line_element_spans.push((start, current_line.len()));
2282 }
2283 }
2284 }
2285
2286 if !current_line.is_empty() {
2288 lines.push(current_line.trim_end().to_string());
2289 }
2290
2291 lines
2292}
2293
2294pub fn reflow_markdown(content: &str, options: &ReflowOptions) -> String {
2296 let lines: Vec<&str> = content.lines().collect();
2297 let mut result = Vec::new();
2298 let mut i = 0;
2299
2300 while i < lines.len() {
2301 let line = lines[i];
2302 let trimmed = line.trim();
2303
2304 if trimmed.is_empty() {
2306 result.push(String::new());
2307 i += 1;
2308 continue;
2309 }
2310
2311 if trimmed.starts_with('#') {
2313 result.push(line.to_string());
2314 i += 1;
2315 continue;
2316 }
2317
2318 if trimmed.starts_with(":::") {
2320 result.push(line.to_string());
2321 i += 1;
2322 continue;
2323 }
2324
2325 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
2327 result.push(line.to_string());
2328 i += 1;
2329 while i < lines.len() {
2331 result.push(lines[i].to_string());
2332 if lines[i].trim().starts_with("```") || lines[i].trim().starts_with("~~~") {
2333 i += 1;
2334 break;
2335 }
2336 i += 1;
2337 }
2338 continue;
2339 }
2340
2341 if calculate_indentation_width_default(line) >= 4 {
2343 result.push(line.to_string());
2345 i += 1;
2346 while i < lines.len() {
2347 let next_line = lines[i];
2348 if calculate_indentation_width_default(next_line) >= 4 || next_line.trim().is_empty() {
2350 result.push(next_line.to_string());
2351 i += 1;
2352 } else {
2353 break;
2354 }
2355 }
2356 continue;
2357 }
2358
2359 if trimmed.starts_with('>') {
2361 let gt_pos = line.find('>').expect("'>' must exist since trimmed.starts_with('>')");
2364 let quote_prefix = line[0..=gt_pos].to_string();
2365 let quote_content = &line[quote_prefix.len()..].trim_start();
2366
2367 let reflowed = reflow_line(quote_content, options);
2368 for reflowed_line in &reflowed {
2369 result.push(format!("{quote_prefix} {reflowed_line}"));
2370 }
2371 i += 1;
2372 continue;
2373 }
2374
2375 if is_horizontal_rule(trimmed) {
2377 result.push(line.to_string());
2378 i += 1;
2379 continue;
2380 }
2381
2382 if is_unordered_list_marker(trimmed) || is_numbered_list_item(trimmed) {
2384 let indent = line.len() - line.trim_start().len();
2386 let indent_str = " ".repeat(indent);
2387
2388 let mut marker_end = indent;
2391 let mut content_start = indent;
2392
2393 if trimmed.chars().next().is_some_and(char::is_numeric) {
2394 if let Some(period_pos) = line[indent..].find('.') {
2396 marker_end = indent + period_pos + 1; content_start = marker_end;
2398 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
2402 content_start += 1;
2403 }
2404 }
2405 } else {
2406 marker_end = indent + 1; content_start = marker_end;
2409 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
2413 content_start += 1;
2414 }
2415 }
2416
2417 let min_continuation_indent = content_start;
2419
2420 let rest = &line[content_start..];
2423 if rest.starts_with("[ ] ") || rest.starts_with("[x] ") || rest.starts_with("[X] ") {
2424 marker_end = content_start + 3; content_start += 4; }
2427
2428 let marker = &line[indent..marker_end];
2429
2430 let mut list_content = vec![trim_preserving_hard_break(&line[content_start..])];
2433 i += 1;
2434
2435 while i < lines.len() {
2439 let next_line = lines[i];
2440 let next_trimmed = next_line.trim();
2441
2442 if is_block_boundary(next_trimmed) {
2444 break;
2445 }
2446
2447 let next_indent = next_line.len() - next_line.trim_start().len();
2449 if next_indent >= min_continuation_indent {
2450 let trimmed_start = next_line.trim_start();
2453 list_content.push(trim_preserving_hard_break(trimmed_start));
2454 i += 1;
2455 } else {
2456 break;
2458 }
2459 }
2460
2461 let combined_content = if options.preserve_breaks {
2464 list_content[0].clone()
2465 } else {
2466 let has_hard_breaks = list_content.iter().any(|line| has_hard_break(line));
2468 if has_hard_breaks {
2469 list_content.join("\n")
2471 } else {
2472 list_content.join(" ")
2474 }
2475 };
2476
2477 let trimmed_marker = marker;
2479 let continuation_spaces = if let Some(max_indent) = options.max_list_continuation_indent {
2480 indent + (content_start - indent).min(max_indent)
2483 } else {
2484 content_start
2485 };
2486
2487 let prefix_length = indent + trimmed_marker.len() + 1;
2489
2490 let adjusted_options = ReflowOptions {
2492 line_length: options.line_length.saturating_sub(prefix_length),
2493 ..options.clone()
2494 };
2495
2496 let reflowed = reflow_line(&combined_content, &adjusted_options);
2497 for (j, reflowed_line) in reflowed.iter().enumerate() {
2498 if j == 0 {
2499 result.push(format!("{indent_str}{trimmed_marker} {reflowed_line}"));
2500 } else {
2501 let continuation_indent = " ".repeat(continuation_spaces);
2503 result.push(format!("{continuation_indent}{reflowed_line}"));
2504 }
2505 }
2506 continue;
2507 }
2508
2509 if crate::utils::table_utils::TableUtils::is_potential_table_row(line) {
2511 result.push(line.to_string());
2512 i += 1;
2513 continue;
2514 }
2515
2516 if trimmed.starts_with('[') && line.contains("]:") {
2518 result.push(line.to_string());
2519 i += 1;
2520 continue;
2521 }
2522
2523 if is_definition_list_item(trimmed) {
2525 result.push(line.to_string());
2526 i += 1;
2527 continue;
2528 }
2529
2530 let mut is_single_line_paragraph = true;
2532 if i + 1 < lines.len() {
2533 let next_trimmed = lines[i + 1].trim();
2534 if !is_block_boundary(next_trimmed) {
2536 is_single_line_paragraph = false;
2537 }
2538 }
2539
2540 if is_single_line_paragraph && display_len(line, options.length_mode) <= options.line_length {
2542 result.push(line.to_string());
2543 i += 1;
2544 continue;
2545 }
2546
2547 let mut paragraph_parts = Vec::new();
2549 let mut current_part = vec![line];
2550 i += 1;
2551
2552 if options.preserve_breaks {
2554 let hard_break_type = if line.strip_suffix('\r').unwrap_or(line).ends_with('\\') {
2556 Some("\\")
2557 } else if line.ends_with(" ") {
2558 Some(" ")
2559 } else {
2560 None
2561 };
2562 let reflowed = reflow_line(line, options);
2563
2564 if let Some(break_marker) = hard_break_type {
2566 if !reflowed.is_empty() {
2567 let mut reflowed_with_break = reflowed;
2568 let last_idx = reflowed_with_break.len() - 1;
2569 if !has_hard_break(&reflowed_with_break[last_idx]) {
2570 reflowed_with_break[last_idx].push_str(break_marker);
2571 }
2572 result.extend(reflowed_with_break);
2573 }
2574 } else {
2575 result.extend(reflowed);
2576 }
2577 } else {
2578 while i < lines.len() {
2580 let prev_line = if !current_part.is_empty() {
2581 current_part.last().unwrap()
2582 } else {
2583 ""
2584 };
2585 let next_line = lines[i];
2586 let next_trimmed = next_line.trim();
2587
2588 if is_block_boundary(next_trimmed) {
2590 break;
2591 }
2592
2593 let prev_trimmed = prev_line.trim();
2596 let abbreviations = get_abbreviations(&options.abbreviations);
2597 let ends_with_sentence = (prev_trimmed.ends_with('.')
2598 || prev_trimmed.ends_with('!')
2599 || prev_trimmed.ends_with('?')
2600 || prev_trimmed.ends_with(".*")
2601 || prev_trimmed.ends_with("!*")
2602 || prev_trimmed.ends_with("?*")
2603 || prev_trimmed.ends_with("._")
2604 || prev_trimmed.ends_with("!_")
2605 || prev_trimmed.ends_with("?_")
2606 || prev_trimmed.ends_with(".\"")
2608 || prev_trimmed.ends_with("!\"")
2609 || prev_trimmed.ends_with("?\"")
2610 || prev_trimmed.ends_with(".'")
2611 || prev_trimmed.ends_with("!'")
2612 || prev_trimmed.ends_with("?'")
2613 || prev_trimmed.ends_with(".\u{201D}")
2614 || prev_trimmed.ends_with("!\u{201D}")
2615 || prev_trimmed.ends_with("?\u{201D}")
2616 || prev_trimmed.ends_with(".\u{2019}")
2617 || prev_trimmed.ends_with("!\u{2019}")
2618 || prev_trimmed.ends_with("?\u{2019}"))
2619 && !text_ends_with_abbreviation(
2620 prev_trimmed.trim_end_matches(['*', '_', '"', '\'', '\u{201D}', '\u{2019}']),
2621 &abbreviations,
2622 );
2623
2624 if has_hard_break(prev_line) || (options.sentence_per_line && ends_with_sentence) {
2625 paragraph_parts.push(current_part.join(" "));
2627 current_part = vec![next_line];
2628 } else {
2629 current_part.push(next_line);
2630 }
2631 i += 1;
2632 }
2633
2634 if !current_part.is_empty() {
2636 if current_part.len() == 1 {
2637 paragraph_parts.push(current_part[0].to_string());
2639 } else {
2640 paragraph_parts.push(current_part.join(" "));
2641 }
2642 }
2643
2644 for (j, part) in paragraph_parts.iter().enumerate() {
2646 let reflowed = reflow_line(part, options);
2647 result.extend(reflowed);
2648
2649 if j < paragraph_parts.len() - 1 && !result.is_empty() && !options.sentence_per_line {
2653 let last_idx = result.len() - 1;
2654 if !has_hard_break(&result[last_idx]) {
2655 result[last_idx].push_str(" ");
2656 }
2657 }
2658 }
2659 }
2660 }
2661
2662 let result_text = result.join("\n");
2664 if content.ends_with('\n') && !result_text.ends_with('\n') {
2665 format!("{result_text}\n")
2666 } else {
2667 result_text
2668 }
2669}
2670
2671#[derive(Debug, Clone)]
2673pub struct ParagraphReflow {
2674 pub start_byte: usize,
2676 pub end_byte: usize,
2678 pub reflowed_text: String,
2680}
2681
2682#[derive(Debug, Clone)]
2688pub struct BlockquoteLineData {
2689 pub(crate) content: String,
2691 pub(crate) is_explicit: bool,
2693 pub(crate) prefix: Option<String>,
2695}
2696
2697impl BlockquoteLineData {
2698 pub fn explicit(content: String, prefix: String) -> Self {
2700 Self {
2701 content,
2702 is_explicit: true,
2703 prefix: Some(prefix),
2704 }
2705 }
2706
2707 pub fn lazy(content: String) -> Self {
2709 Self {
2710 content,
2711 is_explicit: false,
2712 prefix: None,
2713 }
2714 }
2715}
2716
2717#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2719pub enum BlockquoteContinuationStyle {
2720 Explicit,
2721 Lazy,
2722}
2723
2724pub fn blockquote_continuation_style(lines: &[BlockquoteLineData]) -> BlockquoteContinuationStyle {
2732 let mut explicit_count = 0usize;
2733 let mut lazy_count = 0usize;
2734
2735 for line in lines.iter().skip(1) {
2736 if line.is_explicit {
2737 explicit_count += 1;
2738 } else {
2739 lazy_count += 1;
2740 }
2741 }
2742
2743 if explicit_count > 0 && lazy_count == 0 {
2744 BlockquoteContinuationStyle::Explicit
2745 } else if lazy_count > 0 && explicit_count == 0 {
2746 BlockquoteContinuationStyle::Lazy
2747 } else if explicit_count >= lazy_count {
2748 BlockquoteContinuationStyle::Explicit
2749 } else {
2750 BlockquoteContinuationStyle::Lazy
2751 }
2752}
2753
2754pub fn dominant_blockquote_prefix(lines: &[BlockquoteLineData], fallback: &str) -> String {
2759 let mut counts: std::collections::HashMap<String, (usize, usize)> = std::collections::HashMap::new();
2760
2761 for (idx, line) in lines.iter().enumerate() {
2762 let Some(prefix) = line.prefix.as_ref() else {
2763 continue;
2764 };
2765 counts
2766 .entry(prefix.clone())
2767 .and_modify(|entry| entry.0 += 1)
2768 .or_insert((1, idx));
2769 }
2770
2771 counts
2772 .into_iter()
2773 .max_by(|(_, (count_a, first_idx_a)), (_, (count_b, first_idx_b))| {
2774 count_a.cmp(count_b).then_with(|| first_idx_b.cmp(first_idx_a))
2775 })
2776 .map_or_else(|| fallback.to_string(), |(prefix, _)| prefix)
2777}
2778
2779pub(crate) fn should_force_explicit_blockquote_line(content_line: &str) -> bool {
2784 let trimmed = content_line.trim_start();
2785 trimmed.starts_with('>')
2786 || trimmed.starts_with('#')
2787 || trimmed.starts_with("```")
2788 || trimmed.starts_with("~~~")
2789 || is_unordered_list_marker(trimmed)
2790 || is_numbered_list_item(trimmed)
2791 || is_horizontal_rule(trimmed)
2792 || is_definition_list_item(trimmed)
2793 || (trimmed.starts_with('[') && trimmed.contains("]:"))
2794 || trimmed.starts_with(":::")
2795 || (trimmed.starts_with('<')
2796 && !trimmed.starts_with("<http")
2797 && !trimmed.starts_with("<https")
2798 && !trimmed.starts_with("<mailto:"))
2799}
2800
2801pub fn reflow_blockquote_content(
2810 lines: &[BlockquoteLineData],
2811 explicit_prefix: &str,
2812 continuation_style: BlockquoteContinuationStyle,
2813 options: &ReflowOptions,
2814) -> Vec<String> {
2815 let content_strs: Vec<&str> = lines.iter().map(|l| l.content.as_str()).collect();
2816 let segments = split_into_segments_strs(&content_strs);
2817 let mut reflowed_content_lines: Vec<String> = Vec::new();
2818
2819 for segment in segments {
2820 let hard_break_type = segment.last().and_then(|&line| {
2821 let line = line.strip_suffix('\r').unwrap_or(line);
2822 if line.ends_with('\\') {
2823 Some("\\")
2824 } else if line.ends_with(" ") {
2825 Some(" ")
2826 } else {
2827 None
2828 }
2829 });
2830
2831 let pieces: Vec<&str> = segment
2832 .iter()
2833 .map(|&line| {
2834 if let Some(l) = line.strip_suffix('\\') {
2835 l.trim_end()
2836 } else if let Some(l) = line.strip_suffix(" ") {
2837 l.trim_end()
2838 } else {
2839 line.trim_end()
2840 }
2841 })
2842 .collect();
2843
2844 let segment_text = pieces.join(" ");
2845 let segment_text = segment_text.trim();
2846 if segment_text.is_empty() {
2847 continue;
2848 }
2849
2850 let mut reflowed = reflow_line(segment_text, options);
2851 if let Some(break_marker) = hard_break_type
2852 && !reflowed.is_empty()
2853 {
2854 let last_idx = reflowed.len() - 1;
2855 if !has_hard_break(&reflowed[last_idx]) {
2856 reflowed[last_idx].push_str(break_marker);
2857 }
2858 }
2859 reflowed_content_lines.extend(reflowed);
2860 }
2861
2862 let mut styled_lines: Vec<String> = Vec::new();
2863 for (idx, line) in reflowed_content_lines.iter().enumerate() {
2864 let force_explicit = idx == 0
2865 || continuation_style == BlockquoteContinuationStyle::Explicit
2866 || should_force_explicit_blockquote_line(line);
2867 if force_explicit {
2868 styled_lines.push(format!("{explicit_prefix}{line}"));
2869 } else {
2870 styled_lines.push(line.clone());
2871 }
2872 }
2873
2874 styled_lines
2875}
2876
2877fn is_blockquote_content_boundary(content: &str) -> bool {
2878 let trimmed = content.trim();
2879 trimmed.is_empty()
2880 || is_block_boundary(trimmed)
2881 || crate::utils::table_utils::TableUtils::is_potential_table_row(content)
2882 || trimmed.starts_with(":::")
2883 || crate::utils::is_template_directive_only(content)
2884 || is_standalone_attr_list(content)
2885 || is_snippet_block_delimiter(content)
2886}
2887
2888fn split_into_segments_strs<'a>(lines: &[&'a str]) -> Vec<Vec<&'a str>> {
2889 let mut segments = Vec::new();
2890 let mut current = Vec::new();
2891
2892 for &line in lines {
2893 current.push(line);
2894 if has_hard_break(line) {
2895 segments.push(current);
2896 current = Vec::new();
2897 }
2898 }
2899
2900 if !current.is_empty() {
2901 segments.push(current);
2902 }
2903
2904 segments
2905}
2906
2907fn reflow_blockquote_paragraph_at_line(
2908 content: &str,
2909 lines: &[&str],
2910 target_idx: usize,
2911 options: &ReflowOptions,
2912) -> Option<ParagraphReflow> {
2913 let mut anchor_idx = target_idx;
2914 let mut target_level = if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[target_idx]) {
2915 parsed.nesting_level
2916 } else {
2917 let mut found = None;
2918 let mut idx = target_idx;
2919 loop {
2920 if lines[idx].trim().is_empty() {
2921 break;
2922 }
2923 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[idx]) {
2924 found = Some((idx, parsed.nesting_level));
2925 break;
2926 }
2927 if idx == 0 {
2928 break;
2929 }
2930 idx -= 1;
2931 }
2932 let (idx, level) = found?;
2933 anchor_idx = idx;
2934 level
2935 };
2936
2937 let mut para_start = anchor_idx;
2939 while para_start > 0 {
2940 let prev_idx = para_start - 1;
2941 let prev_line = lines[prev_idx];
2942
2943 if prev_line.trim().is_empty() {
2944 break;
2945 }
2946
2947 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(prev_line) {
2948 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
2949 break;
2950 }
2951 para_start = prev_idx;
2952 continue;
2953 }
2954
2955 let prev_lazy = prev_line.trim_start();
2956 if is_blockquote_content_boundary(prev_lazy) {
2957 break;
2958 }
2959 para_start = prev_idx;
2960 }
2961
2962 while para_start < lines.len() {
2964 let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[para_start]) else {
2965 para_start += 1;
2966 continue;
2967 };
2968 target_level = parsed.nesting_level;
2969 break;
2970 }
2971
2972 if para_start >= lines.len() || para_start > target_idx {
2973 return None;
2974 }
2975
2976 let mut collected: Vec<(usize, BlockquoteLineData)> = Vec::new();
2979 let mut idx = para_start;
2980 while idx < lines.len() {
2981 if !collected.is_empty() && has_hard_break(&collected[collected.len() - 1].1.content) {
2982 break;
2983 }
2984
2985 let line = lines[idx];
2986 if line.trim().is_empty() {
2987 break;
2988 }
2989
2990 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(line) {
2991 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
2992 break;
2993 }
2994 collected.push((
2995 idx,
2996 BlockquoteLineData::explicit(trim_preserving_hard_break(parsed.content), parsed.prefix.to_string()),
2997 ));
2998 idx += 1;
2999 continue;
3000 }
3001
3002 let lazy_content = line.trim_start();
3003 if is_blockquote_content_boundary(lazy_content) {
3004 break;
3005 }
3006
3007 collected.push((idx, BlockquoteLineData::lazy(trim_preserving_hard_break(lazy_content))));
3008 idx += 1;
3009 }
3010
3011 if collected.is_empty() {
3012 return None;
3013 }
3014
3015 let para_end = collected[collected.len() - 1].0;
3016 if target_idx < para_start || target_idx > para_end {
3017 return None;
3018 }
3019
3020 let line_data: Vec<BlockquoteLineData> = collected.iter().map(|(_, d)| d.clone()).collect();
3021
3022 let fallback_prefix = line_data
3023 .iter()
3024 .find_map(|d| d.prefix.clone())
3025 .unwrap_or_else(|| "> ".to_string());
3026 let explicit_prefix = dominant_blockquote_prefix(&line_data, &fallback_prefix);
3027 let continuation_style = blockquote_continuation_style(&line_data);
3028
3029 let adjusted_line_length = options
3030 .line_length
3031 .saturating_sub(display_len(&explicit_prefix, options.length_mode))
3032 .max(1);
3033
3034 let adjusted_options = ReflowOptions {
3035 line_length: adjusted_line_length,
3036 ..options.clone()
3037 };
3038
3039 let styled_lines = reflow_blockquote_content(&line_data, &explicit_prefix, continuation_style, &adjusted_options);
3040
3041 if styled_lines.is_empty() {
3042 return None;
3043 }
3044
3045 let mut start_byte = 0;
3047 for line in lines.iter().take(para_start) {
3048 start_byte += line.len() + 1;
3049 }
3050
3051 let mut end_byte = start_byte;
3052 for line in lines.iter().take(para_end + 1).skip(para_start) {
3053 end_byte += line.len() + 1;
3054 }
3055
3056 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
3057 if !includes_trailing_newline {
3058 end_byte -= 1;
3059 }
3060
3061 let reflowed_joined = styled_lines.join("\n");
3062 let reflowed_text = if includes_trailing_newline {
3063 if reflowed_joined.ends_with('\n') {
3064 reflowed_joined
3065 } else {
3066 format!("{reflowed_joined}\n")
3067 }
3068 } else if reflowed_joined.ends_with('\n') {
3069 reflowed_joined.trim_end_matches('\n').to_string()
3070 } else {
3071 reflowed_joined
3072 };
3073
3074 Some(ParagraphReflow {
3075 start_byte,
3076 end_byte,
3077 reflowed_text,
3078 })
3079}
3080
3081pub fn reflow_paragraph_at_line(content: &str, line_number: usize, line_length: usize) -> Option<ParagraphReflow> {
3099 reflow_paragraph_at_line_with_mode(content, line_number, line_length, ReflowLengthMode::default())
3100}
3101
3102pub fn reflow_paragraph_at_line_with_mode(
3104 content: &str,
3105 line_number: usize,
3106 line_length: usize,
3107 length_mode: ReflowLengthMode,
3108) -> Option<ParagraphReflow> {
3109 let options = ReflowOptions {
3110 line_length,
3111 length_mode,
3112 ..Default::default()
3113 };
3114 reflow_paragraph_at_line_with_options(content, line_number, &options)
3115}
3116
3117pub fn reflow_paragraph_at_line_with_options(
3128 content: &str,
3129 line_number: usize,
3130 options: &ReflowOptions,
3131) -> Option<ParagraphReflow> {
3132 if line_number == 0 {
3133 return None;
3134 }
3135
3136 let lines: Vec<&str> = content.lines().collect();
3137
3138 if line_number > lines.len() {
3140 return None;
3141 }
3142
3143 let target_idx = line_number - 1; let target_line = lines[target_idx];
3145 let trimmed = target_line.trim();
3146
3147 if let Some(blockquote_reflow) = reflow_blockquote_paragraph_at_line(content, &lines, target_idx, options) {
3150 return Some(blockquote_reflow);
3151 }
3152
3153 if is_paragraph_boundary(trimmed, target_line) {
3155 return None;
3156 }
3157
3158 let mut para_start = target_idx;
3160 while para_start > 0 {
3161 let prev_idx = para_start - 1;
3162 let prev_line = lines[prev_idx];
3163 let prev_trimmed = prev_line.trim();
3164
3165 if is_paragraph_boundary(prev_trimmed, prev_line) {
3167 break;
3168 }
3169
3170 para_start = prev_idx;
3171 }
3172
3173 let mut para_end = target_idx;
3175 while para_end + 1 < lines.len() {
3176 let next_idx = para_end + 1;
3177 let next_line = lines[next_idx];
3178 let next_trimmed = next_line.trim();
3179
3180 if is_paragraph_boundary(next_trimmed, next_line) {
3182 break;
3183 }
3184
3185 para_end = next_idx;
3186 }
3187
3188 let paragraph_lines = &lines[para_start..=para_end];
3190
3191 let mut start_byte = 0;
3193 for line in lines.iter().take(para_start) {
3194 start_byte += line.len() + 1; }
3196
3197 let mut end_byte = start_byte;
3198 for line in paragraph_lines {
3199 end_byte += line.len() + 1; }
3201
3202 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
3205
3206 if !includes_trailing_newline {
3208 end_byte -= 1;
3209 }
3210
3211 let paragraph_text = paragraph_lines.join("\n");
3213
3214 let reflowed = reflow_markdown(¶graph_text, options);
3216
3217 let reflowed_text = if includes_trailing_newline {
3221 if reflowed.ends_with('\n') {
3223 reflowed
3224 } else {
3225 format!("{reflowed}\n")
3226 }
3227 } else {
3228 if reflowed.ends_with('\n') {
3230 reflowed.trim_end_matches('\n').to_string()
3231 } else {
3232 reflowed
3233 }
3234 };
3235
3236 Some(ParagraphReflow {
3237 start_byte,
3238 end_byte,
3239 reflowed_text,
3240 })
3241}
3242
3243#[cfg(test)]
3244mod tests {
3245 use super::*;
3246
3247 #[test]
3252 fn test_helper_function_text_ends_with_abbreviation() {
3253 let abbreviations = get_abbreviations(&None);
3255
3256 assert!(text_ends_with_abbreviation("Dr.", &abbreviations));
3258 assert!(text_ends_with_abbreviation("word Dr.", &abbreviations));
3259 assert!(text_ends_with_abbreviation("e.g.", &abbreviations));
3260 assert!(text_ends_with_abbreviation("i.e.", &abbreviations));
3261 assert!(text_ends_with_abbreviation("Mr.", &abbreviations));
3262 assert!(text_ends_with_abbreviation("Mrs.", &abbreviations));
3263 assert!(text_ends_with_abbreviation("Ms.", &abbreviations));
3264 assert!(text_ends_with_abbreviation("Prof.", &abbreviations));
3265
3266 assert!(!text_ends_with_abbreviation("etc.", &abbreviations));
3268 assert!(!text_ends_with_abbreviation("paradigms.", &abbreviations));
3269 assert!(!text_ends_with_abbreviation("programs.", &abbreviations));
3270 assert!(!text_ends_with_abbreviation("items.", &abbreviations));
3271 assert!(!text_ends_with_abbreviation("systems.", &abbreviations));
3272 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)); }
3278
3279 #[test]
3280 fn test_is_unordered_list_marker() {
3281 assert!(is_unordered_list_marker("- item"));
3283 assert!(is_unordered_list_marker("* item"));
3284 assert!(is_unordered_list_marker("+ item"));
3285 assert!(is_unordered_list_marker("-")); assert!(is_unordered_list_marker("*"));
3287 assert!(is_unordered_list_marker("+"));
3288
3289 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")); }
3300
3301 #[test]
3302 fn test_is_block_boundary() {
3303 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"));
3325 assert!(!is_block_boundary("*emphasis*")); assert!(!is_block_boundary("[link](url)")); assert!(!is_block_boundary("some words here"));
3328 }
3329
3330 #[test]
3331 fn test_definition_list_boundary_in_single_line_paragraph() {
3332 let options = ReflowOptions {
3335 line_length: 80,
3336 ..Default::default()
3337 };
3338 let input = "Term\n: Definition of the term";
3339 let result = reflow_markdown(input, &options);
3340 assert!(
3342 result.contains(": Definition"),
3343 "Definition list item should not be merged into previous line. Got: {result:?}"
3344 );
3345 let lines: Vec<&str> = result.lines().collect();
3346 assert_eq!(lines.len(), 2, "Should remain two separate lines. Got: {lines:?}");
3347 assert_eq!(lines[0], "Term");
3348 assert_eq!(lines[1], ": Definition of the term");
3349 }
3350
3351 #[test]
3352 fn test_is_paragraph_boundary() {
3353 assert!(is_paragraph_boundary("# Heading", "# Heading"));
3355 assert!(is_paragraph_boundary("- item", "- item"));
3356 assert!(is_paragraph_boundary(":::", ":::"));
3357 assert!(is_paragraph_boundary(": definition", ": definition"));
3358
3359 assert!(is_paragraph_boundary("code", " code"));
3361 assert!(is_paragraph_boundary("code", "\tcode"));
3362
3363 assert!(is_paragraph_boundary("| a | b |", "| a | b |"));
3365 assert!(is_paragraph_boundary("a | b", "a | b")); assert!(!is_paragraph_boundary("regular text", "regular text"));
3369 assert!(!is_paragraph_boundary("text", " text")); }
3371
3372 #[test]
3373 fn test_div_marker_boundary_in_reflow_paragraph_at_line() {
3374 let content = "Some paragraph text here.\n\n::: {.callout-note}\nThis is a callout.\n:::\n";
3377 let result = reflow_paragraph_at_line(content, 3, 80);
3379 assert!(result.is_none(), "Div marker line should not be reflowed");
3380 }
3381}