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, FOOTNOTE_REF_REGEX, HTML_ENTITY_REGEX, HTML_TAG_PATTERN,
12 HUGO_SHORTCODE_REGEX, INLINE_IMAGE_REGEX, INLINE_LINK_FANCY_REGEX, INLINE_MATH_REGEX, LINKED_IMAGE_INLINE_INLINE,
13 LINKED_IMAGE_INLINE_REF, LINKED_IMAGE_REF_INLINE, LINKED_IMAGE_REF_REF, REF_IMAGE_REGEX, REF_LINK_REGEX,
14 SHORTCUT_REF_REGEX, WIKI_LINK_REGEX,
15};
16use crate::utils::sentence_utils::{
17 get_abbreviations, is_cjk_char, is_cjk_sentence_ending, is_closing_quote, is_opening_quote,
18 text_ends_with_abbreviation,
19};
20use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
21use std::collections::HashSet;
22use unicode_width::UnicodeWidthStr;
23
24#[derive(Clone, Copy, Debug, Default, PartialEq)]
26pub enum ReflowLengthMode {
27 Chars,
29 #[default]
31 Visual,
32 Bytes,
34}
35
36fn display_len(s: &str, mode: ReflowLengthMode) -> usize {
38 match mode {
39 ReflowLengthMode::Chars => s.chars().count(),
40 ReflowLengthMode::Visual => s.width(),
41 ReflowLengthMode::Bytes => s.len(),
42 }
43}
44
45#[derive(Clone)]
47pub struct ReflowOptions {
48 pub line_length: usize,
50 pub break_on_sentences: bool,
52 pub preserve_breaks: bool,
54 pub sentence_per_line: bool,
56 pub semantic_line_breaks: bool,
58 pub abbreviations: Option<Vec<String>>,
62 pub length_mode: ReflowLengthMode,
64 pub attr_lists: bool,
67 pub require_sentence_capital: bool,
72 pub max_list_continuation_indent: Option<usize>,
76}
77
78impl Default for ReflowOptions {
79 fn default() -> Self {
80 Self {
81 line_length: 80,
82 break_on_sentences: true,
83 preserve_breaks: false,
84 sentence_per_line: false,
85 semantic_line_breaks: false,
86 abbreviations: None,
87 length_mode: ReflowLengthMode::default(),
88 attr_lists: false,
89 require_sentence_capital: true,
90 max_list_continuation_indent: None,
91 }
92 }
93}
94
95fn compute_inline_code_mask(text: &str) -> Vec<bool> {
98 let chars: Vec<char> = text.chars().collect();
99 let len = chars.len();
100 let mut mask = vec![false; len];
101 let mut i = 0;
102
103 while i < len {
104 if chars[i] == '`' {
105 let open_start = i;
107 let mut backtick_count = 0;
108 while i < len && chars[i] == '`' {
109 backtick_count += 1;
110 i += 1;
111 }
112
113 let mut found_close = false;
115 let content_start = i;
116 while i < len {
117 if chars[i] == '`' {
118 let close_start = i;
119 let mut close_count = 0;
120 while i < len && chars[i] == '`' {
121 close_count += 1;
122 i += 1;
123 }
124 if close_count == backtick_count {
125 for item in mask.iter_mut().take(close_start).skip(content_start) {
127 *item = true;
128 }
129 for item in mask.iter_mut().take(content_start).skip(open_start) {
131 *item = true;
132 }
133 for item in mask.iter_mut().take(i).skip(close_start) {
134 *item = true;
135 }
136 found_close = true;
137 break;
138 }
139 } else {
140 i += 1;
141 }
142 }
143
144 if !found_close {
145 i = open_start + backtick_count;
147 }
148 } else {
149 i += 1;
150 }
151 }
152
153 mask
154}
155
156fn is_sentence_boundary(
160 text: &str,
161 chars: &[char],
162 pos: usize,
163 abbreviations: &HashSet<String>,
164 require_sentence_capital: bool,
165) -> bool {
166 if pos + 1 >= chars.len() {
167 return false;
168 }
169
170 let c = chars[pos];
171 let next_char = chars[pos + 1];
172
173 if is_cjk_sentence_ending(c) {
176 let mut after_punct_pos = pos + 1;
178 while after_punct_pos < chars.len()
179 && (chars[after_punct_pos] == '*' || chars[after_punct_pos] == '_' || chars[after_punct_pos] == '~')
180 {
181 after_punct_pos += 1;
182 }
183
184 while after_punct_pos < chars.len() && chars[after_punct_pos].is_whitespace() {
186 after_punct_pos += 1;
187 }
188
189 if after_punct_pos >= chars.len() {
191 return false;
192 }
193
194 while after_punct_pos < chars.len()
196 && (chars[after_punct_pos] == '*' || chars[after_punct_pos] == '_' || chars[after_punct_pos] == '~')
197 {
198 after_punct_pos += 1;
199 }
200
201 if after_punct_pos >= chars.len() {
202 return false;
203 }
204
205 return true;
208 }
209
210 if c != '.' && c != '!' && c != '?' {
212 return false;
213 }
214
215 let (_space_pos, after_space_pos) = if next_char == ' ' {
217 (pos + 1, pos + 2)
219 } else if is_closing_quote(next_char) && pos + 2 < chars.len() {
220 if chars[pos + 2] == ' ' {
222 (pos + 2, pos + 3)
224 } else if (chars[pos + 2] == '*' || chars[pos + 2] == '_') && pos + 3 < chars.len() && chars[pos + 3] == ' ' {
225 (pos + 3, pos + 4)
227 } else if (chars[pos + 2] == '*' || chars[pos + 2] == '_')
228 && pos + 4 < chars.len()
229 && chars[pos + 3] == chars[pos + 2]
230 && chars[pos + 4] == ' '
231 {
232 (pos + 4, pos + 5)
234 } else {
235 return false;
236 }
237 } else if (next_char == '*' || next_char == '_') && pos + 2 < chars.len() && chars[pos + 2] == ' ' {
238 (pos + 2, pos + 3)
240 } else if (next_char == '*' || next_char == '_')
241 && pos + 3 < chars.len()
242 && chars[pos + 2] == next_char
243 && chars[pos + 3] == ' '
244 {
245 (pos + 3, pos + 4)
247 } else if next_char == '~' && pos + 3 < chars.len() && chars[pos + 2] == '~' && chars[pos + 3] == ' ' {
248 (pos + 3, pos + 4)
250 } else {
251 return false;
252 };
253
254 let mut next_char_pos = after_space_pos;
256 while next_char_pos < chars.len() && chars[next_char_pos].is_whitespace() {
257 next_char_pos += 1;
258 }
259
260 if next_char_pos >= chars.len() {
262 return false;
263 }
264
265 let mut first_letter_pos = next_char_pos;
267 while first_letter_pos < chars.len()
268 && (chars[first_letter_pos] == '*'
269 || chars[first_letter_pos] == '_'
270 || chars[first_letter_pos] == '~'
271 || is_opening_quote(chars[first_letter_pos]))
272 {
273 first_letter_pos += 1;
274 }
275
276 if first_letter_pos >= chars.len() {
278 return false;
279 }
280
281 let first_char = chars[first_letter_pos];
282
283 if c == '!' || c == '?' {
285 return true;
286 }
287
288 if pos > 0 {
292 let byte_offset: usize = chars[..=pos].iter().map(|ch| ch.len_utf8()).sum();
294 if text_ends_with_abbreviation(&text[..byte_offset], abbreviations) {
295 return false;
296 }
297
298 if chars[pos - 1].is_numeric() && first_char.is_ascii_digit() {
300 return false;
301 }
302
303 if chars[pos - 1].is_ascii_uppercase() && (pos == 1 || (pos >= 2 && chars[pos - 2].is_whitespace())) {
307 return false;
308 }
309 }
310
311 if require_sentence_capital && !first_char.is_uppercase() && !is_cjk_char(first_char) {
314 return false;
315 }
316
317 true
318}
319
320pub fn split_into_sentences(text: &str) -> Vec<String> {
322 split_into_sentences_custom(text, &None)
323}
324
325pub fn split_into_sentences_custom(text: &str, custom_abbreviations: &Option<Vec<String>>) -> Vec<String> {
327 let abbreviations = get_abbreviations(custom_abbreviations);
328 split_into_sentences_with_set(text, &abbreviations, true)
329}
330
331fn split_into_sentences_with_set(
334 text: &str,
335 abbreviations: &HashSet<String>,
336 require_sentence_capital: bool,
337) -> Vec<String> {
338 let in_code = compute_inline_code_mask(text);
340 let char_vec: Vec<char> = text.chars().collect();
343
344 let mut sentences = Vec::new();
345 let mut current_sentence = String::new();
346 let mut chars = text.chars().peekable();
347 let mut pos = 0;
348
349 while let Some(c) = chars.next() {
350 current_sentence.push(c);
351
352 if !in_code[pos] && is_sentence_boundary(text, &char_vec, pos, abbreviations, require_sentence_capital) {
353 while let Some(&next) = chars.peek() {
355 if next == '*' || next == '_' || next == '~' || is_closing_quote(next) {
356 current_sentence.push(chars.next().unwrap());
357 pos += 1;
358 } else {
359 break;
360 }
361 }
362
363 if chars.peek() == Some(&' ') {
365 chars.next();
366 pos += 1;
367 }
368
369 sentences.push(current_sentence.trim().to_string());
370 current_sentence.clear();
371 }
372
373 pos += 1;
374 }
375
376 if !current_sentence.trim().is_empty() {
378 sentences.push(current_sentence.trim().to_string());
379 }
380 sentences
381}
382
383fn is_horizontal_rule(line: &str) -> bool {
385 if line.len() < 3 {
386 return false;
387 }
388
389 let mut chars = line.chars();
392 let Some(first_char) = chars.next() else {
393 return false;
394 };
395 if first_char != '-' && first_char != '_' && first_char != '*' {
396 return false;
397 }
398
399 let mut non_space_count = 1usize; for c in chars {
401 if c == ' ' {
402 continue;
403 }
404 if c != first_char {
405 return false;
406 }
407 non_space_count += 1;
408 }
409 non_space_count >= 3
410}
411
412fn is_numbered_list_item(line: &str) -> bool {
414 let mut chars = line.chars();
415
416 if !chars.next().is_some_and(char::is_numeric) {
418 return false;
419 }
420
421 while let Some(c) = chars.next() {
423 if c == '.' {
424 return chars.next() == Some(' ');
427 }
428 if !c.is_numeric() {
429 return false;
430 }
431 }
432
433 false
434}
435
436fn is_unordered_list_marker(s: &str) -> bool {
438 matches!(s.as_bytes().first(), Some(b'-' | b'*' | b'+'))
439 && !is_horizontal_rule(s)
440 && (s.len() == 1 || s.as_bytes().get(1) == Some(&b' '))
441}
442
443fn is_block_boundary_core(trimmed: &str) -> bool {
446 trimmed.is_empty()
447 || trimmed.starts_with('#')
448 || trimmed.starts_with("```")
449 || trimmed.starts_with("~~~")
450 || trimmed.starts_with('>')
451 || (trimmed.starts_with('[') && trimmed.contains("]:"))
452 || is_horizontal_rule(trimmed)
453 || is_unordered_list_marker(trimmed)
454 || is_numbered_list_item(trimmed)
455 || is_definition_list_item(trimmed)
456 || trimmed.starts_with(":::")
457}
458
459fn is_block_boundary(trimmed: &str) -> bool {
462 is_block_boundary_core(trimmed) || trimmed.starts_with('|')
463}
464
465fn is_paragraph_boundary(trimmed: &str, line: &str) -> bool {
469 is_block_boundary_core(trimmed)
470 || calculate_indentation_width_default(line) >= 4
471 || crate::utils::table_utils::TableUtils::is_potential_table_row(line)
472}
473
474fn has_hard_break(line: &str) -> bool {
480 let line = line.strip_suffix('\r').unwrap_or(line);
481 line.ends_with(" ") || line.ends_with('\\')
482}
483
484fn ends_with_sentence_punct(text: &str) -> bool {
486 text.ends_with('.') || text.ends_with('!') || text.ends_with('?')
487}
488
489fn trim_preserving_hard_break(s: &str) -> String {
495 let s = s.strip_suffix('\r').unwrap_or(s);
497
498 if s.ends_with('\\') {
500 return s.to_string();
502 }
503
504 if s.ends_with(" ") {
506 let content_end = s.trim_end().len();
508 if content_end == 0 {
509 return String::new();
511 }
512 format!("{} ", &s[..content_end])
514 } else {
515 s.trim_end().to_string()
517 }
518}
519
520fn parse_elements(text: &str, options: &ReflowOptions) -> Vec<Element> {
522 if options.attr_lists {
523 parse_markdown_elements_with_attr_lists(text)
524 } else {
525 parse_markdown_elements(text)
526 }
527}
528
529pub fn reflow_line(line: &str, options: &ReflowOptions) -> Vec<String> {
530 if options.sentence_per_line {
532 let elements = parse_elements(line, options);
533 return reflow_elements_sentence_per_line(&elements, &options.abbreviations, options.require_sentence_capital);
534 }
535
536 if options.semantic_line_breaks {
538 let elements = parse_elements(line, options);
539 return reflow_elements_semantic(&elements, options);
540 }
541
542 if options.line_length == 0 || display_len(line, options.length_mode) <= options.line_length {
545 return vec![line.to_string()];
546 }
547
548 let elements = parse_elements(line, options);
550
551 reflow_elements(&elements, options)
553}
554
555#[derive(Debug, Clone)]
557enum LinkedImageSource {
558 Inline(String),
560 Reference(String),
562}
563
564#[derive(Debug, Clone)]
566enum LinkedImageTarget {
567 Inline(String),
569 Reference(String),
571}
572
573#[derive(Debug, Clone)]
575enum Element {
576 Text(String),
578 Link { text: String, url: String },
580 ReferenceLink { text: String, reference: String },
582 EmptyReferenceLink { text: String },
584 ShortcutReference { reference: String },
586 InlineImage { alt: String, url: String },
588 ReferenceImage { alt: String, reference: String },
590 EmptyReferenceImage { alt: String },
592 LinkedImage {
598 alt: String,
599 img_source: LinkedImageSource,
600 link_target: LinkedImageTarget,
601 },
602 FootnoteReference { note: String },
604 Strikethrough(String),
606 WikiLink(String),
608 InlineMath(String),
610 DisplayMath(String),
612 EmojiShortcode(String),
614 Autolink(String),
616 HtmlTag(String),
618 HtmlEntity(String),
620 HugoShortcode(String),
622 AttrList(String),
624 Code(String),
626 Bold {
628 content: String,
629 underscore: bool,
631 },
632 Italic {
634 content: String,
635 underscore: bool,
637 },
638}
639
640impl std::fmt::Display for Element {
641 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
642 match self {
643 Element::Text(s) => write!(f, "{s}"),
644 Element::Link { text, url } => write!(f, "[{text}]({url})"),
645 Element::ReferenceLink { text, reference } => write!(f, "[{text}][{reference}]"),
646 Element::EmptyReferenceLink { text } => write!(f, "[{text}][]"),
647 Element::ShortcutReference { reference } => write!(f, "[{reference}]"),
648 Element::InlineImage { alt, url } => write!(f, ""),
649 Element::ReferenceImage { alt, reference } => write!(f, "![{alt}][{reference}]"),
650 Element::EmptyReferenceImage { alt } => write!(f, "![{alt}][]"),
651 Element::LinkedImage {
652 alt,
653 img_source,
654 link_target,
655 } => {
656 let img_part = match img_source {
658 LinkedImageSource::Inline(url) => format!(""),
659 LinkedImageSource::Reference(r) => format!("![{alt}][{r}]"),
660 };
661 match link_target {
663 LinkedImageTarget::Inline(url) => write!(f, "[{img_part}]({url})"),
664 LinkedImageTarget::Reference(r) => write!(f, "[{img_part}][{r}]"),
665 }
666 }
667 Element::FootnoteReference { note } => write!(f, "[^{note}]"),
668 Element::Strikethrough(s) => write!(f, "~~{s}~~"),
669 Element::WikiLink(s) => write!(f, "[[{s}]]"),
670 Element::InlineMath(s) => write!(f, "${s}$"),
671 Element::DisplayMath(s) => write!(f, "$${s}$$"),
672 Element::EmojiShortcode(s) => write!(f, ":{s}:"),
673 Element::Autolink(s) => write!(f, "{s}"),
674 Element::HtmlTag(s) => write!(f, "{s}"),
675 Element::HtmlEntity(s) => write!(f, "{s}"),
676 Element::HugoShortcode(s) => write!(f, "{s}"),
677 Element::AttrList(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}
713
714fn extract_emphasis_spans(text: &str) -> Vec<EmphasisSpan> {
724 let mut spans = Vec::new();
725 let mut options = Options::empty();
726 options.insert(Options::ENABLE_STRIKETHROUGH);
727
728 let mut emphasis_stack: Vec<(usize, bool)> = Vec::new(); let mut strong_stack: Vec<(usize, bool)> = Vec::new();
731 let mut strikethrough_stack: Vec<usize> = Vec::new();
732
733 let parser = Parser::new_ext(text, options).into_offset_iter();
734
735 for (event, range) in parser {
736 match event {
737 Event::Start(Tag::Emphasis) => {
738 let uses_underscore = text.get(range.start..range.start + 1) == Some("_");
740 emphasis_stack.push((range.start, uses_underscore));
741 }
742 Event::End(TagEnd::Emphasis) => {
743 if let Some((start_byte, uses_underscore)) = emphasis_stack.pop() {
744 let content_start = start_byte + 1;
746 let content_end = range.end - 1;
747 if content_end > content_start
748 && let Some(content) = text.get(content_start..content_end)
749 {
750 spans.push(EmphasisSpan {
751 start: start_byte,
752 end: range.end,
753 content: content.to_string(),
754 is_strong: false,
755 is_strikethrough: false,
756 uses_underscore,
757 });
758 }
759 }
760 }
761 Event::Start(Tag::Strong) => {
762 let uses_underscore = text.get(range.start..range.start + 2) == Some("__");
764 strong_stack.push((range.start, uses_underscore));
765 }
766 Event::End(TagEnd::Strong) => {
767 if let Some((start_byte, uses_underscore)) = strong_stack.pop() {
768 let content_start = start_byte + 2;
770 let content_end = range.end - 2;
771 if content_end > content_start
772 && let Some(content) = text.get(content_start..content_end)
773 {
774 spans.push(EmphasisSpan {
775 start: start_byte,
776 end: range.end,
777 content: content.to_string(),
778 is_strong: true,
779 is_strikethrough: false,
780 uses_underscore,
781 });
782 }
783 }
784 }
785 Event::Start(Tag::Strikethrough) => {
786 strikethrough_stack.push(range.start);
787 }
788 Event::End(TagEnd::Strikethrough) => {
789 if let Some(start_byte) = strikethrough_stack.pop() {
790 let content_start = start_byte + 2;
792 let content_end = range.end - 2;
793 if content_end > content_start
794 && let Some(content) = text.get(content_start..content_end)
795 {
796 spans.push(EmphasisSpan {
797 start: start_byte,
798 end: range.end,
799 content: content.to_string(),
800 is_strong: false,
801 is_strikethrough: true,
802 uses_underscore: false,
803 });
804 }
805 }
806 }
807 _ => {}
808 }
809 }
810
811 spans.sort_by_key(|s| s.start);
813 spans
814}
815
816fn parse_markdown_elements(text: &str) -> Vec<Element> {
827 parse_markdown_elements_inner(text, false)
828}
829
830fn parse_markdown_elements_with_attr_lists(text: &str) -> Vec<Element> {
831 parse_markdown_elements_inner(text, true)
832}
833
834fn parse_markdown_elements_inner(text: &str, attr_lists: bool) -> Vec<Element> {
835 let mut elements = Vec::new();
836 let mut remaining = text;
837
838 let emphasis_spans = extract_emphasis_spans(text);
840
841 while !remaining.is_empty() {
842 let current_offset = text.len() - remaining.len();
844 let mut earliest_match: Option<(usize, usize, &str)> = None;
847
848 if remaining.contains("[!") {
852 if let Some(m) = LINKED_IMAGE_INLINE_INLINE.find(remaining)
854 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
855 {
856 earliest_match = Some((m.start(), m.end(), "linked_image_ii"));
857 }
858
859 if let Some(m) = LINKED_IMAGE_REF_INLINE.find(remaining)
861 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
862 {
863 earliest_match = Some((m.start(), m.end(), "linked_image_ri"));
864 }
865
866 if let Some(m) = LINKED_IMAGE_INLINE_REF.find(remaining)
868 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
869 {
870 earliest_match = Some((m.start(), m.end(), "linked_image_ir"));
871 }
872
873 if let Some(m) = LINKED_IMAGE_REF_REF.find(remaining)
875 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
876 {
877 earliest_match = Some((m.start(), m.end(), "linked_image_rr"));
878 }
879 }
880
881 if let Some(m) = INLINE_IMAGE_REGEX.find(remaining)
884 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
885 {
886 earliest_match = Some((m.start(), m.end(), "inline_image"));
887 }
888
889 if let Some(m) = REF_IMAGE_REGEX.find(remaining)
891 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
892 {
893 earliest_match = Some((m.start(), m.end(), "ref_image"));
894 }
895
896 if let Some(m) = FOOTNOTE_REF_REGEX.find(remaining)
898 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
899 {
900 earliest_match = Some((m.start(), m.end(), "footnote_ref"));
901 }
902
903 if let Ok(Some(m)) = INLINE_LINK_FANCY_REGEX.find(remaining)
905 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
906 {
907 earliest_match = Some((m.start(), m.end(), "inline_link"));
908 }
909
910 if let Ok(Some(m)) = REF_LINK_REGEX.find(remaining)
912 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
913 {
914 earliest_match = Some((m.start(), m.end(), "ref_link"));
915 }
916
917 if let Ok(Some(m)) = SHORTCUT_REF_REGEX.find(remaining)
920 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
921 {
922 earliest_match = Some((m.start(), m.end(), "shortcut_ref"));
923 }
924
925 if let Some(m) = WIKI_LINK_REGEX.find(remaining)
927 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
928 {
929 earliest_match = Some((m.start(), m.end(), "wiki_link"));
930 }
931
932 if let Some(m) = DISPLAY_MATH_REGEX.find(remaining)
934 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
935 {
936 earliest_match = Some((m.start(), m.end(), "display_math"));
937 }
938
939 if let Ok(Some(m)) = INLINE_MATH_REGEX.find(remaining)
941 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
942 {
943 earliest_match = Some((m.start(), m.end(), "inline_math"));
944 }
945
946 if let Some(m) = EMOJI_SHORTCODE_REGEX.find(remaining)
950 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
951 {
952 earliest_match = Some((m.start(), m.end(), "emoji"));
953 }
954
955 if let Some(m) = HTML_ENTITY_REGEX.find(remaining)
957 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
958 {
959 earliest_match = Some((m.start(), m.end(), "html_entity"));
960 }
961
962 if let Some(m) = HUGO_SHORTCODE_REGEX.find(remaining)
965 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
966 {
967 earliest_match = Some((m.start(), m.end(), "hugo_shortcode"));
968 }
969
970 if let Some(m) = HTML_TAG_PATTERN.find(remaining)
973 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
974 {
975 let matched_text = &remaining[m.start()..m.end()];
977 let is_url_autolink = matched_text.starts_with("<http://")
978 || matched_text.starts_with("<https://")
979 || matched_text.starts_with("<mailto:")
980 || matched_text.starts_with("<ftp://")
981 || matched_text.starts_with("<ftps://");
982
983 let is_email_autolink = {
986 let content = matched_text.trim_start_matches('<').trim_end_matches('>');
987 EMAIL_PATTERN.is_match(content)
988 };
989
990 if is_url_autolink || is_email_autolink {
991 earliest_match = Some((m.start(), m.end(), "autolink"));
992 } else {
993 earliest_match = Some((m.start(), m.end(), "html_tag"));
994 }
995 }
996
997 let mut next_special = remaining.len();
999 let mut special_type = "";
1000 let mut pulldown_emphasis: Option<&EmphasisSpan> = None;
1001 let mut attr_list_len: usize = 0;
1002
1003 if let Some(pos) = remaining.find('`')
1005 && pos < next_special
1006 {
1007 next_special = pos;
1008 special_type = "code";
1009 }
1010
1011 if attr_lists
1013 && let Some(pos) = remaining.find('{')
1014 && pos < next_special
1015 && let Some(m) = ATTR_LIST_PATTERN.find(&remaining[pos..])
1016 && m.start() == 0
1017 {
1018 next_special = pos;
1019 special_type = "attr_list";
1020 attr_list_len = m.end();
1021 }
1022
1023 for span in &emphasis_spans {
1026 if span.start >= current_offset && span.start < current_offset + remaining.len() {
1027 let pos_in_remaining = span.start - current_offset;
1028 if pos_in_remaining < next_special {
1029 next_special = pos_in_remaining;
1030 special_type = "pulldown_emphasis";
1031 pulldown_emphasis = Some(span);
1032 }
1033 break; }
1035 }
1036
1037 let should_process_markdown_link = if let Some((pos, _, _)) = earliest_match {
1039 pos < next_special
1040 } else {
1041 false
1042 };
1043
1044 if should_process_markdown_link {
1045 let (pos, match_end, pattern_type) = earliest_match.unwrap();
1046
1047 if pos > 0 {
1049 elements.push(Element::Text(remaining[..pos].to_string()));
1050 }
1051
1052 match pattern_type {
1054 "linked_image_ii" => {
1056 if let Some(caps) = LINKED_IMAGE_INLINE_INLINE.captures(remaining) {
1057 let alt = caps.get(1).map_or("", |m| m.as_str());
1058 let img_url = caps.get(2).map_or("", |m| m.as_str());
1059 let link_url = caps.get(3).map_or("", |m| m.as_str());
1060 elements.push(Element::LinkedImage {
1061 alt: alt.to_string(),
1062 img_source: LinkedImageSource::Inline(img_url.to_string()),
1063 link_target: LinkedImageTarget::Inline(link_url.to_string()),
1064 });
1065 remaining = &remaining[match_end..];
1066 } else {
1067 elements.push(Element::Text("[".to_string()));
1068 remaining = &remaining[1..];
1069 }
1070 }
1071 "linked_image_ri" => {
1073 if let Some(caps) = LINKED_IMAGE_REF_INLINE.captures(remaining) {
1074 let alt = caps.get(1).map_or("", |m| m.as_str());
1075 let img_ref = caps.get(2).map_or("", |m| m.as_str());
1076 let link_url = caps.get(3).map_or("", |m| m.as_str());
1077 elements.push(Element::LinkedImage {
1078 alt: alt.to_string(),
1079 img_source: LinkedImageSource::Reference(img_ref.to_string()),
1080 link_target: LinkedImageTarget::Inline(link_url.to_string()),
1081 });
1082 remaining = &remaining[match_end..];
1083 } else {
1084 elements.push(Element::Text("[".to_string()));
1085 remaining = &remaining[1..];
1086 }
1087 }
1088 "linked_image_ir" => {
1090 if let Some(caps) = LINKED_IMAGE_INLINE_REF.captures(remaining) {
1091 let alt = caps.get(1).map_or("", |m| m.as_str());
1092 let img_url = caps.get(2).map_or("", |m| m.as_str());
1093 let link_ref = caps.get(3).map_or("", |m| m.as_str());
1094 elements.push(Element::LinkedImage {
1095 alt: alt.to_string(),
1096 img_source: LinkedImageSource::Inline(img_url.to_string()),
1097 link_target: LinkedImageTarget::Reference(link_ref.to_string()),
1098 });
1099 remaining = &remaining[match_end..];
1100 } else {
1101 elements.push(Element::Text("[".to_string()));
1102 remaining = &remaining[1..];
1103 }
1104 }
1105 "linked_image_rr" => {
1107 if let Some(caps) = LINKED_IMAGE_REF_REF.captures(remaining) {
1108 let alt = caps.get(1).map_or("", |m| m.as_str());
1109 let img_ref = caps.get(2).map_or("", |m| m.as_str());
1110 let link_ref = caps.get(3).map_or("", |m| m.as_str());
1111 elements.push(Element::LinkedImage {
1112 alt: alt.to_string(),
1113 img_source: LinkedImageSource::Reference(img_ref.to_string()),
1114 link_target: LinkedImageTarget::Reference(link_ref.to_string()),
1115 });
1116 remaining = &remaining[match_end..];
1117 } else {
1118 elements.push(Element::Text("[".to_string()));
1119 remaining = &remaining[1..];
1120 }
1121 }
1122 "inline_image" => {
1123 if let Some(caps) = INLINE_IMAGE_REGEX.captures(remaining) {
1124 let alt = caps.get(1).map_or("", |m| m.as_str());
1125 let url = caps.get(2).map_or("", |m| m.as_str());
1126 elements.push(Element::InlineImage {
1127 alt: alt.to_string(),
1128 url: url.to_string(),
1129 });
1130 remaining = &remaining[match_end..];
1131 } else {
1132 elements.push(Element::Text("!".to_string()));
1133 remaining = &remaining[1..];
1134 }
1135 }
1136 "ref_image" => {
1137 if let Some(caps) = REF_IMAGE_REGEX.captures(remaining) {
1138 let alt = caps.get(1).map_or("", |m| m.as_str());
1139 let reference = caps.get(2).map_or("", |m| m.as_str());
1140
1141 if reference.is_empty() {
1142 elements.push(Element::EmptyReferenceImage { alt: alt.to_string() });
1143 } else {
1144 elements.push(Element::ReferenceImage {
1145 alt: alt.to_string(),
1146 reference: reference.to_string(),
1147 });
1148 }
1149 remaining = &remaining[match_end..];
1150 } else {
1151 elements.push(Element::Text("!".to_string()));
1152 remaining = &remaining[1..];
1153 }
1154 }
1155 "footnote_ref" => {
1156 if let Some(caps) = FOOTNOTE_REF_REGEX.captures(remaining) {
1157 let note = caps.get(1).map_or("", |m| m.as_str());
1158 elements.push(Element::FootnoteReference { note: note.to_string() });
1159 remaining = &remaining[match_end..];
1160 } else {
1161 elements.push(Element::Text("[".to_string()));
1162 remaining = &remaining[1..];
1163 }
1164 }
1165 "inline_link" => {
1166 if let Ok(Some(caps)) = INLINE_LINK_FANCY_REGEX.captures(remaining) {
1167 let text = caps.get(1).map_or("", |m| m.as_str());
1168 let url = caps.get(2).map_or("", |m| m.as_str());
1169 elements.push(Element::Link {
1170 text: text.to_string(),
1171 url: url.to_string(),
1172 });
1173 remaining = &remaining[match_end..];
1174 } else {
1175 elements.push(Element::Text("[".to_string()));
1177 remaining = &remaining[1..];
1178 }
1179 }
1180 "ref_link" => {
1181 if let Ok(Some(caps)) = REF_LINK_REGEX.captures(remaining) {
1182 let text = caps.get(1).map_or("", |m| m.as_str());
1183 let reference = caps.get(2).map_or("", |m| m.as_str());
1184
1185 if reference.is_empty() {
1186 elements.push(Element::EmptyReferenceLink { text: text.to_string() });
1188 } else {
1189 elements.push(Element::ReferenceLink {
1191 text: text.to_string(),
1192 reference: reference.to_string(),
1193 });
1194 }
1195 remaining = &remaining[match_end..];
1196 } else {
1197 elements.push(Element::Text("[".to_string()));
1199 remaining = &remaining[1..];
1200 }
1201 }
1202 "shortcut_ref" => {
1203 if let Ok(Some(caps)) = SHORTCUT_REF_REGEX.captures(remaining) {
1204 let reference = caps.get(1).map_or("", |m| m.as_str());
1205 elements.push(Element::ShortcutReference {
1206 reference: reference.to_string(),
1207 });
1208 remaining = &remaining[match_end..];
1209 } else {
1210 elements.push(Element::Text("[".to_string()));
1212 remaining = &remaining[1..];
1213 }
1214 }
1215 "wiki_link" => {
1216 if let Some(caps) = WIKI_LINK_REGEX.captures(remaining) {
1217 let content = caps.get(1).map_or("", |m| m.as_str());
1218 elements.push(Element::WikiLink(content.to_string()));
1219 remaining = &remaining[match_end..];
1220 } else {
1221 elements.push(Element::Text("[[".to_string()));
1222 remaining = &remaining[2..];
1223 }
1224 }
1225 "display_math" => {
1226 if let Some(caps) = DISPLAY_MATH_REGEX.captures(remaining) {
1227 let math = caps.get(1).map_or("", |m| m.as_str());
1228 elements.push(Element::DisplayMath(math.to_string()));
1229 remaining = &remaining[match_end..];
1230 } else {
1231 elements.push(Element::Text("$$".to_string()));
1232 remaining = &remaining[2..];
1233 }
1234 }
1235 "inline_math" => {
1236 if let Ok(Some(caps)) = INLINE_MATH_REGEX.captures(remaining) {
1237 let math = caps.get(1).map_or("", |m| m.as_str());
1238 elements.push(Element::InlineMath(math.to_string()));
1239 remaining = &remaining[match_end..];
1240 } else {
1241 elements.push(Element::Text("$".to_string()));
1242 remaining = &remaining[1..];
1243 }
1244 }
1245 "emoji" => {
1247 if let Some(caps) = EMOJI_SHORTCODE_REGEX.captures(remaining) {
1248 let emoji = caps.get(1).map_or("", |m| m.as_str());
1249 elements.push(Element::EmojiShortcode(emoji.to_string()));
1250 remaining = &remaining[match_end..];
1251 } else {
1252 elements.push(Element::Text(":".to_string()));
1253 remaining = &remaining[1..];
1254 }
1255 }
1256 "html_entity" => {
1257 elements.push(Element::HtmlEntity(remaining[pos..match_end].to_string()));
1259 remaining = &remaining[match_end..];
1260 }
1261 "hugo_shortcode" => {
1262 elements.push(Element::HugoShortcode(remaining[pos..match_end].to_string()));
1264 remaining = &remaining[match_end..];
1265 }
1266 "autolink" => {
1267 elements.push(Element::Autolink(remaining[pos..match_end].to_string()));
1269 remaining = &remaining[match_end..];
1270 }
1271 "html_tag" => {
1272 elements.push(Element::HtmlTag(remaining[pos..match_end].to_string()));
1274 remaining = &remaining[match_end..];
1275 }
1276 _ => {
1277 elements.push(Element::Text("[".to_string()));
1279 remaining = &remaining[1..];
1280 }
1281 }
1282 } else {
1283 if next_special > 0 && next_special < remaining.len() {
1287 elements.push(Element::Text(remaining[..next_special].to_string()));
1288 remaining = &remaining[next_special..];
1289 }
1290
1291 match special_type {
1293 "code" => {
1294 if let Some(code_end) = remaining[1..].find('`') {
1296 let code = &remaining[1..=code_end];
1297 elements.push(Element::Code(code.to_string()));
1298 remaining = &remaining[1 + code_end + 1..];
1299 } else {
1300 elements.push(Element::Text(remaining.to_string()));
1302 break;
1303 }
1304 }
1305 "attr_list" => {
1306 elements.push(Element::AttrList(remaining[..attr_list_len].to_string()));
1307 remaining = &remaining[attr_list_len..];
1308 }
1309 "pulldown_emphasis" => {
1310 if let Some(span) = pulldown_emphasis {
1312 let span_len = span.end - span.start;
1313 if span.is_strikethrough {
1314 elements.push(Element::Strikethrough(span.content.clone()));
1315 } else if span.is_strong {
1316 elements.push(Element::Bold {
1317 content: span.content.clone(),
1318 underscore: span.uses_underscore,
1319 });
1320 } else {
1321 elements.push(Element::Italic {
1322 content: span.content.clone(),
1323 underscore: span.uses_underscore,
1324 });
1325 }
1326 remaining = &remaining[span_len..];
1327 } else {
1328 elements.push(Element::Text(remaining[..1].to_string()));
1330 remaining = &remaining[1..];
1331 }
1332 }
1333 _ => {
1334 elements.push(Element::Text(remaining.to_string()));
1336 break;
1337 }
1338 }
1339 }
1340 }
1341
1342 elements
1343}
1344
1345fn should_insert_space_before_join(current: &str) -> bool {
1346 !current.is_empty()
1347 && !current.ends_with(' ')
1348 && !current.ends_with('(')
1349 && !current.ends_with('[')
1350 && !current.ends_with('-')
1351}
1352
1353fn reflow_elements_sentence_per_line(
1355 elements: &[Element],
1356 custom_abbreviations: &Option<Vec<String>>,
1357 require_sentence_capital: bool,
1358) -> Vec<String> {
1359 let abbreviations = get_abbreviations(custom_abbreviations);
1360 let mut lines = Vec::new();
1361 let mut current_line = String::new();
1362
1363 for (idx, element) in elements.iter().enumerate() {
1364 let element_str = format!("{element}");
1365
1366 if let Element::Text(text) = element {
1368 let combined = format!("{current_line}{text}");
1370 let sentences = split_into_sentences_with_set(&combined, &abbreviations, require_sentence_capital);
1372
1373 if sentences.len() > 1 {
1374 for (i, sentence) in sentences.iter().enumerate() {
1376 if i == 0 {
1377 let trimmed = sentence.trim();
1380
1381 if text_ends_with_abbreviation(trimmed, &abbreviations) {
1382 current_line.clone_from(sentence);
1384 } else {
1385 lines.push(sentence.clone());
1387 current_line.clear();
1388 }
1389 } else if i == sentences.len() - 1 {
1390 let trimmed = sentence.trim();
1392 let ends_with_sentence_punct = ends_with_sentence_punct(trimmed);
1393
1394 if ends_with_sentence_punct && !text_ends_with_abbreviation(trimmed, &abbreviations) {
1395 lines.push(sentence.clone());
1397 current_line.clear();
1398 } else {
1399 current_line.clone_from(sentence);
1401 }
1402 } else {
1403 lines.push(sentence.clone());
1405 }
1406 }
1407 } else {
1408 let trimmed = combined.trim();
1410
1411 if trimmed.is_empty() {
1415 continue;
1416 }
1417
1418 let ends_with_sentence_punct = ends_with_sentence_punct(trimmed);
1419
1420 if ends_with_sentence_punct && !text_ends_with_abbreviation(trimmed, &abbreviations) {
1421 lines.push(trimmed.to_string());
1423 current_line.clear();
1424 } else {
1425 current_line = combined;
1427 }
1428 }
1429 } else if let Element::Italic { content, underscore } = element {
1430 let marker = if *underscore { "_" } else { "*" };
1432 handle_emphasis_sentence_split(
1433 content,
1434 marker,
1435 &abbreviations,
1436 require_sentence_capital,
1437 &mut current_line,
1438 &mut lines,
1439 );
1440 } else if let Element::Bold { content, underscore } = element {
1441 let marker = if *underscore { "__" } else { "**" };
1443 handle_emphasis_sentence_split(
1444 content,
1445 marker,
1446 &abbreviations,
1447 require_sentence_capital,
1448 &mut current_line,
1449 &mut lines,
1450 );
1451 } else if let Element::Strikethrough(content) = element {
1452 handle_emphasis_sentence_split(
1454 content,
1455 "~~",
1456 &abbreviations,
1457 require_sentence_capital,
1458 &mut current_line,
1459 &mut lines,
1460 );
1461 } else {
1462 let is_adjacent = if idx > 0 {
1465 match &elements[idx - 1] {
1466 Element::Text(t) => !t.is_empty() && !t.ends_with(char::is_whitespace),
1467 _ => true,
1468 }
1469 } else {
1470 false
1471 };
1472
1473 if !is_adjacent && should_insert_space_before_join(¤t_line) {
1475 current_line.push(' ');
1476 }
1477 current_line.push_str(&element_str);
1478 }
1479 }
1480
1481 if !current_line.is_empty() {
1483 lines.push(current_line.trim().to_string());
1484 }
1485 lines
1486}
1487
1488fn handle_emphasis_sentence_split(
1490 content: &str,
1491 marker: &str,
1492 abbreviations: &HashSet<String>,
1493 require_sentence_capital: bool,
1494 current_line: &mut String,
1495 lines: &mut Vec<String>,
1496) {
1497 let sentences = split_into_sentences_with_set(content, abbreviations, require_sentence_capital);
1499
1500 if sentences.len() <= 1 {
1501 if should_insert_space_before_join(current_line) {
1503 current_line.push(' ');
1504 }
1505 current_line.push_str(marker);
1506 current_line.push_str(content);
1507 current_line.push_str(marker);
1508
1509 let trimmed = content.trim();
1511 let ends_with_punct = ends_with_sentence_punct(trimmed);
1512 if ends_with_punct && !text_ends_with_abbreviation(trimmed, abbreviations) {
1513 lines.push(current_line.clone());
1514 current_line.clear();
1515 }
1516 } else {
1517 for (i, sentence) in sentences.iter().enumerate() {
1519 let trimmed = sentence.trim();
1520 if trimmed.is_empty() {
1521 continue;
1522 }
1523
1524 if i == 0 {
1525 if should_insert_space_before_join(current_line) {
1527 current_line.push(' ');
1528 }
1529 current_line.push_str(marker);
1530 current_line.push_str(trimmed);
1531 current_line.push_str(marker);
1532
1533 let ends_with_punct = ends_with_sentence_punct(trimmed);
1535 if ends_with_punct && !text_ends_with_abbreviation(trimmed, abbreviations) {
1536 lines.push(current_line.clone());
1537 current_line.clear();
1538 }
1539 } else if i == sentences.len() - 1 {
1540 let ends_with_punct = ends_with_sentence_punct(trimmed);
1542
1543 let mut line = String::new();
1544 line.push_str(marker);
1545 line.push_str(trimmed);
1546 line.push_str(marker);
1547
1548 if ends_with_punct && !text_ends_with_abbreviation(trimmed, abbreviations) {
1549 lines.push(line);
1550 } else {
1551 *current_line = line;
1553 }
1554 } else {
1555 let mut line = String::new();
1557 line.push_str(marker);
1558 line.push_str(trimmed);
1559 line.push_str(marker);
1560 lines.push(line);
1561 }
1562 }
1563 }
1564}
1565
1566const BREAK_WORDS: &[&str] = &[
1570 "and",
1571 "or",
1572 "but",
1573 "nor",
1574 "yet",
1575 "so",
1576 "for",
1577 "which",
1578 "that",
1579 "because",
1580 "when",
1581 "if",
1582 "while",
1583 "where",
1584 "although",
1585 "though",
1586 "unless",
1587 "since",
1588 "after",
1589 "before",
1590 "until",
1591 "as",
1592 "once",
1593 "whether",
1594 "however",
1595 "therefore",
1596 "moreover",
1597 "furthermore",
1598 "nevertheless",
1599 "whereas",
1600];
1601
1602fn is_clause_punctuation(c: char) -> bool {
1604 matches!(c, ',' | ';' | ':' | '\u{2014}') }
1606
1607fn paren_group_end<'a>(slice: &'a str, element_spans: &[(usize, usize)], offset: usize) -> Option<(usize, &'a str)> {
1621 debug_assert!(slice.starts_with('('));
1622 let mut depth: i32 = 0;
1623 for (local_byte, c) in slice.char_indices() {
1624 let global_byte = offset + local_byte;
1625 if depth > 0 && is_inside_element(global_byte, element_spans) {
1630 continue;
1631 }
1632 match c {
1633 '(' => depth += 1,
1634 ')' => {
1635 depth -= 1;
1636 if depth == 0 {
1637 let end = local_byte + 1;
1638 let inner = &slice[1..local_byte];
1639 return Some((end, inner));
1640 }
1641 }
1642 _ => {}
1643 }
1644 }
1645 None
1646}
1647
1648fn split_at_parenthetical(
1665 text: &str,
1666 line_length: usize,
1667 element_spans: &[(usize, usize)],
1668 length_mode: ReflowLengthMode,
1669) -> Option<(String, String)> {
1670 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
1671
1672 if text.starts_with('(')
1674 && let Some((end_local, inner)) = paren_group_end(text, element_spans, 0)
1675 && inner.contains(' ')
1676 {
1677 let tail = &text[end_local..];
1681 let attached_len = tail
1682 .char_indices()
1683 .take_while(|(_, c)| is_closing_quote(*c) || is_clause_punctuation(*c))
1684 .last()
1685 .map_or(0, |(idx, c)| idx + c.len_utf8());
1686 let first_end = end_local + attached_len;
1687 let rest_start = first_end;
1688 let first = &text[..first_end];
1689 let first_len = display_len(first, length_mode);
1690 if first_len <= line_length {
1693 let rest = text[rest_start..].trim_start();
1694 if !rest.is_empty() {
1695 return Some((first.to_string(), rest.to_string()));
1696 }
1697 }
1698 }
1699
1700 let mut best_open_byte: Option<usize> = None;
1702 let mut pos = 0usize;
1703 while pos < text.len() {
1704 if text.as_bytes()[pos] != b'(' {
1706 let c = text[pos..].chars().next().unwrap();
1707 pos += c.len_utf8();
1708 continue;
1709 }
1710 if is_inside_element(pos, element_spans) {
1712 pos += 1;
1713 continue;
1714 }
1715 if let Some((end_local, inner)) = paren_group_end(&text[pos..], element_spans, pos) {
1716 let first = text[..pos].trim_end();
1717 let first_len = display_len(first, length_mode);
1718 if !first.is_empty()
1719 && first_len >= min_first_len
1720 && first_len <= line_length
1721 && inner.contains(' ')
1722 && best_open_byte.is_none_or(|prev| pos > prev)
1723 {
1724 best_open_byte = Some(pos);
1725 }
1726 pos += end_local;
1727 } else {
1728 pos += 1;
1729 }
1730 }
1731
1732 let open_byte = best_open_byte?;
1733 let first = text[..open_byte].trim_end().to_string();
1734 let rest = text[open_byte..].to_string();
1735 if first.is_empty() || rest.trim().is_empty() {
1736 return None;
1737 }
1738 Some((first, rest))
1739}
1740
1741fn compute_element_spans(elements: &[Element]) -> Vec<(usize, usize)> {
1745 let mut spans = Vec::new();
1746 let mut offset = 0;
1747 for element in elements {
1748 let rendered = format!("{element}");
1749 let len = rendered.len();
1750 if !matches!(element, Element::Text(_)) {
1751 spans.push((offset, offset + len));
1752 }
1753 offset += len;
1754 }
1755 spans
1756}
1757
1758fn is_inside_element(pos: usize, spans: &[(usize, usize)]) -> bool {
1760 spans.iter().any(|(start, end)| pos > *start && pos < *end)
1761}
1762
1763const MIN_SPLIT_RATIO: f64 = 0.3;
1766
1767fn split_at_clause_punctuation(
1771 text: &str,
1772 line_length: usize,
1773 element_spans: &[(usize, usize)],
1774 length_mode: ReflowLengthMode,
1775) -> Option<(String, String)> {
1776 let chars: Vec<char> = text.chars().collect();
1777 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
1778
1779 let mut width_acc = 0;
1781 let mut search_end_char = 0;
1782 for (idx, &c) in chars.iter().enumerate() {
1783 let c_width = display_len(&c.to_string(), length_mode);
1784 if width_acc + c_width > line_length {
1785 break;
1786 }
1787 width_acc += c_width;
1788 search_end_char = idx + 1;
1789 }
1790
1791 let mut paren_depth: i32 = 0;
1798 let mut best_pos = None;
1799 for i in (0..search_end_char).rev() {
1800 let byte_start: usize = chars[..i].iter().map(|c| c.len_utf8()).sum();
1802 let byte_after: usize = byte_start + chars[i].len_utf8();
1804
1805 if !is_inside_element(byte_start, element_spans) {
1806 match chars[i] {
1807 ')' => paren_depth += 1,
1808 '(' => paren_depth = paren_depth.saturating_sub(1),
1809 _ => {}
1810 }
1811 }
1812
1813 if paren_depth == 0 && is_clause_punctuation(chars[i]) && !is_inside_element(byte_after, element_spans) {
1814 best_pos = Some(i);
1815 break;
1816 }
1817 }
1818
1819 let pos = best_pos?;
1820
1821 let first: String = chars[..=pos].iter().collect();
1823 let first_display_len = display_len(&first, length_mode);
1824 if first_display_len < min_first_len {
1825 return None;
1826 }
1827
1828 let rest: String = chars[pos + 1..].iter().collect();
1830 let rest = rest.trim_start().to_string();
1831
1832 if rest.is_empty() {
1833 return None;
1834 }
1835
1836 Some((first, rest))
1837}
1838
1839fn paren_depth_map(text: &str, element_spans: &[(usize, usize)]) -> Vec<i32> {
1846 let mut map = vec![0i32; text.len()];
1847 let mut depth = 0i32;
1848 for (byte, c) in text.char_indices() {
1849 if !is_inside_element(byte, element_spans) {
1850 match c {
1851 '(' => depth += 1,
1852 ')' => depth = depth.saturating_sub(1),
1853 _ => {}
1854 }
1855 }
1856 let end = (byte + c.len_utf8()).min(map.len());
1858 for slot in &mut map[byte..end] {
1859 *slot = depth;
1860 }
1861 }
1862 map
1863}
1864
1865fn is_standalone_parenthetical(line: &str) -> bool {
1874 let trimmed = line.trim();
1875 if !trimmed.starts_with('(') {
1876 return false;
1877 }
1878 let core = trimmed.trim_end_matches(|c: char| is_clause_punctuation(c));
1880 if !core.ends_with(')') {
1881 return false;
1882 }
1883 let inner = &core[1..core.len() - 1];
1885 if !inner.contains(' ') {
1886 return false;
1887 }
1888 let mut depth = 0i32;
1890 for c in core.chars() {
1891 match c {
1892 '(' => depth += 1,
1893 ')' => depth -= 1,
1894 _ => {}
1895 }
1896 if depth < 0 {
1897 return false;
1898 }
1899 }
1900 depth == 0
1901}
1902
1903fn split_at_break_word(
1907 text: &str,
1908 line_length: usize,
1909 element_spans: &[(usize, usize)],
1910 length_mode: ReflowLengthMode,
1911) -> Option<(String, String)> {
1912 let lower = text.to_lowercase();
1913 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
1914 let mut best_split: Option<(usize, usize)> = None; let depth_map = paren_depth_map(text, element_spans);
1919
1920 for &word in BREAK_WORDS {
1921 let mut search_start = 0;
1922 while let Some(pos) = lower[search_start..].find(word) {
1923 let abs_pos = search_start + pos;
1924
1925 let preceded_by_space = abs_pos == 0 || text.as_bytes().get(abs_pos - 1) == Some(&b' ');
1927 let followed_by_space = text.as_bytes().get(abs_pos + word.len()) == Some(&b' ');
1928
1929 if preceded_by_space && followed_by_space {
1930 let first_part = text[..abs_pos].trim_end();
1932 let first_part_len = display_len(first_part, length_mode);
1933
1934 let inside_paren = depth_map.get(abs_pos).is_some_and(|&d| d > 0);
1936
1937 if first_part_len >= min_first_len
1938 && first_part_len <= line_length
1939 && !is_inside_element(abs_pos, element_spans)
1940 && !inside_paren
1941 {
1942 if best_split.is_none_or(|(prev_pos, _)| abs_pos > prev_pos) {
1944 best_split = Some((abs_pos, word.len()));
1945 }
1946 }
1947 }
1948
1949 search_start = abs_pos + word.len();
1950 }
1951 }
1952
1953 let (byte_start, _word_len) = best_split?;
1954
1955 let first = text[..byte_start].trim_end().to_string();
1956 let rest = text[byte_start..].to_string();
1957
1958 if first.is_empty() || rest.trim().is_empty() {
1959 return None;
1960 }
1961
1962 Some((first, rest))
1963}
1964
1965fn cascade_split_line(
1968 text: &str,
1969 line_length: usize,
1970 abbreviations: &Option<Vec<String>>,
1971 length_mode: ReflowLengthMode,
1972 attr_lists: bool,
1973) -> Vec<String> {
1974 if line_length == 0 || display_len(text, length_mode) <= line_length {
1975 return vec![text.to_string()];
1976 }
1977
1978 let elements = parse_markdown_elements_inner(text, attr_lists);
1979 let element_spans = compute_element_spans(&elements);
1980
1981 if let Some((first, rest)) = split_at_parenthetical(text, line_length, &element_spans, length_mode) {
1984 let mut result = vec![first];
1985 result.extend(cascade_split_line(
1986 &rest,
1987 line_length,
1988 abbreviations,
1989 length_mode,
1990 attr_lists,
1991 ));
1992 return result;
1993 }
1994
1995 if let Some((first, rest)) = split_at_clause_punctuation(text, line_length, &element_spans, length_mode) {
1997 let mut result = vec![first];
1998 result.extend(cascade_split_line(
1999 &rest,
2000 line_length,
2001 abbreviations,
2002 length_mode,
2003 attr_lists,
2004 ));
2005 return result;
2006 }
2007
2008 if let Some((first, rest)) = split_at_break_word(text, line_length, &element_spans, length_mode) {
2010 let mut result = vec![first];
2011 result.extend(cascade_split_line(
2012 &rest,
2013 line_length,
2014 abbreviations,
2015 length_mode,
2016 attr_lists,
2017 ));
2018 return result;
2019 }
2020
2021 let options = ReflowOptions {
2023 line_length,
2024 break_on_sentences: false,
2025 preserve_breaks: false,
2026 sentence_per_line: false,
2027 semantic_line_breaks: false,
2028 abbreviations: abbreviations.clone(),
2029 length_mode,
2030 attr_lists,
2031 require_sentence_capital: true,
2032 max_list_continuation_indent: None,
2033 };
2034 reflow_elements(&elements, &options)
2035}
2036
2037fn reflow_elements_semantic(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
2041 let sentence_lines =
2043 reflow_elements_sentence_per_line(elements, &options.abbreviations, options.require_sentence_capital);
2044
2045 if options.line_length == 0 {
2048 return sentence_lines;
2049 }
2050
2051 let length_mode = options.length_mode;
2052 let mut result = Vec::new();
2053 for line in sentence_lines {
2054 if display_len(&line, length_mode) <= options.line_length {
2055 result.push(line);
2056 } else {
2057 result.extend(cascade_split_line(
2058 &line,
2059 options.line_length,
2060 &options.abbreviations,
2061 length_mode,
2062 options.attr_lists,
2063 ));
2064 }
2065 }
2066
2067 let min_line_len = ((options.line_length as f64) * MIN_SPLIT_RATIO) as usize;
2070 let mut merged: Vec<String> = Vec::with_capacity(result.len());
2071 for line in result {
2072 if !merged.is_empty() && display_len(&line, length_mode) < min_line_len && !line.trim().is_empty() {
2073 if is_standalone_parenthetical(&line) {
2076 merged.push(line);
2077 continue;
2078 }
2079
2080 let prev_ends_at_sentence = {
2082 let trimmed = merged.last().unwrap().trim_end();
2083 trimmed
2084 .chars()
2085 .rev()
2086 .find(|c| !matches!(c, '"' | '\'' | '\u{201D}' | '\u{2019}' | ')' | ']'))
2087 .is_some_and(|c| matches!(c, '.' | '!' | '?'))
2088 };
2089
2090 if !prev_ends_at_sentence {
2091 let prev = merged.last_mut().unwrap();
2092 let combined = format!("{prev} {line}");
2093 if display_len(&combined, length_mode) <= options.line_length {
2095 *prev = combined;
2096 continue;
2097 }
2098 }
2099 }
2100 merged.push(line);
2101 }
2102 merged
2103}
2104
2105fn rfind_safe_space(line: &str, element_spans: &[(usize, usize)]) -> Option<usize> {
2113 line.char_indices()
2114 .rev()
2115 .map(|(pos, _)| pos)
2116 .find(|&pos| line.as_bytes()[pos] == b' ' && !element_spans.iter().any(|(s, e)| pos > *s && pos < *e))
2117}
2118
2119fn reflow_elements(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
2121 let mut lines = Vec::new();
2122 let mut current_line = String::new();
2123 let mut current_length = 0;
2124 let mut current_line_element_spans: Vec<(usize, usize)> = Vec::new();
2126 let length_mode = options.length_mode;
2127
2128 for (idx, element) in elements.iter().enumerate() {
2129 let element_str = format!("{element}");
2132 let element_len = display_len(&element_str, length_mode);
2133
2134 let is_adjacent_to_prev = if idx > 0 {
2140 match (&elements[idx - 1], element) {
2141 (Element::Text(t), _) => !t.is_empty() && !t.ends_with(char::is_whitespace),
2142 (_, Element::Text(t)) => !t.is_empty() && !t.starts_with(char::is_whitespace),
2143 _ => true,
2144 }
2145 } else {
2146 false
2147 };
2148
2149 if let Element::Text(text) = element {
2151 let has_leading_space = text.starts_with(char::is_whitespace);
2153 let words: Vec<&str> = text.split_whitespace().collect();
2155
2156 for (i, word) in words.iter().enumerate() {
2157 let word_len = display_len(word, length_mode);
2158 let is_trailing_punct = word
2160 .chars()
2161 .all(|c| matches!(c, ',' | '.' | ':' | ';' | '!' | '?' | ')' | ']' | '}'));
2162
2163 let is_first_adjacent = i == 0 && is_adjacent_to_prev;
2166
2167 if is_first_adjacent {
2168 if current_length + word_len > options.line_length && current_length > 0 {
2170 if let Some(last_space) = rfind_safe_space(¤t_line, ¤t_line_element_spans) {
2173 let before = current_line[..last_space].trim_end().to_string();
2174 let after = current_line[last_space + 1..].to_string();
2175 lines.push(before);
2176 current_line = format!("{after}{word}");
2177 current_length = display_len(¤t_line, length_mode);
2178 current_line_element_spans.clear();
2179 } else {
2180 current_line.push_str(word);
2181 current_length += word_len;
2182 }
2183 } else {
2184 current_line.push_str(word);
2185 current_length += word_len;
2186 }
2187 } else if current_length > 0
2188 && current_length + 1 + word_len > options.line_length
2189 && !is_trailing_punct
2190 {
2191 lines.push(current_line.trim().to_string());
2193 current_line = word.to_string();
2194 current_length = word_len;
2195 current_line_element_spans.clear();
2196 } else {
2197 if current_length > 0 && (i > 0 || has_leading_space) && !is_trailing_punct {
2201 current_line.push(' ');
2202 current_length += 1;
2203 }
2204 current_line.push_str(word);
2205 current_length += word_len;
2206 }
2207 }
2208 } else if matches!(
2209 element,
2210 Element::Italic { .. } | Element::Bold { .. } | Element::Strikethrough(_)
2211 ) && element_len > options.line_length
2212 {
2213 let (content, marker): (&str, &str) = match element {
2217 Element::Italic { content, underscore } => (content.as_str(), if *underscore { "_" } else { "*" }),
2218 Element::Bold { content, underscore } => (content.as_str(), if *underscore { "__" } else { "**" }),
2219 Element::Strikethrough(content) => (content.as_str(), "~~"),
2220 _ => unreachable!(),
2221 };
2222
2223 let words: Vec<&str> = content.split_whitespace().collect();
2224 let n = words.len();
2225
2226 if n == 0 {
2227 let full = format!("{marker}{marker}");
2229 let full_len = display_len(&full, length_mode);
2230 if !is_adjacent_to_prev && current_length > 0 {
2231 current_line.push(' ');
2232 current_length += 1;
2233 }
2234 current_line.push_str(&full);
2235 current_length += full_len;
2236 } else {
2237 for (i, word) in words.iter().enumerate() {
2238 let is_first = i == 0;
2239 let is_last = i == n - 1;
2240 let word_str: String = match (is_first, is_last) {
2241 (true, true) => format!("{marker}{word}{marker}"),
2242 (true, false) => format!("{marker}{word}"),
2243 (false, true) => format!("{word}{marker}"),
2244 (false, false) => word.to_string(),
2245 };
2246 let word_len = display_len(&word_str, length_mode);
2247
2248 let needs_space = if is_first {
2249 !is_adjacent_to_prev && current_length > 0
2250 } else {
2251 current_length > 0
2252 };
2253
2254 if needs_space && current_length + 1 + word_len > options.line_length {
2255 lines.push(current_line.trim_end().to_string());
2256 current_line = word_str;
2257 current_length = word_len;
2258 current_line_element_spans.clear();
2259 } else {
2260 if needs_space {
2261 current_line.push(' ');
2262 current_length += 1;
2263 }
2264 current_line.push_str(&word_str);
2265 current_length += word_len;
2266 }
2267 }
2268 }
2269 } else {
2270 if is_adjacent_to_prev {
2274 if current_length + element_len > options.line_length {
2276 if let Some(last_space) = rfind_safe_space(¤t_line, ¤t_line_element_spans) {
2279 let before = current_line[..last_space].trim_end().to_string();
2280 let after = current_line[last_space + 1..].to_string();
2281 lines.push(before);
2282 current_line = format!("{after}{element_str}");
2283 current_length = display_len(¤t_line, length_mode);
2284 current_line_element_spans.clear();
2285 let start = after.len();
2287 current_line_element_spans.push((start, start + element_str.len()));
2288 } else {
2289 let start = current_line.len();
2291 current_line.push_str(&element_str);
2292 current_length += element_len;
2293 current_line_element_spans.push((start, current_line.len()));
2294 }
2295 } else {
2296 let start = current_line.len();
2297 current_line.push_str(&element_str);
2298 current_length += element_len;
2299 current_line_element_spans.push((start, current_line.len()));
2300 }
2301 } else if current_length > 0 && current_length + 1 + element_len > options.line_length {
2302 lines.push(current_line.trim().to_string());
2304 current_line.clone_from(&element_str);
2305 current_length = element_len;
2306 current_line_element_spans.clear();
2307 current_line_element_spans.push((0, element_str.len()));
2308 } else {
2309 let ends_with_opener =
2311 current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
2312 if current_length > 0 && !ends_with_opener {
2313 current_line.push(' ');
2314 current_length += 1;
2315 }
2316 let start = current_line.len();
2317 current_line.push_str(&element_str);
2318 current_length += element_len;
2319 current_line_element_spans.push((start, current_line.len()));
2320 }
2321 }
2322 }
2323
2324 if !current_line.is_empty() {
2326 lines.push(current_line.trim_end().to_string());
2327 }
2328
2329 lines
2330}
2331
2332pub fn reflow_markdown(content: &str, options: &ReflowOptions) -> String {
2334 let lines: Vec<&str> = content.lines().collect();
2335 let mut result = Vec::new();
2336 let mut i = 0;
2337
2338 while i < lines.len() {
2339 let line = lines[i];
2340 let trimmed = line.trim();
2341
2342 if trimmed.is_empty() {
2344 result.push(String::new());
2345 i += 1;
2346 continue;
2347 }
2348
2349 if trimmed.starts_with('#') {
2351 result.push(line.to_string());
2352 i += 1;
2353 continue;
2354 }
2355
2356 if trimmed.starts_with(":::") {
2358 result.push(line.to_string());
2359 i += 1;
2360 continue;
2361 }
2362
2363 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
2365 result.push(line.to_string());
2366 i += 1;
2367 while i < lines.len() {
2369 result.push(lines[i].to_string());
2370 if lines[i].trim().starts_with("```") || lines[i].trim().starts_with("~~~") {
2371 i += 1;
2372 break;
2373 }
2374 i += 1;
2375 }
2376 continue;
2377 }
2378
2379 if calculate_indentation_width_default(line) >= 4 {
2381 result.push(line.to_string());
2383 i += 1;
2384 while i < lines.len() {
2385 let next_line = lines[i];
2386 if calculate_indentation_width_default(next_line) >= 4 || next_line.trim().is_empty() {
2388 result.push(next_line.to_string());
2389 i += 1;
2390 } else {
2391 break;
2392 }
2393 }
2394 continue;
2395 }
2396
2397 if trimmed.starts_with('>') {
2399 let gt_pos = line.find('>').expect("'>' must exist since trimmed.starts_with('>')");
2402 let quote_prefix = line[0..=gt_pos].to_string();
2403 let quote_content = &line[quote_prefix.len()..].trim_start();
2404
2405 let reflowed = reflow_line(quote_content, options);
2406 for reflowed_line in &reflowed {
2407 result.push(format!("{quote_prefix} {reflowed_line}"));
2408 }
2409 i += 1;
2410 continue;
2411 }
2412
2413 if is_horizontal_rule(trimmed) {
2415 result.push(line.to_string());
2416 i += 1;
2417 continue;
2418 }
2419
2420 if is_unordered_list_marker(trimmed) || is_numbered_list_item(trimmed) {
2422 let indent = line.len() - line.trim_start().len();
2424 let indent_str = " ".repeat(indent);
2425
2426 let mut marker_end = indent;
2429 let mut content_start = indent;
2430
2431 if trimmed.chars().next().is_some_and(char::is_numeric) {
2432 if let Some(period_pos) = line[indent..].find('.') {
2434 marker_end = indent + period_pos + 1; content_start = marker_end;
2436 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
2440 content_start += 1;
2441 }
2442 }
2443 } else {
2444 marker_end = indent + 1; content_start = marker_end;
2447 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
2451 content_start += 1;
2452 }
2453 }
2454
2455 let min_continuation_indent = content_start;
2457
2458 let rest = &line[content_start..];
2461 if rest.starts_with("[ ] ") || rest.starts_with("[x] ") || rest.starts_with("[X] ") {
2462 marker_end = content_start + 3; content_start += 4; }
2465
2466 let marker = &line[indent..marker_end];
2467
2468 let mut list_content = vec![trim_preserving_hard_break(&line[content_start..])];
2471 i += 1;
2472
2473 while i < lines.len() {
2477 let next_line = lines[i];
2478 let next_trimmed = next_line.trim();
2479
2480 if is_block_boundary(next_trimmed) {
2482 break;
2483 }
2484
2485 let next_indent = next_line.len() - next_line.trim_start().len();
2487 if next_indent >= min_continuation_indent {
2488 let trimmed_start = next_line.trim_start();
2491 list_content.push(trim_preserving_hard_break(trimmed_start));
2492 i += 1;
2493 } else {
2494 break;
2496 }
2497 }
2498
2499 let combined_content = if options.preserve_breaks {
2502 list_content[0].clone()
2503 } else {
2504 let has_hard_breaks = list_content.iter().any(|line| has_hard_break(line));
2506 if has_hard_breaks {
2507 list_content.join("\n")
2509 } else {
2510 list_content.join(" ")
2512 }
2513 };
2514
2515 let trimmed_marker = marker;
2517 let continuation_spaces = if let Some(max_indent) = options.max_list_continuation_indent {
2518 indent + (content_start - indent).min(max_indent)
2521 } else {
2522 content_start
2523 };
2524
2525 let prefix_length = indent + trimmed_marker.len() + 1;
2527
2528 let adjusted_options = ReflowOptions {
2530 line_length: options.line_length.saturating_sub(prefix_length),
2531 ..options.clone()
2532 };
2533
2534 let reflowed = reflow_line(&combined_content, &adjusted_options);
2535 for (j, reflowed_line) in reflowed.iter().enumerate() {
2536 if j == 0 {
2537 result.push(format!("{indent_str}{trimmed_marker} {reflowed_line}"));
2538 } else {
2539 let continuation_indent = " ".repeat(continuation_spaces);
2541 result.push(format!("{continuation_indent}{reflowed_line}"));
2542 }
2543 }
2544 continue;
2545 }
2546
2547 if crate::utils::table_utils::TableUtils::is_potential_table_row(line) {
2549 result.push(line.to_string());
2550 i += 1;
2551 continue;
2552 }
2553
2554 if trimmed.starts_with('[') && line.contains("]:") {
2556 result.push(line.to_string());
2557 i += 1;
2558 continue;
2559 }
2560
2561 if is_definition_list_item(trimmed) {
2563 result.push(line.to_string());
2564 i += 1;
2565 continue;
2566 }
2567
2568 let mut is_single_line_paragraph = true;
2570 if i + 1 < lines.len() {
2571 let next_trimmed = lines[i + 1].trim();
2572 if !is_block_boundary(next_trimmed) {
2574 is_single_line_paragraph = false;
2575 }
2576 }
2577
2578 if is_single_line_paragraph && display_len(line, options.length_mode) <= options.line_length {
2580 result.push(line.to_string());
2581 i += 1;
2582 continue;
2583 }
2584
2585 let mut paragraph_parts = Vec::new();
2587 let mut current_part = vec![line];
2588 i += 1;
2589
2590 if options.preserve_breaks {
2592 let hard_break_type = if line.strip_suffix('\r').unwrap_or(line).ends_with('\\') {
2594 Some("\\")
2595 } else if line.ends_with(" ") {
2596 Some(" ")
2597 } else {
2598 None
2599 };
2600 let reflowed = reflow_line(line, options);
2601
2602 if let Some(break_marker) = hard_break_type {
2604 if !reflowed.is_empty() {
2605 let mut reflowed_with_break = reflowed;
2606 let last_idx = reflowed_with_break.len() - 1;
2607 if !has_hard_break(&reflowed_with_break[last_idx]) {
2608 reflowed_with_break[last_idx].push_str(break_marker);
2609 }
2610 result.extend(reflowed_with_break);
2611 }
2612 } else {
2613 result.extend(reflowed);
2614 }
2615 } else {
2616 while i < lines.len() {
2618 let prev_line = if !current_part.is_empty() {
2619 current_part.last().unwrap()
2620 } else {
2621 ""
2622 };
2623 let next_line = lines[i];
2624 let next_trimmed = next_line.trim();
2625
2626 if is_block_boundary(next_trimmed) {
2628 break;
2629 }
2630
2631 let prev_trimmed = prev_line.trim();
2634 let abbreviations = get_abbreviations(&options.abbreviations);
2635 let ends_with_sentence = (prev_trimmed.ends_with('.')
2636 || prev_trimmed.ends_with('!')
2637 || prev_trimmed.ends_with('?')
2638 || prev_trimmed.ends_with(".*")
2639 || prev_trimmed.ends_with("!*")
2640 || prev_trimmed.ends_with("?*")
2641 || prev_trimmed.ends_with("._")
2642 || prev_trimmed.ends_with("!_")
2643 || prev_trimmed.ends_with("?_")
2644 || prev_trimmed.ends_with(".\"")
2646 || prev_trimmed.ends_with("!\"")
2647 || prev_trimmed.ends_with("?\"")
2648 || prev_trimmed.ends_with(".'")
2649 || prev_trimmed.ends_with("!'")
2650 || prev_trimmed.ends_with("?'")
2651 || prev_trimmed.ends_with(".\u{201D}")
2652 || prev_trimmed.ends_with("!\u{201D}")
2653 || prev_trimmed.ends_with("?\u{201D}")
2654 || prev_trimmed.ends_with(".\u{2019}")
2655 || prev_trimmed.ends_with("!\u{2019}")
2656 || prev_trimmed.ends_with("?\u{2019}"))
2657 && !text_ends_with_abbreviation(
2658 prev_trimmed.trim_end_matches(['*', '_', '"', '\'', '\u{201D}', '\u{2019}']),
2659 &abbreviations,
2660 );
2661
2662 if has_hard_break(prev_line) || (options.sentence_per_line && ends_with_sentence) {
2663 paragraph_parts.push(current_part.join(" "));
2665 current_part = vec![next_line];
2666 } else {
2667 current_part.push(next_line);
2668 }
2669 i += 1;
2670 }
2671
2672 if !current_part.is_empty() {
2674 if current_part.len() == 1 {
2675 paragraph_parts.push(current_part[0].to_string());
2677 } else {
2678 paragraph_parts.push(current_part.join(" "));
2679 }
2680 }
2681
2682 for (j, part) in paragraph_parts.iter().enumerate() {
2684 let reflowed = reflow_line(part, options);
2685 result.extend(reflowed);
2686
2687 if j < paragraph_parts.len() - 1 && !result.is_empty() && !options.sentence_per_line {
2691 let last_idx = result.len() - 1;
2692 if !has_hard_break(&result[last_idx]) {
2693 result[last_idx].push_str(" ");
2694 }
2695 }
2696 }
2697 }
2698 }
2699
2700 let result_text = result.join("\n");
2702 if content.ends_with('\n') && !result_text.ends_with('\n') {
2703 format!("{result_text}\n")
2704 } else {
2705 result_text
2706 }
2707}
2708
2709#[derive(Debug, Clone)]
2711pub struct ParagraphReflow {
2712 pub start_byte: usize,
2714 pub end_byte: usize,
2716 pub reflowed_text: String,
2718}
2719
2720#[derive(Debug, Clone)]
2726pub struct BlockquoteLineData {
2727 pub(crate) content: String,
2729 pub(crate) is_explicit: bool,
2731 pub(crate) prefix: Option<String>,
2733}
2734
2735impl BlockquoteLineData {
2736 pub fn explicit(content: String, prefix: String) -> Self {
2738 Self {
2739 content,
2740 is_explicit: true,
2741 prefix: Some(prefix),
2742 }
2743 }
2744
2745 pub fn lazy(content: String) -> Self {
2747 Self {
2748 content,
2749 is_explicit: false,
2750 prefix: None,
2751 }
2752 }
2753}
2754
2755#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2757pub enum BlockquoteContinuationStyle {
2758 Explicit,
2759 Lazy,
2760}
2761
2762pub fn blockquote_continuation_style(lines: &[BlockquoteLineData]) -> BlockquoteContinuationStyle {
2770 let mut explicit_count = 0usize;
2771 let mut lazy_count = 0usize;
2772
2773 for line in lines.iter().skip(1) {
2774 if line.is_explicit {
2775 explicit_count += 1;
2776 } else {
2777 lazy_count += 1;
2778 }
2779 }
2780
2781 if explicit_count > 0 && lazy_count == 0 {
2782 BlockquoteContinuationStyle::Explicit
2783 } else if lazy_count > 0 && explicit_count == 0 {
2784 BlockquoteContinuationStyle::Lazy
2785 } else if explicit_count >= lazy_count {
2786 BlockquoteContinuationStyle::Explicit
2787 } else {
2788 BlockquoteContinuationStyle::Lazy
2789 }
2790}
2791
2792pub fn dominant_blockquote_prefix(lines: &[BlockquoteLineData], fallback: &str) -> String {
2797 let mut counts: std::collections::HashMap<String, (usize, usize)> = std::collections::HashMap::new();
2798
2799 for (idx, line) in lines.iter().enumerate() {
2800 let Some(prefix) = line.prefix.as_ref() else {
2801 continue;
2802 };
2803 counts
2804 .entry(prefix.clone())
2805 .and_modify(|entry| entry.0 += 1)
2806 .or_insert((1, idx));
2807 }
2808
2809 counts
2810 .into_iter()
2811 .max_by(|(_, (count_a, first_idx_a)), (_, (count_b, first_idx_b))| {
2812 count_a.cmp(count_b).then_with(|| first_idx_b.cmp(first_idx_a))
2813 })
2814 .map_or_else(|| fallback.to_string(), |(prefix, _)| prefix)
2815}
2816
2817pub(crate) fn should_force_explicit_blockquote_line(content_line: &str) -> bool {
2822 let trimmed = content_line.trim_start();
2823 trimmed.starts_with('>')
2824 || trimmed.starts_with('#')
2825 || trimmed.starts_with("```")
2826 || trimmed.starts_with("~~~")
2827 || is_unordered_list_marker(trimmed)
2828 || is_numbered_list_item(trimmed)
2829 || is_horizontal_rule(trimmed)
2830 || is_definition_list_item(trimmed)
2831 || (trimmed.starts_with('[') && trimmed.contains("]:"))
2832 || trimmed.starts_with(":::")
2833 || (trimmed.starts_with('<')
2834 && !trimmed.starts_with("<http")
2835 && !trimmed.starts_with("<https")
2836 && !trimmed.starts_with("<mailto:"))
2837}
2838
2839pub fn reflow_blockquote_content(
2848 lines: &[BlockquoteLineData],
2849 explicit_prefix: &str,
2850 continuation_style: BlockquoteContinuationStyle,
2851 options: &ReflowOptions,
2852) -> Vec<String> {
2853 let content_strs: Vec<&str> = lines.iter().map(|l| l.content.as_str()).collect();
2854 let segments = split_into_segments_strs(&content_strs);
2855 let mut reflowed_content_lines: Vec<String> = Vec::new();
2856
2857 for segment in segments {
2858 let hard_break_type = segment.last().and_then(|&line| {
2859 let line = line.strip_suffix('\r').unwrap_or(line);
2860 if line.ends_with('\\') {
2861 Some("\\")
2862 } else if line.ends_with(" ") {
2863 Some(" ")
2864 } else {
2865 None
2866 }
2867 });
2868
2869 let pieces: Vec<&str> = segment
2870 .iter()
2871 .map(|&line| {
2872 if let Some(l) = line.strip_suffix('\\') {
2873 l.trim_end()
2874 } else if let Some(l) = line.strip_suffix(" ") {
2875 l.trim_end()
2876 } else {
2877 line.trim_end()
2878 }
2879 })
2880 .collect();
2881
2882 let segment_text = pieces.join(" ");
2883 let segment_text = segment_text.trim();
2884 if segment_text.is_empty() {
2885 continue;
2886 }
2887
2888 let mut reflowed = reflow_line(segment_text, options);
2889 if let Some(break_marker) = hard_break_type
2890 && !reflowed.is_empty()
2891 {
2892 let last_idx = reflowed.len() - 1;
2893 if !has_hard_break(&reflowed[last_idx]) {
2894 reflowed[last_idx].push_str(break_marker);
2895 }
2896 }
2897 reflowed_content_lines.extend(reflowed);
2898 }
2899
2900 let mut styled_lines: Vec<String> = Vec::new();
2901 for (idx, line) in reflowed_content_lines.iter().enumerate() {
2902 let force_explicit = idx == 0
2903 || continuation_style == BlockquoteContinuationStyle::Explicit
2904 || should_force_explicit_blockquote_line(line);
2905 if force_explicit {
2906 styled_lines.push(format!("{explicit_prefix}{line}"));
2907 } else {
2908 styled_lines.push(line.clone());
2909 }
2910 }
2911
2912 styled_lines
2913}
2914
2915fn is_blockquote_content_boundary(content: &str) -> bool {
2916 let trimmed = content.trim();
2917 trimmed.is_empty()
2918 || is_block_boundary(trimmed)
2919 || crate::utils::table_utils::TableUtils::is_potential_table_row(content)
2920 || trimmed.starts_with(":::")
2921 || crate::utils::is_template_directive_only(content)
2922 || is_standalone_attr_list(content)
2923 || is_snippet_block_delimiter(content)
2924}
2925
2926fn split_into_segments_strs<'a>(lines: &[&'a str]) -> Vec<Vec<&'a str>> {
2927 let mut segments = Vec::new();
2928 let mut current = Vec::new();
2929
2930 for &line in lines {
2931 current.push(line);
2932 if has_hard_break(line) {
2933 segments.push(current);
2934 current = Vec::new();
2935 }
2936 }
2937
2938 if !current.is_empty() {
2939 segments.push(current);
2940 }
2941
2942 segments
2943}
2944
2945fn reflow_blockquote_paragraph_at_line(
2946 content: &str,
2947 lines: &[&str],
2948 target_idx: usize,
2949 options: &ReflowOptions,
2950) -> Option<ParagraphReflow> {
2951 let mut anchor_idx = target_idx;
2952 let mut target_level = if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[target_idx]) {
2953 parsed.nesting_level
2954 } else {
2955 let mut found = None;
2956 let mut idx = target_idx;
2957 loop {
2958 if lines[idx].trim().is_empty() {
2959 break;
2960 }
2961 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[idx]) {
2962 found = Some((idx, parsed.nesting_level));
2963 break;
2964 }
2965 if idx == 0 {
2966 break;
2967 }
2968 idx -= 1;
2969 }
2970 let (idx, level) = found?;
2971 anchor_idx = idx;
2972 level
2973 };
2974
2975 let mut para_start = anchor_idx;
2977 while para_start > 0 {
2978 let prev_idx = para_start - 1;
2979 let prev_line = lines[prev_idx];
2980
2981 if prev_line.trim().is_empty() {
2982 break;
2983 }
2984
2985 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(prev_line) {
2986 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
2987 break;
2988 }
2989 para_start = prev_idx;
2990 continue;
2991 }
2992
2993 let prev_lazy = prev_line.trim_start();
2994 if is_blockquote_content_boundary(prev_lazy) {
2995 break;
2996 }
2997 para_start = prev_idx;
2998 }
2999
3000 while para_start < lines.len() {
3002 let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[para_start]) else {
3003 para_start += 1;
3004 continue;
3005 };
3006 target_level = parsed.nesting_level;
3007 break;
3008 }
3009
3010 if para_start >= lines.len() || para_start > target_idx {
3011 return None;
3012 }
3013
3014 let mut collected: Vec<(usize, BlockquoteLineData)> = Vec::new();
3017 let mut idx = para_start;
3018 while idx < lines.len() {
3019 if !collected.is_empty() && has_hard_break(&collected[collected.len() - 1].1.content) {
3020 break;
3021 }
3022
3023 let line = lines[idx];
3024 if line.trim().is_empty() {
3025 break;
3026 }
3027
3028 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(line) {
3029 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
3030 break;
3031 }
3032 collected.push((
3033 idx,
3034 BlockquoteLineData::explicit(trim_preserving_hard_break(parsed.content), parsed.prefix.to_string()),
3035 ));
3036 idx += 1;
3037 continue;
3038 }
3039
3040 let lazy_content = line.trim_start();
3041 if is_blockquote_content_boundary(lazy_content) {
3042 break;
3043 }
3044
3045 collected.push((idx, BlockquoteLineData::lazy(trim_preserving_hard_break(lazy_content))));
3046 idx += 1;
3047 }
3048
3049 if collected.is_empty() {
3050 return None;
3051 }
3052
3053 let para_end = collected[collected.len() - 1].0;
3054 if target_idx < para_start || target_idx > para_end {
3055 return None;
3056 }
3057
3058 let line_data: Vec<BlockquoteLineData> = collected.iter().map(|(_, d)| d.clone()).collect();
3059
3060 let fallback_prefix = line_data
3061 .iter()
3062 .find_map(|d| d.prefix.clone())
3063 .unwrap_or_else(|| "> ".to_string());
3064 let explicit_prefix = dominant_blockquote_prefix(&line_data, &fallback_prefix);
3065 let continuation_style = blockquote_continuation_style(&line_data);
3066
3067 let adjusted_line_length = options
3068 .line_length
3069 .saturating_sub(display_len(&explicit_prefix, options.length_mode))
3070 .max(1);
3071
3072 let adjusted_options = ReflowOptions {
3073 line_length: adjusted_line_length,
3074 ..options.clone()
3075 };
3076
3077 let styled_lines = reflow_blockquote_content(&line_data, &explicit_prefix, continuation_style, &adjusted_options);
3078
3079 if styled_lines.is_empty() {
3080 return None;
3081 }
3082
3083 let mut start_byte = 0;
3085 for line in lines.iter().take(para_start) {
3086 start_byte += line.len() + 1;
3087 }
3088
3089 let mut end_byte = start_byte;
3090 for line in lines.iter().take(para_end + 1).skip(para_start) {
3091 end_byte += line.len() + 1;
3092 }
3093
3094 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
3095 if !includes_trailing_newline {
3096 end_byte -= 1;
3097 }
3098
3099 let reflowed_joined = styled_lines.join("\n");
3100 let reflowed_text = if includes_trailing_newline {
3101 if reflowed_joined.ends_with('\n') {
3102 reflowed_joined
3103 } else {
3104 format!("{reflowed_joined}\n")
3105 }
3106 } else if reflowed_joined.ends_with('\n') {
3107 reflowed_joined.trim_end_matches('\n').to_string()
3108 } else {
3109 reflowed_joined
3110 };
3111
3112 Some(ParagraphReflow {
3113 start_byte,
3114 end_byte,
3115 reflowed_text,
3116 })
3117}
3118
3119pub fn reflow_paragraph_at_line(content: &str, line_number: usize, line_length: usize) -> Option<ParagraphReflow> {
3137 reflow_paragraph_at_line_with_mode(content, line_number, line_length, ReflowLengthMode::default())
3138}
3139
3140pub fn reflow_paragraph_at_line_with_mode(
3142 content: &str,
3143 line_number: usize,
3144 line_length: usize,
3145 length_mode: ReflowLengthMode,
3146) -> Option<ParagraphReflow> {
3147 let options = ReflowOptions {
3148 line_length,
3149 length_mode,
3150 ..Default::default()
3151 };
3152 reflow_paragraph_at_line_with_options(content, line_number, &options)
3153}
3154
3155pub fn reflow_paragraph_at_line_with_options(
3166 content: &str,
3167 line_number: usize,
3168 options: &ReflowOptions,
3169) -> Option<ParagraphReflow> {
3170 if line_number == 0 {
3171 return None;
3172 }
3173
3174 let lines: Vec<&str> = content.lines().collect();
3175
3176 if line_number > lines.len() {
3178 return None;
3179 }
3180
3181 let target_idx = line_number - 1; let target_line = lines[target_idx];
3183 let trimmed = target_line.trim();
3184
3185 if let Some(blockquote_reflow) = reflow_blockquote_paragraph_at_line(content, &lines, target_idx, options) {
3188 return Some(blockquote_reflow);
3189 }
3190
3191 if is_paragraph_boundary(trimmed, target_line) {
3193 return None;
3194 }
3195
3196 let mut para_start = target_idx;
3198 while para_start > 0 {
3199 let prev_idx = para_start - 1;
3200 let prev_line = lines[prev_idx];
3201 let prev_trimmed = prev_line.trim();
3202
3203 if is_paragraph_boundary(prev_trimmed, prev_line) {
3205 break;
3206 }
3207
3208 para_start = prev_idx;
3209 }
3210
3211 let mut para_end = target_idx;
3213 while para_end + 1 < lines.len() {
3214 let next_idx = para_end + 1;
3215 let next_line = lines[next_idx];
3216 let next_trimmed = next_line.trim();
3217
3218 if is_paragraph_boundary(next_trimmed, next_line) {
3220 break;
3221 }
3222
3223 para_end = next_idx;
3224 }
3225
3226 let paragraph_lines = &lines[para_start..=para_end];
3228
3229 let mut start_byte = 0;
3231 for line in lines.iter().take(para_start) {
3232 start_byte += line.len() + 1; }
3234
3235 let mut end_byte = start_byte;
3236 for line in paragraph_lines {
3237 end_byte += line.len() + 1; }
3239
3240 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
3243
3244 if !includes_trailing_newline {
3246 end_byte -= 1;
3247 }
3248
3249 let paragraph_text = paragraph_lines.join("\n");
3251
3252 let reflowed = reflow_markdown(¶graph_text, options);
3254
3255 let reflowed_text = if includes_trailing_newline {
3259 if reflowed.ends_with('\n') {
3261 reflowed
3262 } else {
3263 format!("{reflowed}\n")
3264 }
3265 } else {
3266 if reflowed.ends_with('\n') {
3268 reflowed.trim_end_matches('\n').to_string()
3269 } else {
3270 reflowed
3271 }
3272 };
3273
3274 Some(ParagraphReflow {
3275 start_byte,
3276 end_byte,
3277 reflowed_text,
3278 })
3279}
3280
3281#[cfg(test)]
3282mod tests {
3283 use super::*;
3284
3285 #[test]
3290 fn test_helper_function_text_ends_with_abbreviation() {
3291 let abbreviations = get_abbreviations(&None);
3293
3294 assert!(text_ends_with_abbreviation("Dr.", &abbreviations));
3296 assert!(text_ends_with_abbreviation("word Dr.", &abbreviations));
3297 assert!(text_ends_with_abbreviation("e.g.", &abbreviations));
3298 assert!(text_ends_with_abbreviation("i.e.", &abbreviations));
3299 assert!(text_ends_with_abbreviation("Mr.", &abbreviations));
3300 assert!(text_ends_with_abbreviation("Mrs.", &abbreviations));
3301 assert!(text_ends_with_abbreviation("Ms.", &abbreviations));
3302 assert!(text_ends_with_abbreviation("Prof.", &abbreviations));
3303
3304 assert!(!text_ends_with_abbreviation("etc.", &abbreviations));
3306 assert!(!text_ends_with_abbreviation("paradigms.", &abbreviations));
3307 assert!(!text_ends_with_abbreviation("programs.", &abbreviations));
3308 assert!(!text_ends_with_abbreviation("items.", &abbreviations));
3309 assert!(!text_ends_with_abbreviation("systems.", &abbreviations));
3310 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)); }
3316
3317 #[test]
3318 fn test_is_unordered_list_marker() {
3319 assert!(is_unordered_list_marker("- item"));
3321 assert!(is_unordered_list_marker("* item"));
3322 assert!(is_unordered_list_marker("+ item"));
3323 assert!(is_unordered_list_marker("-")); assert!(is_unordered_list_marker("*"));
3325 assert!(is_unordered_list_marker("+"));
3326
3327 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")); }
3338
3339 #[test]
3340 fn test_is_block_boundary() {
3341 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"));
3363 assert!(!is_block_boundary("*emphasis*")); assert!(!is_block_boundary("[link](url)")); assert!(!is_block_boundary("some words here"));
3366 }
3367
3368 #[test]
3369 fn test_definition_list_boundary_in_single_line_paragraph() {
3370 let options = ReflowOptions {
3373 line_length: 80,
3374 ..Default::default()
3375 };
3376 let input = "Term\n: Definition of the term";
3377 let result = reflow_markdown(input, &options);
3378 assert!(
3380 result.contains(": Definition"),
3381 "Definition list item should not be merged into previous line. Got: {result:?}"
3382 );
3383 let lines: Vec<&str> = result.lines().collect();
3384 assert_eq!(lines.len(), 2, "Should remain two separate lines. Got: {lines:?}");
3385 assert_eq!(lines[0], "Term");
3386 assert_eq!(lines[1], ": Definition of the term");
3387 }
3388
3389 #[test]
3390 fn test_is_paragraph_boundary() {
3391 assert!(is_paragraph_boundary("# Heading", "# Heading"));
3393 assert!(is_paragraph_boundary("- item", "- item"));
3394 assert!(is_paragraph_boundary(":::", ":::"));
3395 assert!(is_paragraph_boundary(": definition", ": definition"));
3396
3397 assert!(is_paragraph_boundary("code", " code"));
3399 assert!(is_paragraph_boundary("code", "\tcode"));
3400
3401 assert!(is_paragraph_boundary("| a | b |", "| a | b |"));
3403 assert!(is_paragraph_boundary("a | b", "a | b")); assert!(!is_paragraph_boundary("regular text", "regular text"));
3407 assert!(!is_paragraph_boundary("text", " text")); }
3409
3410 #[test]
3411 fn test_div_marker_boundary_in_reflow_paragraph_at_line() {
3412 let content = "Some paragraph text here.\n\n::: {.callout-note}\nThis is a callout.\n:::\n";
3415 let result = reflow_paragraph_at_line(content, 3, 80);
3417 assert!(result.is_none(), "Div marker line should not be reflowed");
3418 }
3419}