1use crate::utils::calculate_indentation_width_default;
7use crate::utils::is_definition_list_item;
8use crate::utils::mkdocs_attr_list::{ATTR_LIST_PATTERN, is_standalone_attr_list};
9use crate::utils::mkdocs_snippets::is_snippet_block_delimiter;
10use crate::utils::regex_cache::{
11 DISPLAY_MATH_REGEX, EMAIL_PATTERN, EMOJI_SHORTCODE_REGEX, HTML_ENTITY_REGEX, HTML_TAG_PATTERN,
12 HUGO_SHORTCODE_REGEX, INLINE_MATH_REGEX, WIKI_LINK_REGEX,
13};
14use crate::utils::sentence_utils::{
15 get_abbreviations, is_cjk_char, is_cjk_sentence_ending, is_closing_quote, is_opening_quote,
16 text_ends_with_abbreviation,
17};
18use pulldown_cmark::{BrokenLink, CowStr, Event, LinkType, Options, Parser, Tag, TagEnd};
19use std::collections::HashSet;
20use unicode_width::UnicodeWidthStr;
21
22#[derive(Clone, Copy, Debug, Default, PartialEq)]
24pub enum ReflowLengthMode {
25 Chars,
27 #[default]
29 Visual,
30 Bytes,
32}
33
34fn display_len(s: &str, mode: ReflowLengthMode) -> usize {
36 match mode {
37 ReflowLengthMode::Chars => s.chars().count(),
38 ReflowLengthMode::Visual => s.width(),
39 ReflowLengthMode::Bytes => s.len(),
40 }
41}
42
43#[derive(Clone)]
45pub struct ReflowOptions {
46 pub line_length: usize,
48 pub break_on_sentences: bool,
50 pub preserve_breaks: bool,
52 pub sentence_per_line: bool,
54 pub semantic_line_breaks: bool,
56 pub abbreviations: Option<Vec<String>>,
60 pub length_mode: ReflowLengthMode,
62 pub attr_lists: bool,
65 pub myst_roles: bool,
69 pub require_sentence_capital: bool,
74 pub max_list_continuation_indent: Option<usize>,
78 pub defined_references: Option<HashSet<String>>,
92}
93
94impl Default for ReflowOptions {
95 fn default() -> Self {
96 Self {
97 line_length: 80,
98 break_on_sentences: true,
99 preserve_breaks: false,
100 sentence_per_line: false,
101 semantic_line_breaks: false,
102 abbreviations: None,
103 length_mode: ReflowLengthMode::default(),
104 attr_lists: false,
105 myst_roles: false,
106 require_sentence_capital: true,
107 max_list_continuation_indent: None,
108 defined_references: None,
109 }
110 }
111}
112
113pub fn normalize_reference_label(label: &str) -> String {
120 label.split_whitespace().collect::<Vec<_>>().join(" ").to_lowercase()
121}
122
123fn compute_inline_code_mask(text: &str) -> Vec<bool> {
126 let code_spans = extract_code_spans(text);
127 let chars: Vec<char> = text.chars().collect();
128 let mut mask = vec![false; chars.len()];
129 let mut span_it = code_spans.iter().peekable();
130 let mut byte_idx = 0;
131 for (char_idx, ch) in chars.iter().enumerate() {
135 let next_byte_idx = byte_idx + ch.len_utf8();
136 while let Some(span) = span_it.peek() {
137 if span.end <= byte_idx {
138 span_it.next();
139 } else {
140 break;
141 }
142 }
143 if let Some(span) = span_it.peek()
144 && byte_idx >= span.start
145 && byte_idx < span.end
146 {
147 mask[char_idx] = true;
148 }
149 byte_idx = next_byte_idx;
150 }
151 mask
152}
153
154fn footnote_refs_end(chars: &[char], start: usize) -> Option<usize> {
160 let mut pos = start;
161 let mut found = false;
162
163 loop {
164 if chars.get(pos) != Some(&'[') || chars.get(pos + 1) != Some(&'^') {
165 break;
166 }
167 let label_start = pos + 2;
168 let mut label_end = label_start;
169 while matches!(chars.get(label_end), Some(c) if c.is_ascii_alphanumeric() || *c == '_' || *c == '-') {
170 label_end += 1;
171 }
172 if label_end == label_start || chars.get(label_end) != Some(&']') {
173 break;
174 }
175 pos = label_end + 1;
176 found = true;
177 }
178
179 found.then_some(pos)
180}
181
182fn is_sentence_boundary(
186 text: &str,
187 chars: &[char],
188 pos: usize,
189 abbreviations: &HashSet<String>,
190 require_sentence_capital: bool,
191) -> bool {
192 if pos + 1 >= chars.len() {
193 return false;
194 }
195
196 let c = chars[pos];
197 let next_char = chars[pos + 1];
198
199 if is_cjk_sentence_ending(c) {
202 let mut after_punct_pos = pos + 1;
204 while after_punct_pos < chars.len()
205 && (chars[after_punct_pos] == '*' || chars[after_punct_pos] == '_' || chars[after_punct_pos] == '~')
206 {
207 after_punct_pos += 1;
208 }
209
210 while after_punct_pos < chars.len() && chars[after_punct_pos].is_whitespace() {
212 after_punct_pos += 1;
213 }
214
215 if after_punct_pos >= chars.len() {
217 return false;
218 }
219
220 while after_punct_pos < chars.len()
222 && (chars[after_punct_pos] == '*' || chars[after_punct_pos] == '_' || chars[after_punct_pos] == '~')
223 {
224 after_punct_pos += 1;
225 }
226
227 if after_punct_pos >= chars.len() {
228 return false;
229 }
230
231 return true;
234 }
235
236 if c != '.' && c != '!' && c != '?' {
238 return false;
239 }
240
241 let (_space_pos, after_space_pos) = if next_char == ' ' {
243 (pos + 1, pos + 2)
245 } else if is_closing_quote(next_char) && pos + 2 < chars.len() {
246 if chars[pos + 2] == ' ' {
248 (pos + 2, pos + 3)
250 } else if (chars[pos + 2] == '*' || chars[pos + 2] == '_') && pos + 3 < chars.len() && chars[pos + 3] == ' ' {
251 (pos + 3, pos + 4)
253 } else if (chars[pos + 2] == '*' || chars[pos + 2] == '_')
254 && pos + 4 < chars.len()
255 && chars[pos + 3] == chars[pos + 2]
256 && chars[pos + 4] == ' '
257 {
258 (pos + 4, pos + 5)
260 } else {
261 return false;
262 }
263 } else if (next_char == '*' || next_char == '_') && pos + 2 < chars.len() && chars[pos + 2] == ' ' {
264 (pos + 2, pos + 3)
266 } else if (next_char == '*' || next_char == '_')
267 && pos + 3 < chars.len()
268 && chars[pos + 2] == next_char
269 && chars[pos + 3] == ' '
270 {
271 (pos + 3, pos + 4)
273 } else if next_char == '~' && pos + 3 < chars.len() && chars[pos + 2] == '~' && chars[pos + 3] == ' ' {
274 (pos + 3, pos + 4)
276 } else if next_char == '[' {
277 match footnote_refs_end(chars, pos + 1) {
283 Some(end_pos) if chars.get(end_pos) == Some(&' ') => (end_pos, end_pos + 1),
284 _ => return false,
285 }
286 } else {
287 return false;
288 };
289
290 let mut next_char_pos = after_space_pos;
292 while next_char_pos < chars.len() && chars[next_char_pos].is_whitespace() {
293 next_char_pos += 1;
294 }
295
296 if next_char_pos >= chars.len() {
298 return false;
299 }
300
301 let mut first_letter_pos = next_char_pos;
303 while first_letter_pos < chars.len()
304 && (chars[first_letter_pos] == '*'
305 || chars[first_letter_pos] == '_'
306 || chars[first_letter_pos] == '~'
307 || is_opening_quote(chars[first_letter_pos]))
308 {
309 first_letter_pos += 1;
310 }
311
312 if first_letter_pos >= chars.len() {
314 return false;
315 }
316
317 let first_char = chars[first_letter_pos];
318
319 if c == '!' || c == '?' {
321 return true;
322 }
323
324 if pos > 0 {
328 let byte_offset: usize = chars[..=pos].iter().map(|ch| ch.len_utf8()).sum();
330 if text_ends_with_abbreviation(&text[..byte_offset], abbreviations) {
331 return false;
332 }
333
334 if chars[pos - 1].is_numeric() && first_char.is_ascii_digit() {
336 return false;
337 }
338
339 if chars[pos - 1].is_ascii_uppercase() && (pos == 1 || (pos >= 2 && chars[pos - 2].is_whitespace())) {
343 return false;
344 }
345 }
346
347 if require_sentence_capital && !first_char.is_uppercase() && !is_cjk_char(first_char) {
350 return false;
351 }
352
353 true
354}
355
356pub fn split_into_sentences(text: &str) -> Vec<String> {
358 split_into_sentences_custom(text, &None)
359}
360
361pub fn split_into_sentences_custom(text: &str, custom_abbreviations: &Option<Vec<String>>) -> Vec<String> {
363 let abbreviations = get_abbreviations(custom_abbreviations);
364 split_into_sentences_with_set(text, &abbreviations, true)
365}
366
367fn split_into_sentences_with_set(
370 text: &str,
371 abbreviations: &HashSet<String>,
372 require_sentence_capital: bool,
373) -> Vec<String> {
374 let in_code = compute_inline_code_mask(text);
376 let char_vec: Vec<char> = text.chars().collect();
379
380 let mut sentences = Vec::new();
381 let mut current_sentence = String::new();
382 let mut chars = text.chars().peekable();
383 let mut pos = 0;
384
385 while let Some(c) = chars.next() {
386 current_sentence.push(c);
387
388 if !in_code[pos] && is_sentence_boundary(text, &char_vec, pos, abbreviations, require_sentence_capital) {
389 if let Some(end_pos) = footnote_refs_end(&char_vec, pos + 1) {
394 while pos + 1 < end_pos {
395 current_sentence.push(chars.next().unwrap());
396 pos += 1;
397 }
398 }
399
400 while let Some(&next) = chars.peek() {
402 if next == '*' || next == '_' || next == '~' || is_closing_quote(next) {
403 current_sentence.push(chars.next().unwrap());
404 pos += 1;
405 } else {
406 break;
407 }
408 }
409
410 if chars.peek() == Some(&' ') {
412 chars.next();
413 pos += 1;
414 }
415
416 sentences.push(current_sentence.trim().to_string());
417 current_sentence.clear();
418 }
419
420 pos += 1;
421 }
422
423 if !current_sentence.trim().is_empty() {
425 sentences.push(current_sentence.trim().to_string());
426 }
427 sentences
428}
429
430fn is_horizontal_rule(line: &str) -> bool {
432 if line.len() < 3 {
433 return false;
434 }
435
436 let mut chars = line.chars();
439 let Some(first_char) = chars.next() else {
440 return false;
441 };
442 if first_char != '-' && first_char != '_' && first_char != '*' {
443 return false;
444 }
445
446 let mut non_space_count = 1usize; for c in chars {
448 if c == ' ' {
449 continue;
450 }
451 if c != first_char {
452 return false;
453 }
454 non_space_count += 1;
455 }
456 non_space_count >= 3
457}
458
459fn is_numbered_list_item(line: &str) -> bool {
461 let mut chars = line.chars();
462
463 if !chars.next().is_some_and(char::is_numeric) {
465 return false;
466 }
467
468 while let Some(c) = chars.next() {
470 if c == '.' {
471 return chars.next() == Some(' ');
474 }
475 if !c.is_numeric() {
476 return false;
477 }
478 }
479
480 false
481}
482
483fn is_unordered_list_marker(s: &str) -> bool {
485 matches!(s.as_bytes().first(), Some(b'-' | b'*' | b'+'))
486 && !is_horizontal_rule(s)
487 && (s.len() == 1 || s.as_bytes().get(1) == Some(&b' '))
488}
489
490fn is_block_boundary_core(trimmed: &str) -> bool {
493 trimmed.is_empty()
494 || trimmed.starts_with('#')
495 || trimmed.starts_with("```")
496 || trimmed.starts_with("~~~")
497 || trimmed.starts_with('>')
498 || (trimmed.starts_with('[') && trimmed.contains("]:"))
499 || is_horizontal_rule(trimmed)
500 || is_unordered_list_marker(trimmed)
501 || is_numbered_list_item(trimmed)
502 || is_definition_list_item(trimmed)
503 || trimmed.starts_with(":::")
504}
505
506fn is_block_boundary(trimmed: &str) -> bool {
509 is_block_boundary_core(trimmed) || trimmed.starts_with('|')
510}
511
512fn is_paragraph_boundary(trimmed: &str, line: &str) -> bool {
516 is_block_boundary_core(trimmed)
517 || calculate_indentation_width_default(line) >= 4
518 || crate::utils::table_utils::TableUtils::is_potential_table_row(line)
519}
520
521fn has_hard_break(line: &str) -> bool {
527 let line = line.strip_suffix('\r').unwrap_or(line);
528 line.ends_with(" ") || line.ends_with('\\')
529}
530
531fn ends_with_sentence_punct(text: &str) -> bool {
533 text.ends_with('.') || text.ends_with('!') || text.ends_with('?')
534}
535
536fn trim_preserving_hard_break(s: &str) -> String {
542 let s = s.strip_suffix('\r').unwrap_or(s);
544
545 if s.ends_with('\\') {
547 return s.to_string();
549 }
550
551 if s.ends_with(" ") {
553 let content_end = s.trim_end().len();
555 if content_end == 0 {
556 return String::new();
558 }
559 format!("{} ", &s[..content_end])
561 } else {
562 s.trim_end().to_string()
564 }
565}
566
567fn parse_elements(text: &str, options: &ReflowOptions) -> Vec<Element> {
569 parse_markdown_elements_inner(
570 text,
571 options.attr_lists,
572 options.myst_roles,
573 options.defined_references.as_ref(),
574 )
575}
576
577pub fn reflow_line(line: &str, options: &ReflowOptions) -> Vec<String> {
578 if options.sentence_per_line {
580 let elements = parse_elements(line, options);
581 return merge_block_construct_continuations(reflow_elements_sentence_per_line(
582 &elements,
583 &options.abbreviations,
584 options.require_sentence_capital,
585 ));
586 }
587
588 if options.semantic_line_breaks {
590 let elements = parse_elements(line, options);
591 return merge_block_construct_continuations(reflow_elements_semantic(&elements, options));
592 }
593
594 if options.line_length == 0 || display_len(line, options.length_mode) <= options.line_length {
597 return vec![line.to_string()];
598 }
599
600 let elements = parse_elements(line, options);
602
603 merge_block_construct_continuations(reflow_elements(&elements, options))
605}
606
607#[derive(Debug, Clone)]
609enum Element {
610 Text(String),
612 Link(String),
614 ReferenceLink(String),
616 EmptyReferenceLink(String),
618 ShortcutReference(String),
620 InlineImage(String),
622 ReferenceImage(String),
624 EmptyReferenceImage(String),
626 LinkedImage(String),
628 FootnoteReference(String),
630 Strikethrough {
632 content: String,
633 double: bool,
635 },
636 WikiLink(String),
638 InlineMath(String),
640 DisplayMath(String),
642 EmojiShortcode(String),
644 Autolink(String),
646 HtmlTag(String),
648 HtmlEntity(String),
650 HugoShortcode(String),
652 AttrList(String),
654 MystRole(String),
658 Code(String),
660 Bold {
662 content: String,
663 underscore: bool,
665 },
666 Italic {
668 content: String,
669 underscore: bool,
671 },
672}
673
674impl std::fmt::Display for Element {
675 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
676 match self {
677 Element::Text(s) => write!(f, "{s}"),
678 Element::Link(s) => write!(f, "{s}"),
679 Element::ReferenceLink(s) => write!(f, "{s}"),
680 Element::EmptyReferenceLink(s) => write!(f, "{s}"),
681 Element::ShortcutReference(s) => write!(f, "{s}"),
682 Element::InlineImage(s) => write!(f, "{s}"),
683 Element::ReferenceImage(s) => write!(f, "{s}"),
684 Element::EmptyReferenceImage(s) => write!(f, "{s}"),
685 Element::LinkedImage(s) => write!(f, "{s}"),
686 Element::FootnoteReference(s) => write!(f, "{s}"),
687 Element::Strikethrough { content, double } => {
688 let marker = if *double { "~~" } else { "~" };
689 write!(f, "{marker}{content}{marker}")
690 }
691 Element::WikiLink(s) => write!(f, "[[{s}]]"),
692 Element::InlineMath(s) => write!(f, "${s}$"),
693 Element::DisplayMath(s) => write!(f, "$${s}$$"),
694 Element::EmojiShortcode(s) => write!(f, ":{s}:"),
695 Element::Autolink(s) => write!(f, "{s}"),
696 Element::HtmlTag(s) => write!(f, "{s}"),
697 Element::HtmlEntity(s) => write!(f, "{s}"),
698 Element::HugoShortcode(s) => write!(f, "{s}"),
699 Element::AttrList(s) => write!(f, "{s}"),
700 Element::MystRole(s) => write!(f, "{s}"),
701 Element::Code(s) => write!(f, "{s}"),
702 Element::Bold { content, underscore } => {
703 if *underscore {
704 write!(f, "__{content}__")
705 } else {
706 write!(f, "**{content}**")
707 }
708 }
709 Element::Italic { content, underscore } => {
710 if *underscore {
711 write!(f, "_{content}_")
712 } else {
713 write!(f, "*{content}*")
714 }
715 }
716 }
717 }
718}
719
720#[derive(Debug, Clone)]
722struct EmphasisSpan {
723 start: usize,
725 end: usize,
727 content: String,
729 is_strong: bool,
731 is_strikethrough: bool,
733 uses_underscore: bool,
735 strikethrough_double: bool,
738}
739
740fn extract_emphasis_spans(text: &str) -> Vec<EmphasisSpan> {
750 if !text.contains(['*', '_', '~']) {
753 return Vec::new();
754 }
755
756 let mut spans = Vec::new();
757 let mut options = Options::empty();
758 options.insert(Options::ENABLE_STRIKETHROUGH);
759
760 let mut emphasis_stack: Vec<(usize, bool)> = Vec::new(); let mut strong_stack: Vec<(usize, bool)> = Vec::new();
763 let mut strikethrough_stack: Vec<usize> = Vec::new();
764
765 let parser = Parser::new_ext(text, options).into_offset_iter();
766
767 for (event, range) in parser {
768 match event {
769 Event::Start(Tag::Emphasis) => {
770 let uses_underscore = text.get(range.start..range.start + 1) == Some("_");
772 emphasis_stack.push((range.start, uses_underscore));
773 }
774 Event::End(TagEnd::Emphasis) => {
775 if let Some((start_byte, uses_underscore)) = emphasis_stack.pop() {
776 let content_start = start_byte + 1;
778 let content_end = range.end - 1;
779 if content_end > content_start
780 && let Some(content) = text.get(content_start..content_end)
781 {
782 spans.push(EmphasisSpan {
783 start: start_byte,
784 end: range.end,
785 content: content.to_string(),
786 is_strong: false,
787 is_strikethrough: false,
788 uses_underscore,
789 strikethrough_double: false,
790 });
791 }
792 }
793 }
794 Event::Start(Tag::Strong) => {
795 let uses_underscore = text.get(range.start..range.start + 2) == Some("__");
797 strong_stack.push((range.start, uses_underscore));
798 }
799 Event::End(TagEnd::Strong) => {
800 if let Some((start_byte, uses_underscore)) = strong_stack.pop() {
801 let content_start = start_byte + 2;
803 let content_end = range.end - 2;
804 if content_end > content_start
805 && let Some(content) = text.get(content_start..content_end)
806 {
807 spans.push(EmphasisSpan {
808 start: start_byte,
809 end: range.end,
810 content: content.to_string(),
811 is_strong: true,
812 is_strikethrough: false,
813 uses_underscore,
814 strikethrough_double: false,
815 });
816 }
817 }
818 }
819 Event::Start(Tag::Strikethrough) => {
820 strikethrough_stack.push(range.start);
821 }
822 Event::End(TagEnd::Strikethrough) => {
823 if let Some(start_byte) = strikethrough_stack.pop() {
824 let double = text.get(start_byte..start_byte + 2) == Some("~~");
828 let marker_len = if double { 2 } else { 1 };
829 let content_start = start_byte + marker_len;
830 let content_end = range.end - marker_len;
831 if content_end > content_start
832 && let Some(content) = text.get(content_start..content_end)
833 {
834 spans.push(EmphasisSpan {
835 start: start_byte,
836 end: range.end,
837 content: content.to_string(),
838 is_strong: false,
839 is_strikethrough: true,
840 uses_underscore: false,
841 strikethrough_double: double,
842 });
843 }
844 }
845 }
846 _ => {}
847 }
848 }
849
850 spans.sort_by_key(|s| s.start);
852 spans
853}
854
855#[derive(Debug, Clone)]
856struct CodeSpan {
857 start: usize,
858 end: usize,
859}
860
861fn extract_code_spans(text: &str) -> Vec<CodeSpan> {
862 if !text.contains('`') {
864 return Vec::new();
865 }
866
867 let mut spans = Vec::new();
868 let parser = Parser::new(text).into_offset_iter();
869 for (event, range) in parser {
870 if let Event::Code(_) = event {
871 spans.push(CodeSpan {
872 start: range.start,
873 end: range.end,
874 });
875 }
876 }
877 spans
878}
879
880#[derive(Debug, Clone)]
881struct LinkSpan {
882 start: usize,
883 end: usize,
884 link_type: Option<LinkType>,
885 is_image: bool,
886 is_footnote: bool,
887}
888
889fn extract_link_spans(text: &str, defined_references: Option<&HashSet<String>>) -> Vec<LinkSpan> {
890 if !text.contains('[') {
893 return Vec::new();
894 }
895
896 let mut spans = Vec::new();
897 let mut options = Options::empty();
898 options.insert(Options::ENABLE_FOOTNOTES);
899
900 let resolve = move |link: BrokenLink<'_>| -> Option<(CowStr<'_>, CowStr<'_>)> {
917 let atomic = match link.link_type {
922 LinkType::Shortcut | LinkType::ShortcutUnknown => match defined_references {
923 Some(defs) => defs.contains(&normalize_reference_label(link.reference.as_ref())),
924 None => true,
925 },
926 _ => true,
927 };
928 atomic.then_some((CowStr::Borrowed(""), CowStr::Borrowed("")))
929 };
930 let parser = Parser::new_with_broken_link_callback(text, options, Some(resolve)).into_offset_iter();
931 let mut stack = Vec::new();
932
933 for (event, range) in parser {
934 match event {
935 Event::Start(Tag::Link { link_type, .. }) => {
936 stack.push((range.start, Some(link_type), false));
937 }
938 Event::Start(Tag::Image { link_type, .. }) => {
939 stack.push((range.start, Some(link_type), true));
940 }
941 Event::End(TagEnd::Link) => {
942 if let Some((start_byte, link_type, is_image)) = stack.pop()
943 && stack.is_empty()
944 {
945 spans.push(LinkSpan {
946 start: start_byte,
947 end: range.end,
948 link_type,
949 is_image,
950 is_footnote: false,
951 });
952 }
953 }
954 Event::End(TagEnd::Image) => {
955 if let Some((start_byte, link_type, is_image)) = stack.pop()
956 && stack.is_empty()
957 {
958 spans.push(LinkSpan {
959 start: start_byte,
960 end: range.end,
961 link_type,
962 is_image,
963 is_footnote: false,
964 });
965 }
966 }
967 Event::FootnoteReference(_) if stack.is_empty() => {
968 spans.push(LinkSpan {
969 start: range.start,
970 end: range.end,
971 link_type: None,
972 is_image: false,
973 is_footnote: true,
974 });
975 }
976 _ => {}
977 }
978 }
979
980 spans.sort_by_key(|s| s.start);
981 spans
982}
983
984fn myst_role_len_at(text: &str) -> Option<usize> {
992 let bytes = text.as_bytes();
993 if bytes.first() != Some(&b'{') {
994 return None;
995 }
996
997 let mut j = 1;
999 match bytes.get(j) {
1000 Some(&b) if b.is_ascii_alphabetic() || b == b'_' => {}
1001 _ => return None,
1002 }
1003 while let Some(&b) = bytes.get(j) {
1004 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b':' | b'.') {
1005 j += 1;
1006 } else {
1007 break;
1008 }
1009 }
1010 if bytes.get(j) != Some(&b'}') {
1011 return None;
1012 }
1013 j += 1; if bytes.get(j) != Some(&b'`') {
1017 return None;
1018 }
1019 let backtick_start = j;
1020 while bytes.get(j) == Some(&b'`') {
1021 j += 1;
1022 }
1023 let backtick_count = j - backtick_start;
1024
1025 while j + backtick_count <= bytes.len() {
1027 if bytes[j] == b'`' {
1028 let close_count = bytes[j..].iter().take_while(|&&b| b == b'`').count();
1029 if close_count == backtick_count {
1030 return Some(j + close_count);
1031 }
1032 j += close_count;
1033 } else {
1034 j += 1;
1035 }
1036 }
1037
1038 None
1039}
1040
1041#[derive(Clone, Copy, Debug)]
1043struct PatternMatch {
1044 start: usize,
1045 end: usize,
1046}
1047
1048fn parse_markdown_elements_inner(
1059 text: &str,
1060 attr_lists: bool,
1061 myst_roles: bool,
1062 defined_references: Option<&HashSet<String>>,
1063) -> Vec<Element> {
1064 let mut elements = Vec::new();
1065 let mut remaining = text;
1066
1067 let emphasis_spans = extract_emphasis_spans(text);
1075 let link_spans = extract_link_spans(text, defined_references);
1076 let code_spans = extract_code_spans(text);
1077
1078 let mut cached_wiki_link: Option<Option<PatternMatch>> = None;
1080 let mut cached_display_math: Option<Option<PatternMatch>> = None;
1081 let mut cached_inline_math: Option<Option<PatternMatch>> = None;
1082 let mut cached_emoji: Option<Option<PatternMatch>> = None;
1083 let mut cached_html_entity: Option<Option<PatternMatch>> = None;
1084 let mut cached_hugo_shortcode: Option<Option<PatternMatch>> = None;
1085 let mut cached_html_tag: Option<Option<PatternMatch>> = None;
1086 let mut cached_next_curly: Option<Option<usize>> = None;
1087
1088 let mut link_span_idx = 0usize;
1092 let mut emphasis_span_idx = 0usize;
1093 let mut code_span_idx = 0usize;
1094
1095 while !remaining.is_empty() {
1096 let current_offset = text.len() - remaining.len();
1097 let mut earliest_match: Option<(usize, usize, &str)> = None;
1098
1099 while link_span_idx < link_spans.len() && link_spans[link_span_idx].start < current_offset {
1101 link_span_idx += 1;
1102 }
1103 let next_link: Option<&LinkSpan> = link_spans.get(link_span_idx);
1104
1105 if let Some(span) = next_link {
1106 let pos_in_remaining = span.start - current_offset;
1107 if earliest_match
1108 .as_ref()
1109 .is_none_or(|(start, _, _)| pos_in_remaining < *start)
1110 {
1111 let match_end = span.end - current_offset;
1112 earliest_match = Some((pos_in_remaining, match_end, "link_span"));
1113 }
1114 }
1115
1116 macro_rules! get_or_update_match {
1117 ($cache:expr, $regex:expr) => {{
1118 let need_search = match &$cache {
1119 Some(Some(pm)) => pm.start < current_offset,
1120 Some(None) => false,
1121 None => true,
1122 };
1123 if need_search {
1124 if let Some(m) = $regex.find(&text[current_offset..]) {
1125 $cache = Some(Some(PatternMatch {
1126 start: current_offset + m.start(),
1127 end: current_offset + m.end(),
1128 }));
1129 } else {
1130 $cache = Some(None);
1131 }
1132 }
1133 match &$cache {
1134 Some(Some(pm)) => Some(*pm),
1135 _ => None,
1136 }
1137 }};
1138 }
1139
1140 macro_rules! get_or_update_fancy_match {
1141 ($cache:expr, $regex:expr) => {{
1142 let lookbehind_hole = current_offset > 0 && text.as_bytes()[current_offset - 1] == b'$';
1151 let need_search = lookbehind_hole
1152 || match &$cache {
1153 Some(Some(pm)) => pm.start < current_offset,
1154 Some(None) => false,
1155 None => true,
1156 };
1157 if need_search {
1158 if let Ok(Some(m)) = $regex.find(&text[current_offset..]) {
1159 $cache = Some(Some(PatternMatch {
1160 start: current_offset + m.start(),
1161 end: current_offset + m.end(),
1162 }));
1163 } else {
1164 $cache = Some(None);
1165 }
1166 }
1167 match &$cache {
1168 Some(Some(pm)) => Some(*pm),
1169 _ => None,
1170 }
1171 }};
1172 }
1173
1174 if let Some(pm) = get_or_update_match!(cached_wiki_link, WIKI_LINK_REGEX) {
1175 let pos_in_remaining = pm.start - current_offset;
1176 if earliest_match
1177 .as_ref()
1178 .is_none_or(|(start, _, _)| pos_in_remaining < *start)
1179 {
1180 let match_end = pm.end - current_offset;
1181 earliest_match = Some((pos_in_remaining, match_end, "wiki_link"));
1182 }
1183 }
1184
1185 if let Some(pm) = get_or_update_match!(cached_display_math, DISPLAY_MATH_REGEX) {
1186 let pos_in_remaining = pm.start - current_offset;
1187 if earliest_match
1188 .as_ref()
1189 .is_none_or(|(start, _, _)| pos_in_remaining < *start)
1190 {
1191 let match_end = pm.end - current_offset;
1192 earliest_match = Some((pos_in_remaining, match_end, "display_math"));
1193 }
1194 }
1195
1196 if let Some(pm) = get_or_update_fancy_match!(cached_inline_math, INLINE_MATH_REGEX) {
1197 let pos_in_remaining = pm.start - current_offset;
1198 if earliest_match
1199 .as_ref()
1200 .is_none_or(|(start, _, _)| pos_in_remaining < *start)
1201 {
1202 let match_end = pm.end - current_offset;
1203 earliest_match = Some((pos_in_remaining, match_end, "inline_math"));
1204 }
1205 }
1206
1207 if let Some(pm) = get_or_update_match!(cached_emoji, EMOJI_SHORTCODE_REGEX) {
1208 let pos_in_remaining = pm.start - current_offset;
1209 if earliest_match
1210 .as_ref()
1211 .is_none_or(|(start, _, _)| pos_in_remaining < *start)
1212 {
1213 let match_end = pm.end - current_offset;
1214 earliest_match = Some((pos_in_remaining, match_end, "emoji"));
1215 }
1216 }
1217
1218 if let Some(pm) = get_or_update_match!(cached_html_entity, HTML_ENTITY_REGEX) {
1219 let pos_in_remaining = pm.start - current_offset;
1220 if earliest_match
1221 .as_ref()
1222 .is_none_or(|(start, _, _)| pos_in_remaining < *start)
1223 {
1224 let match_end = pm.end - current_offset;
1225 earliest_match = Some((pos_in_remaining, match_end, "html_entity"));
1226 }
1227 }
1228
1229 if let Some(pm) = get_or_update_match!(cached_hugo_shortcode, HUGO_SHORTCODE_REGEX) {
1230 let pos_in_remaining = pm.start - current_offset;
1231 if earliest_match
1232 .as_ref()
1233 .is_none_or(|(start, _, _)| pos_in_remaining < *start)
1234 {
1235 let match_end = pm.end - current_offset;
1236 earliest_match = Some((pos_in_remaining, match_end, "hugo_shortcode"));
1237 }
1238 }
1239
1240 let need_html_tag_search = match &cached_html_tag {
1243 Some(Some(pm)) => pm.start < current_offset,
1244 Some(None) => false,
1245 None => true,
1246 };
1247 if need_html_tag_search {
1248 let mut search_offset = current_offset;
1249 loop {
1250 if let Some(m) = HTML_TAG_PATTERN.find(&text[search_offset..]) {
1251 let absolute_start = search_offset + m.start();
1252 let absolute_end = search_offset + m.end();
1253 let matched_text = &text[absolute_start..absolute_end];
1254 let is_url_autolink = matched_text.starts_with("<http://")
1255 || matched_text.starts_with("<https://")
1256 || matched_text.starts_with("<mailto:")
1257 || matched_text.starts_with("<ftp://")
1258 || matched_text.starts_with("<ftps://");
1259 let is_email_autolink = {
1260 let content = matched_text.trim_start_matches('<').trim_end_matches('>');
1262 EMAIL_PATTERN.is_match(content)
1263 };
1264 if is_url_autolink || is_email_autolink {
1265 search_offset = absolute_end;
1266 } else {
1267 cached_html_tag = Some(Some(PatternMatch {
1268 start: absolute_start,
1269 end: absolute_end,
1270 }));
1271 break;
1272 }
1273 } else {
1274 cached_html_tag = Some(None);
1275 break;
1276 }
1277 }
1278 }
1279 if let Some(Some(pm)) = &cached_html_tag {
1280 let pos_in_remaining = pm.start - current_offset;
1281 if earliest_match
1282 .as_ref()
1283 .is_none_or(|(start, _, _)| pos_in_remaining < *start)
1284 {
1285 let match_end = pm.end - current_offset;
1286 earliest_match = Some((pos_in_remaining, match_end, "html_tag"));
1287 }
1288 }
1289
1290 let mut next_special = remaining.len();
1292 let mut special_type = "";
1293 let mut pulldown_emphasis: Option<&EmphasisSpan> = None;
1294 let mut attr_list_len: usize = 0;
1295 let mut myst_role_len: usize = 0;
1296
1297 while code_span_idx < code_spans.len() && code_spans[code_span_idx].start < current_offset {
1299 code_span_idx += 1;
1300 }
1301 let next_code_span: Option<&CodeSpan> = code_spans.get(code_span_idx);
1302 if let Some(span) = next_code_span {
1303 let pos_in_remaining = span.start - current_offset;
1304 if pos_in_remaining < next_special {
1305 next_special = pos_in_remaining;
1306 special_type = "pulldown_code";
1307 }
1308 }
1309
1310 let need_curly_search = match &cached_next_curly {
1311 Some(Some(idx)) => *idx < current_offset,
1312 Some(None) => false,
1313 None => true,
1314 };
1315 if need_curly_search {
1316 if let Some(pos) = remaining.find('{') {
1317 cached_next_curly = Some(Some(current_offset + pos));
1318 } else {
1319 cached_next_curly = Some(None);
1320 }
1321 }
1322 let next_curly_pos = match &cached_next_curly {
1323 Some(Some(idx)) => Some(*idx - current_offset),
1324 _ => None,
1325 };
1326
1327 if myst_roles
1332 && let Some(pos) = next_curly_pos
1333 && pos < next_special
1334 && let Some(role_len) = myst_role_len_at(&remaining[pos..])
1335 {
1336 next_special = pos;
1337 special_type = "myst_role";
1338 myst_role_len = role_len;
1339 }
1340
1341 if attr_lists
1343 && let Some(pos) = next_curly_pos
1344 && pos < next_special
1345 && let Some(m) = ATTR_LIST_PATTERN.find(&remaining[pos..])
1346 && m.start() == 0
1347 {
1348 next_special = pos;
1349 special_type = "attr_list";
1350 attr_list_len = m.end();
1351 }
1352
1353 while emphasis_span_idx < emphasis_spans.len() && emphasis_spans[emphasis_span_idx].start < current_offset {
1355 emphasis_span_idx += 1;
1356 }
1357 if let Some(span) = emphasis_spans.get(emphasis_span_idx) {
1358 let pos_in_remaining = span.start - current_offset;
1359 if pos_in_remaining < next_special {
1360 next_special = pos_in_remaining;
1361 special_type = "pulldown_emphasis";
1362 pulldown_emphasis = Some(span);
1363 }
1364 }
1365
1366 let should_process_markdown_link = if let Some((pos, _, _)) = earliest_match {
1368 pos < next_special
1369 } else {
1370 false
1371 };
1372
1373 if should_process_markdown_link {
1374 let (pos, match_end, pattern_type) = earliest_match.unwrap();
1375
1376 if pos > 0 {
1378 elements.push(Element::Text(remaining[..pos].to_string()));
1379 }
1380
1381 match pattern_type {
1383 "link_span" => {
1384 let span = next_link.unwrap();
1385 let raw_text = remaining[pos..match_end].to_string();
1386 if span.is_footnote {
1387 elements.push(Element::FootnoteReference(raw_text));
1388 } else if span.is_image {
1389 match span.link_type {
1390 Some(LinkType::Inline) => elements.push(Element::InlineImage(raw_text)),
1391 Some(LinkType::Reference)
1394 | Some(LinkType::ReferenceUnknown)
1395 | Some(LinkType::Shortcut)
1396 | Some(LinkType::ShortcutUnknown) => elements.push(Element::ReferenceImage(raw_text)),
1397 Some(LinkType::Collapsed) | Some(LinkType::CollapsedUnknown) => {
1398 elements.push(Element::EmptyReferenceImage(raw_text))
1399 }
1400 _ => elements.push(Element::InlineImage(raw_text)),
1401 }
1402 } else {
1403 match span.link_type {
1404 Some(LinkType::Inline) => {
1405 if raw_text.starts_with('[') && raw_text.contains("![") {
1406 elements.push(Element::LinkedImage(raw_text));
1407 } else {
1408 elements.push(Element::Link(raw_text));
1409 }
1410 }
1411 Some(LinkType::Reference) | Some(LinkType::ReferenceUnknown) => {
1414 elements.push(Element::ReferenceLink(raw_text))
1415 }
1416 Some(LinkType::Collapsed) | Some(LinkType::CollapsedUnknown) => {
1417 elements.push(Element::EmptyReferenceLink(raw_text))
1418 }
1419 Some(LinkType::Shortcut) | Some(LinkType::ShortcutUnknown) => {
1420 elements.push(Element::ShortcutReference(raw_text))
1421 }
1422 Some(LinkType::Autolink) | Some(LinkType::Email) => {
1423 elements.push(Element::Autolink(raw_text))
1424 }
1425 _ => elements.push(Element::Link(raw_text)),
1426 }
1427 }
1428 remaining = &remaining[match_end..];
1429 }
1430 "wiki_link" => {
1431 if let Some(caps) = WIKI_LINK_REGEX.captures(remaining) {
1432 let content = caps.get(1).map_or("", |m| m.as_str());
1433 elements.push(Element::WikiLink(content.to_string()));
1434 remaining = &remaining[match_end..];
1435 } else {
1436 elements.push(Element::Text("[[".to_string()));
1437 remaining = &remaining[2..];
1438 }
1439 }
1440 "display_math" => {
1441 if let Some(caps) = DISPLAY_MATH_REGEX.captures(remaining) {
1442 let math = caps.get(1).map_or("", |m| m.as_str());
1443 elements.push(Element::DisplayMath(math.to_string()));
1444 remaining = &remaining[match_end..];
1445 } else {
1446 elements.push(Element::Text("$$".to_string()));
1447 remaining = &remaining[2..];
1448 }
1449 }
1450 "inline_math" => {
1451 if let Ok(Some(caps)) = INLINE_MATH_REGEX.captures(remaining) {
1452 let math = caps.get(1).map_or("", |m| m.as_str());
1453 elements.push(Element::InlineMath(math.to_string()));
1454 remaining = &remaining[match_end..];
1455 } else {
1456 elements.push(Element::Text("$".to_string()));
1457 remaining = &remaining[1..];
1458 }
1459 }
1460 "emoji" => {
1461 if let Some(caps) = EMOJI_SHORTCODE_REGEX.captures(remaining) {
1462 let emoji = caps.get(1).map_or("", |m| m.as_str());
1463 elements.push(Element::EmojiShortcode(emoji.to_string()));
1464 remaining = &remaining[match_end..];
1465 } else {
1466 elements.push(Element::Text(":".to_string()));
1467 remaining = &remaining[1..];
1468 }
1469 }
1470 "html_entity" => {
1471 elements.push(Element::HtmlEntity(remaining[pos..match_end].to_string()));
1473 remaining = &remaining[match_end..];
1474 }
1475 "hugo_shortcode" => {
1476 elements.push(Element::HugoShortcode(remaining[pos..match_end].to_string()));
1478 remaining = &remaining[match_end..];
1479 }
1480 "html_tag" => {
1481 elements.push(Element::HtmlTag(remaining[pos..match_end].to_string()));
1483 remaining = &remaining[match_end..];
1484 }
1485 _ => {
1486 elements.push(Element::Text("[".to_string()));
1488 remaining = &remaining[1..];
1489 }
1490 }
1491 } else {
1492 if next_special > 0 && next_special < remaining.len() {
1496 elements.push(Element::Text(remaining[..next_special].to_string()));
1497 remaining = &remaining[next_special..];
1498 }
1499
1500 match special_type {
1502 "pulldown_code" => {
1503 let span = next_code_span.unwrap();
1504 let span_len = span.end - span.start;
1505 let code = &remaining[..span_len];
1506 elements.push(Element::Code(code.to_string()));
1507 remaining = &remaining[span_len..];
1508 }
1509 "attr_list" => {
1510 elements.push(Element::AttrList(remaining[..attr_list_len].to_string()));
1511 remaining = &remaining[attr_list_len..];
1512 }
1513 "myst_role" => {
1514 elements.push(Element::MystRole(remaining[..myst_role_len].to_string()));
1515 remaining = &remaining[myst_role_len..];
1516 }
1517 "pulldown_emphasis" => {
1518 if let Some(span) = pulldown_emphasis {
1520 let span_len = span.end - span.start;
1521 if span.is_strikethrough {
1522 elements.push(Element::Strikethrough {
1523 content: span.content.clone(),
1524 double: span.strikethrough_double,
1525 });
1526 } else if span.is_strong {
1527 elements.push(Element::Bold {
1528 content: span.content.clone(),
1529 underscore: span.uses_underscore,
1530 });
1531 } else {
1532 elements.push(Element::Italic {
1533 content: span.content.clone(),
1534 underscore: span.uses_underscore,
1535 });
1536 }
1537 remaining = &remaining[span_len..];
1538 } else {
1539 elements.push(Element::Text(remaining[..1].to_string()));
1541 remaining = &remaining[1..];
1542 }
1543 }
1544 _ => {
1545 elements.push(Element::Text(remaining.to_string()));
1547 break;
1548 }
1549 }
1550 }
1551 }
1552
1553 elements
1554}
1555
1556fn should_insert_space_before_join(current: &str) -> bool {
1557 !current.is_empty()
1558 && !current.ends_with(' ')
1559 && !current.ends_with('(')
1560 && !current.ends_with('[')
1561 && !current.ends_with('-')
1562}
1563
1564fn is_setext_or_thematic(text: &str) -> bool {
1570 let mut marker = '\0';
1571 let mut count = 0usize;
1572 let mut has_space = false;
1573 for c in text.chars() {
1574 match c {
1575 ' ' | '\t' => has_space = true,
1576 '-' | '=' | '*' | '_' => {
1577 if marker == '\0' {
1578 marker = c;
1579 } else if c != marker {
1580 return false;
1581 }
1582 count += 1;
1583 }
1584 _ => return false,
1585 }
1586 }
1587 match marker {
1588 '=' => !has_space,
1589 '-' => !has_space || count >= 3,
1590 '*' | '_' => count >= 3,
1591 _ => false,
1592 }
1593}
1594
1595fn starts_block_construct(text: &str) -> bool {
1607 let text = text.trim_start();
1608 let bytes = text.as_bytes();
1609 let Some(&first) = bytes.first() else {
1610 return false;
1611 };
1612 let marker_then_boundary = |len: usize| bytes.len() == len || bytes[len] == b' ' || bytes[len] == b'\t';
1613 match first {
1614 b'>' => true,
1616 b'-' | b'*' | b'+' => marker_then_boundary(1) || is_setext_or_thematic(text),
1617 b'_' | b'=' => is_setext_or_thematic(text),
1618 b'#' => {
1619 let hashes = bytes.iter().take_while(|&&b| b == b'#').count();
1620 hashes <= 6 && marker_then_boundary(hashes)
1621 }
1622 b'`' => bytes.iter().take_while(|&&b| b == b'`').count() >= 3,
1623 b'~' => bytes.iter().take_while(|&&b| b == b'~').count() >= 3,
1624 b'0'..=b'9' => {
1625 let digits = bytes.iter().take_while(|b| b.is_ascii_digit()).count();
1626 digits <= 9
1627 && bytes.len() > digits
1628 && (bytes[digits] == b'.' || bytes[digits] == b')')
1629 && marker_then_boundary(digits + 1)
1630 }
1631 b'[' => {
1639 let mut escaped = false;
1640 let mut label_close = None;
1641 for (i, &b) in bytes.iter().enumerate().skip(1) {
1642 if escaped {
1643 escaped = false;
1644 } else if b == b'\\' {
1645 escaped = true;
1646 } else if b == b']' {
1647 label_close = Some(i);
1648 break;
1649 }
1650 }
1651 label_close.is_some_and(|i| bytes.get(i + 1) == Some(&b':'))
1652 }
1653 b'<' => crate::utils::html_block::parse_html_block_start(text).is_some(),
1656 _ => false,
1657 }
1658}
1659
1660fn merge_block_construct_continuations(lines: Vec<String>) -> Vec<String> {
1669 let mut merged: Vec<String> = Vec::with_capacity(lines.len());
1670 for line in lines {
1671 match merged.last_mut() {
1672 Some(prev) if starts_block_construct(&line) => {
1673 prev.push(' ');
1674 prev.push_str(line.trim_start());
1675 }
1676 _ => merged.push(line),
1677 }
1678 }
1679 merged
1680}
1681
1682fn reflow_elements_sentence_per_line(
1684 elements: &[Element],
1685 custom_abbreviations: &Option<Vec<String>>,
1686 require_sentence_capital: bool,
1687) -> Vec<String> {
1688 let abbreviations = get_abbreviations(custom_abbreviations);
1689 let mut lines = Vec::new();
1690 let mut current_line = String::new();
1691
1692 for (idx, element) in elements.iter().enumerate() {
1693 let element_str = format!("{element}");
1694
1695 if let Element::Text(text) = element {
1697 let combined = format!("{current_line}{text}");
1699 let sentences = split_into_sentences_with_set(&combined, &abbreviations, require_sentence_capital);
1701
1702 if sentences.len() > 1 {
1703 for (i, sentence) in sentences.iter().enumerate() {
1705 if i == 0 {
1706 let trimmed = sentence.trim();
1709
1710 if text_ends_with_abbreviation(trimmed, &abbreviations) {
1711 current_line.clone_from(sentence);
1713 } else {
1714 lines.push(sentence.clone());
1716 current_line.clear();
1717 }
1718 } else if i == sentences.len() - 1 {
1719 let trimmed = sentence.trim();
1721 let ends_with_sentence_punct = ends_with_sentence_punct(trimmed);
1722
1723 if ends_with_sentence_punct && !text_ends_with_abbreviation(trimmed, &abbreviations) {
1724 lines.push(sentence.clone());
1726 current_line.clear();
1727 } else {
1728 current_line.clone_from(sentence);
1730 }
1731 } else {
1732 lines.push(sentence.clone());
1734 }
1735 }
1736 } else {
1737 let trimmed = combined.trim();
1739
1740 if trimmed.is_empty() {
1744 continue;
1745 }
1746
1747 let ends_with_sentence_punct = ends_with_sentence_punct(trimmed);
1748
1749 if ends_with_sentence_punct && !text_ends_with_abbreviation(trimmed, &abbreviations) {
1750 lines.push(trimmed.to_string());
1752 current_line.clear();
1753 } else {
1754 current_line = combined;
1756 }
1757 }
1758 } else if let Element::Italic { content, underscore } = element {
1759 let marker = if *underscore { "_" } else { "*" };
1761 handle_emphasis_sentence_split(
1762 content,
1763 marker,
1764 &abbreviations,
1765 require_sentence_capital,
1766 &mut current_line,
1767 &mut lines,
1768 );
1769 } else if let Element::Bold { content, underscore } = element {
1770 let marker = if *underscore { "__" } else { "**" };
1772 handle_emphasis_sentence_split(
1773 content,
1774 marker,
1775 &abbreviations,
1776 require_sentence_capital,
1777 &mut current_line,
1778 &mut lines,
1779 );
1780 } else if let Element::Strikethrough { content, double } = element {
1781 handle_emphasis_sentence_split(
1783 content,
1784 if *double { "~~" } else { "~" },
1785 &abbreviations,
1786 require_sentence_capital,
1787 &mut current_line,
1788 &mut lines,
1789 );
1790 } else {
1791 let is_adjacent = if idx > 0 {
1794 match &elements[idx - 1] {
1795 Element::Text(t) => !t.is_empty() && !t.ends_with(char::is_whitespace),
1796 _ => true,
1797 }
1798 } else {
1799 false
1800 };
1801
1802 if !is_adjacent && should_insert_space_before_join(¤t_line) {
1804 current_line.push(' ');
1805 }
1806 current_line.push_str(&element_str);
1807 }
1808 }
1809
1810 if !current_line.is_empty() {
1812 lines.push(current_line.trim().to_string());
1813 }
1814 lines
1815}
1816
1817fn handle_emphasis_sentence_split(
1819 content: &str,
1820 marker: &str,
1821 abbreviations: &HashSet<String>,
1822 require_sentence_capital: bool,
1823 current_line: &mut String,
1824 lines: &mut Vec<String>,
1825) {
1826 let sentences = split_into_sentences_with_set(content, abbreviations, require_sentence_capital);
1828
1829 if sentences.len() <= 1 {
1830 if should_insert_space_before_join(current_line) {
1832 current_line.push(' ');
1833 }
1834 current_line.push_str(marker);
1835 current_line.push_str(content);
1836 current_line.push_str(marker);
1837
1838 let trimmed = content.trim();
1840 let ends_with_punct = ends_with_sentence_punct(trimmed);
1841 if ends_with_punct && !text_ends_with_abbreviation(trimmed, abbreviations) {
1842 lines.push(current_line.clone());
1843 current_line.clear();
1844 }
1845 } else {
1846 for (i, sentence) in sentences.iter().enumerate() {
1848 let trimmed = sentence.trim();
1849 if trimmed.is_empty() {
1850 continue;
1851 }
1852
1853 if i == 0 {
1854 if should_insert_space_before_join(current_line) {
1856 current_line.push(' ');
1857 }
1858 current_line.push_str(marker);
1859 current_line.push_str(trimmed);
1860 current_line.push_str(marker);
1861
1862 let ends_with_punct = ends_with_sentence_punct(trimmed);
1864 if ends_with_punct && !text_ends_with_abbreviation(trimmed, abbreviations) {
1865 lines.push(current_line.clone());
1866 current_line.clear();
1867 }
1868 } else if i == sentences.len() - 1 {
1869 let ends_with_punct = ends_with_sentence_punct(trimmed);
1871
1872 let mut line = String::new();
1873 line.push_str(marker);
1874 line.push_str(trimmed);
1875 line.push_str(marker);
1876
1877 if ends_with_punct && !text_ends_with_abbreviation(trimmed, abbreviations) {
1878 lines.push(line);
1879 } else {
1880 *current_line = line;
1882 }
1883 } else {
1884 let mut line = String::new();
1886 line.push_str(marker);
1887 line.push_str(trimmed);
1888 line.push_str(marker);
1889 lines.push(line);
1890 }
1891 }
1892 }
1893}
1894
1895const BREAK_WORDS: &[&str] = &[
1899 "and",
1900 "or",
1901 "but",
1902 "nor",
1903 "yet",
1904 "so",
1905 "for",
1906 "which",
1907 "that",
1908 "because",
1909 "when",
1910 "if",
1911 "while",
1912 "where",
1913 "although",
1914 "though",
1915 "unless",
1916 "since",
1917 "after",
1918 "before",
1919 "until",
1920 "as",
1921 "once",
1922 "whether",
1923 "however",
1924 "therefore",
1925 "moreover",
1926 "furthermore",
1927 "nevertheless",
1928 "whereas",
1929];
1930
1931fn is_clause_punctuation(c: char) -> bool {
1933 matches!(c, ',' | ';' | ':' | '\u{2014}') }
1935
1936fn clause_break_allowed_after(chars: &[char], i: usize) -> bool {
1944 if chars[i] == '\u{2014}' {
1945 return true;
1946 }
1947 match chars.get(i + 1) {
1948 None => true,
1949 Some(next) => next.is_whitespace(),
1950 }
1951}
1952
1953fn paren_group_end<'a>(slice: &'a str, element_spans: &[(usize, usize)], offset: usize) -> Option<(usize, &'a str)> {
1967 debug_assert!(slice.starts_with('('));
1968 let mut depth: i32 = 0;
1969 for (local_byte, c) in slice.char_indices() {
1970 let global_byte = offset + local_byte;
1971 if depth > 0 && is_inside_element(global_byte, element_spans) {
1976 continue;
1977 }
1978 match c {
1979 '(' => depth += 1,
1980 ')' => {
1981 depth -= 1;
1982 if depth == 0 {
1983 let end = local_byte + 1;
1984 let inner = &slice[1..local_byte];
1985 return Some((end, inner));
1986 }
1987 }
1988 _ => {}
1989 }
1990 }
1991 None
1992}
1993
1994fn split_at_parenthetical(
2011 text: &str,
2012 line_length: usize,
2013 element_spans: &[(usize, usize)],
2014 length_mode: ReflowLengthMode,
2015) -> Option<(String, String)> {
2016 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
2017
2018 if text.starts_with('(')
2020 && let Some((end_local, inner)) = paren_group_end(text, element_spans, 0)
2021 && inner.contains(' ')
2022 {
2023 let tail = &text[end_local..];
2027 let attached_len = tail
2028 .char_indices()
2029 .take_while(|(_, c)| is_closing_quote(*c) || is_clause_punctuation(*c))
2030 .last()
2031 .map_or(0, |(idx, c)| idx + c.len_utf8());
2032 let first_end = end_local + attached_len;
2033 let rest_start = first_end;
2034 let first = &text[..first_end];
2035 let first_len = display_len(first, length_mode);
2036 if first_len <= line_length {
2039 let rest = text[rest_start..].trim_start();
2040 if !rest.is_empty() {
2041 return Some((first.to_string(), rest.to_string()));
2042 }
2043 }
2044 }
2045
2046 let mut best_open_byte: Option<usize> = None;
2048 let mut pos = 0usize;
2049 while pos < text.len() {
2050 if text.as_bytes()[pos] != b'(' {
2052 let c = text[pos..].chars().next().unwrap();
2053 pos += c.len_utf8();
2054 continue;
2055 }
2056 if is_inside_element(pos, element_spans) {
2058 pos += 1;
2059 continue;
2060 }
2061 if let Some((end_local, inner)) = paren_group_end(&text[pos..], element_spans, pos) {
2062 let first = text[..pos].trim_end();
2063 let first_len = display_len(first, length_mode);
2064 if !first.is_empty()
2065 && first_len >= min_first_len
2066 && first_len <= line_length
2067 && inner.contains(' ')
2068 && best_open_byte.is_none_or(|prev| pos > prev)
2069 {
2070 best_open_byte = Some(pos);
2071 }
2072 pos += end_local;
2073 } else {
2074 pos += 1;
2075 }
2076 }
2077
2078 let open_byte = best_open_byte?;
2079 let first = text[..open_byte].trim_end().to_string();
2080 let rest = text[open_byte..].to_string();
2081 if first.is_empty() || rest.trim().is_empty() {
2082 return None;
2083 }
2084 Some((first, rest))
2085}
2086
2087fn compute_element_spans(elements: &[Element]) -> Vec<(usize, usize)> {
2091 let mut spans = Vec::new();
2092 let mut offset = 0;
2093 for element in elements {
2094 let rendered = format!("{element}");
2095 let len = rendered.len();
2096 if !matches!(element, Element::Text(_)) {
2097 spans.push((offset, offset + len));
2098 }
2099 offset += len;
2100 }
2101 spans
2102}
2103
2104fn is_inside_element(pos: usize, spans: &[(usize, usize)]) -> bool {
2106 spans.iter().any(|(start, end)| pos > *start && pos < *end)
2107}
2108
2109const MIN_SPLIT_RATIO: f64 = 0.3;
2112
2113fn split_at_clause_punctuation(
2117 text: &str,
2118 line_length: usize,
2119 element_spans: &[(usize, usize)],
2120 length_mode: ReflowLengthMode,
2121) -> Option<(String, String)> {
2122 let chars: Vec<char> = text.chars().collect();
2123 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
2124
2125 let mut width_acc = 0;
2127 let mut search_end_char = 0;
2128 for (idx, &c) in chars.iter().enumerate() {
2129 let c_width = display_len(&c.to_string(), length_mode);
2130 if width_acc + c_width > line_length {
2131 break;
2132 }
2133 width_acc += c_width;
2134 search_end_char = idx + 1;
2135 }
2136
2137 let mut paren_depth: i32 = 0;
2144 let mut best_pos = None;
2145 for i in (0..search_end_char).rev() {
2146 let byte_start: usize = chars[..i].iter().map(|c| c.len_utf8()).sum();
2148 let byte_after: usize = byte_start + chars[i].len_utf8();
2150
2151 if !is_inside_element(byte_start, element_spans) {
2152 match chars[i] {
2153 ')' => paren_depth += 1,
2154 '(' => paren_depth = paren_depth.saturating_sub(1),
2155 _ => {}
2156 }
2157 }
2158
2159 if paren_depth == 0
2160 && is_clause_punctuation(chars[i])
2161 && clause_break_allowed_after(&chars, i)
2162 && !is_inside_element(byte_after, element_spans)
2163 {
2164 best_pos = Some(i);
2165 break;
2166 }
2167 }
2168
2169 let pos = best_pos?;
2170
2171 let first: String = chars[..=pos].iter().collect();
2173 let first_display_len = display_len(&first, length_mode);
2174 if first_display_len < min_first_len {
2175 return None;
2176 }
2177
2178 let rest: String = chars[pos + 1..].iter().collect();
2180 let rest = rest.trim_start().to_string();
2181
2182 if rest.is_empty() {
2183 return None;
2184 }
2185
2186 Some((first, rest))
2187}
2188
2189fn paren_depth_map(text: &str, element_spans: &[(usize, usize)]) -> Vec<i32> {
2196 let mut map = vec![0i32; text.len()];
2197 let mut depth = 0i32;
2198 for (byte, c) in text.char_indices() {
2199 if !is_inside_element(byte, element_spans) {
2200 match c {
2201 '(' => depth += 1,
2202 ')' => depth = depth.saturating_sub(1),
2203 _ => {}
2204 }
2205 }
2206 let end = (byte + c.len_utf8()).min(map.len());
2208 for slot in &mut map[byte..end] {
2209 *slot = depth;
2210 }
2211 }
2212 map
2213}
2214
2215fn is_standalone_parenthetical(line: &str) -> bool {
2224 let trimmed = line.trim();
2225 if !trimmed.starts_with('(') {
2226 return false;
2227 }
2228 let core = trimmed.trim_end_matches(|c: char| is_clause_punctuation(c));
2230 if !core.ends_with(')') {
2231 return false;
2232 }
2233 let inner = &core[1..core.len() - 1];
2235 if !inner.contains(' ') {
2236 return false;
2237 }
2238 let mut depth = 0i32;
2240 for c in core.chars() {
2241 match c {
2242 '(' => depth += 1,
2243 ')' => depth -= 1,
2244 _ => {}
2245 }
2246 if depth < 0 {
2247 return false;
2248 }
2249 }
2250 depth == 0
2251}
2252
2253fn split_at_break_word(
2257 text: &str,
2258 line_length: usize,
2259 element_spans: &[(usize, usize)],
2260 length_mode: ReflowLengthMode,
2261) -> Option<(String, String)> {
2262 let lower = text.to_lowercase();
2263 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
2264 let mut best_split: Option<(usize, usize)> = None; let depth_map = paren_depth_map(text, element_spans);
2269
2270 for &word in BREAK_WORDS {
2271 let mut search_start = 0;
2272 while let Some(pos) = lower[search_start..].find(word) {
2273 let abs_pos = search_start + pos;
2274
2275 let preceded_by_space = abs_pos == 0 || text.as_bytes().get(abs_pos - 1) == Some(&b' ');
2277 let followed_by_space = text.as_bytes().get(abs_pos + word.len()) == Some(&b' ');
2278
2279 if preceded_by_space && followed_by_space {
2280 let first_part = text[..abs_pos].trim_end();
2282 let first_part_len = display_len(first_part, length_mode);
2283
2284 let inside_paren = depth_map.get(abs_pos).is_some_and(|&d| d > 0);
2286
2287 if first_part_len >= min_first_len
2288 && first_part_len <= line_length
2289 && !is_inside_element(abs_pos, element_spans)
2290 && !inside_paren
2291 {
2292 if best_split.is_none_or(|(prev_pos, _)| abs_pos > prev_pos) {
2294 best_split = Some((abs_pos, word.len()));
2295 }
2296 }
2297 }
2298
2299 search_start = abs_pos + word.len();
2300 }
2301 }
2302
2303 let (byte_start, _word_len) = best_split?;
2304
2305 let first = text[..byte_start].trim_end().to_string();
2306 let rest = text[byte_start..].to_string();
2307
2308 if first.is_empty() || rest.trim().is_empty() {
2309 return None;
2310 }
2311
2312 Some((first, rest))
2313}
2314
2315fn cascade_split_line(
2326 text: &str,
2327 line_length: usize,
2328 abbreviations: &Option<Vec<String>>,
2329 length_mode: ReflowLengthMode,
2330 attr_lists: bool,
2331 myst_roles: bool,
2332 defined_references: Option<&HashSet<String>>,
2333) -> Vec<String> {
2334 if line_length == 0 || display_len(text, length_mode) <= line_length {
2335 return vec![text.to_string()];
2336 }
2337
2338 let elements = parse_markdown_elements_inner(text, attr_lists, myst_roles, defined_references);
2339 let element_spans = compute_element_spans(&elements);
2340
2341 let rebased_spans = |start: usize| -> Vec<(usize, usize)> {
2345 if start == 0 {
2346 return element_spans.clone();
2347 }
2348 element_spans
2349 .iter()
2350 .filter(|&&(_, end)| end > start)
2351 .map(|&(s, e)| (s.saturating_sub(start), e.saturating_sub(start)))
2352 .collect()
2353 };
2354
2355 let mut result = Vec::new();
2356 let mut start = 0usize;
2357
2358 loop {
2359 let remaining = &text[start..];
2360 if display_len(remaining, length_mode) <= line_length {
2361 result.push(remaining.to_string());
2362 return result;
2363 }
2364
2365 let spans = rebased_spans(start);
2366
2367 let split = split_at_parenthetical(remaining, line_length, &spans, length_mode)
2371 .or_else(|| split_at_clause_punctuation(remaining, line_length, &spans, length_mode))
2372 .or_else(|| split_at_break_word(remaining, line_length, &spans, length_mode));
2373
2374 if let Some((first, rest)) = split {
2375 let consumed = remaining.len().saturating_sub(rest.len());
2376 if consumed == 0 {
2379 break;
2380 }
2381 result.push(first);
2382 start += consumed;
2383 continue;
2384 }
2385
2386 break;
2388 }
2389
2390 let options = ReflowOptions {
2392 line_length,
2393 break_on_sentences: false,
2394 preserve_breaks: false,
2395 sentence_per_line: false,
2396 semantic_line_breaks: false,
2397 abbreviations: abbreviations.clone(),
2398 length_mode,
2399 attr_lists,
2400 myst_roles,
2401 require_sentence_capital: true,
2402 max_list_continuation_indent: None,
2403 defined_references: None,
2406 };
2407 let remaining = &text[start..];
2408 let tail_elements = if start == 0 {
2409 elements
2410 } else {
2411 parse_markdown_elements_inner(remaining, attr_lists, myst_roles, defined_references)
2412 };
2413 result.extend(reflow_elements(&tail_elements, &options));
2414 result
2415}
2416
2417fn reflow_elements_semantic(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
2421 let sentence_lines =
2423 reflow_elements_sentence_per_line(elements, &options.abbreviations, options.require_sentence_capital);
2424
2425 if options.line_length == 0 {
2428 return sentence_lines;
2429 }
2430
2431 let length_mode = options.length_mode;
2432 let mut result = Vec::new();
2433 for line in sentence_lines {
2434 if display_len(&line, length_mode) <= options.line_length {
2435 result.push(line);
2436 } else {
2437 result.extend(cascade_split_line(
2438 &line,
2439 options.line_length,
2440 &options.abbreviations,
2441 length_mode,
2442 options.attr_lists,
2443 options.myst_roles,
2444 options.defined_references.as_ref(),
2445 ));
2446 }
2447 }
2448
2449 let min_line_len = ((options.line_length as f64) * MIN_SPLIT_RATIO) as usize;
2452 let mut merged: Vec<String> = Vec::with_capacity(result.len());
2453 for line in result {
2454 if !merged.is_empty() && display_len(&line, length_mode) < min_line_len && !line.trim().is_empty() {
2455 if is_standalone_parenthetical(&line) {
2458 merged.push(line);
2459 continue;
2460 }
2461
2462 let prev_ends_at_sentence = {
2464 let trimmed = merged.last().unwrap().trim_end();
2465 trimmed
2466 .chars()
2467 .rev()
2468 .find(|c| !matches!(c, '"' | '\'' | '\u{201D}' | '\u{2019}' | ')' | ']'))
2469 .is_some_and(|c| matches!(c, '.' | '!' | '?'))
2470 };
2471
2472 if !prev_ends_at_sentence {
2473 let prev = merged.last_mut().unwrap();
2474 let combined = format!("{prev} {line}");
2475 if display_len(&combined, length_mode) <= options.line_length {
2477 *prev = combined;
2478 continue;
2479 }
2480 }
2481 }
2482 merged.push(line);
2483 }
2484 merged
2485}
2486
2487fn rfind_safe_space(line: &str, element_spans: &[(usize, usize)]) -> Option<usize> {
2497 line.char_indices().rev().map(|(pos, _)| pos).find(|&pos| {
2498 line.as_bytes()[pos] == b' '
2499 && !element_spans.iter().any(|(s, e)| pos > *s && pos < *e)
2500 && !starts_block_construct(&line[pos + 1..])
2501 })
2502}
2503
2504fn reflow_elements(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
2506 let mut lines = Vec::new();
2507 let mut current_line = String::new();
2508 let mut current_length = 0;
2509 let mut current_line_element_spans: Vec<(usize, usize)> = Vec::new();
2511 let length_mode = options.length_mode;
2512
2513 for (idx, element) in elements.iter().enumerate() {
2514 let element_str = format!("{element}");
2517 let element_len = display_len(&element_str, length_mode);
2518
2519 let is_adjacent_to_prev = if idx > 0 {
2525 match (&elements[idx - 1], element) {
2526 (Element::Text(t), _) => !t.is_empty() && !t.ends_with(char::is_whitespace),
2527 (_, Element::Text(t)) => !t.is_empty() && !t.starts_with(char::is_whitespace),
2528 _ => true,
2529 }
2530 } else {
2531 false
2532 };
2533
2534 if let Element::Text(text) = element {
2536 let has_leading_space = text.starts_with(char::is_whitespace);
2538 let words: Vec<&str> = text.split_whitespace().collect();
2540
2541 for (i, word) in words.iter().enumerate() {
2542 let word_len = display_len(word, length_mode);
2543 let is_trailing_punct = word
2545 .chars()
2546 .all(|c| matches!(c, ',' | '.' | ':' | ';' | '!' | '?' | ')' | ']' | '}'));
2547
2548 let is_first_adjacent = i == 0 && is_adjacent_to_prev;
2551
2552 if is_first_adjacent {
2553 if current_length + word_len > options.line_length && current_length > 0 {
2555 if let Some(last_space) = rfind_safe_space(¤t_line, ¤t_line_element_spans) {
2559 let before = current_line[..last_space].trim_end().to_string();
2560 let after = current_line[last_space + 1..].to_string();
2561 lines.push(before);
2562 current_line = format!("{after}{word}");
2563 current_length = display_len(¤t_line, length_mode);
2564 current_line_element_spans.clear();
2565 } else {
2566 current_line.push_str(word);
2567 current_length += word_len;
2568 }
2569 } else {
2570 current_line.push_str(word);
2571 current_length += word_len;
2572 }
2573 } else if current_length > 0
2574 && current_length + 1 + word_len > options.line_length
2575 && !is_trailing_punct
2576 {
2577 if !starts_block_construct(word) {
2578 lines.push(current_line.trim().to_string());
2580 current_line = word.to_string();
2581 current_length = word_len;
2582 current_line_element_spans.clear();
2583 } else if let Some(last_space) = rfind_safe_space(¤t_line, ¤t_line_element_spans) {
2584 let before = current_line[..last_space].trim_end().to_string();
2589 let after = current_line[last_space + 1..].to_string();
2590 lines.push(before);
2591 current_line = format!("{after} {word}");
2592 current_length = display_len(¤t_line, length_mode);
2593 current_line_element_spans.clear();
2594 } else {
2595 if i > 0 || has_leading_space {
2598 current_line.push(' ');
2599 current_length += 1;
2600 }
2601 current_line.push_str(word);
2602 current_length += word_len;
2603 }
2604 } else {
2605 let add_space = current_length > 0 && if i == 0 { has_leading_space } else { !is_trailing_punct };
2615 if add_space {
2616 current_line.push(' ');
2617 current_length += 1;
2618 }
2619 current_line.push_str(word);
2620 current_length += word_len;
2621 }
2622 }
2623 } else if matches!(
2624 element,
2625 Element::Italic { .. } | Element::Bold { .. } | Element::Strikethrough { .. }
2626 ) && element_len > options.line_length
2627 {
2628 let (content, marker): (&str, &str) = match element {
2632 Element::Italic { content, underscore } => (content.as_str(), if *underscore { "_" } else { "*" }),
2633 Element::Bold { content, underscore } => (content.as_str(), if *underscore { "__" } else { "**" }),
2634 Element::Strikethrough { content, double } => (content.as_str(), if *double { "~~" } else { "~" }),
2635 _ => unreachable!(),
2636 };
2637
2638 let words: Vec<&str> = content.split_whitespace().collect();
2639 let n = words.len();
2640
2641 if n == 0 {
2642 let full = format!("{marker}{marker}");
2644 let full_len = display_len(&full, length_mode);
2645 if !is_adjacent_to_prev && current_length > 0 {
2646 current_line.push(' ');
2647 current_length += 1;
2648 }
2649 current_line.push_str(&full);
2650 current_length += full_len;
2651 } else {
2652 for (i, word) in words.iter().enumerate() {
2653 let is_first = i == 0;
2654 let is_last = i == n - 1;
2655 let word_str: String = match (is_first, is_last) {
2656 (true, true) => format!("{marker}{word}{marker}"),
2657 (true, false) => format!("{marker}{word}"),
2658 (false, true) => format!("{word}{marker}"),
2659 (false, false) => word.to_string(),
2660 };
2661 let word_len = display_len(&word_str, length_mode);
2662
2663 let needs_space = if is_first {
2664 !is_adjacent_to_prev && current_length > 0
2665 } else {
2666 current_length > 0
2667 };
2668
2669 if needs_space
2670 && current_length + 1 + word_len > options.line_length
2671 && !starts_block_construct(&word_str)
2672 {
2673 lines.push(current_line.trim_end().to_string());
2674 current_line = word_str;
2675 current_length = word_len;
2676 current_line_element_spans.clear();
2677 } else {
2678 if needs_space {
2679 current_line.push(' ');
2680 current_length += 1;
2681 }
2682 current_line.push_str(&word_str);
2683 current_length += word_len;
2684 }
2685 }
2686 }
2687 } else {
2688 if is_adjacent_to_prev {
2692 if current_length + element_len > options.line_length {
2694 if let Some(last_space) = rfind_safe_space(¤t_line, ¤t_line_element_spans) {
2698 let before = current_line[..last_space].trim_end().to_string();
2699 let after = current_line[last_space + 1..].to_string();
2700 lines.push(before);
2701 current_line = format!("{after}{element_str}");
2702 current_length = display_len(¤t_line, length_mode);
2703 current_line_element_spans.clear();
2704 let start = after.len();
2706 current_line_element_spans.push((start, start + element_str.len()));
2707 } else {
2708 let start = current_line.len();
2710 current_line.push_str(&element_str);
2711 current_length += element_len;
2712 current_line_element_spans.push((start, current_line.len()));
2713 }
2714 } else {
2715 let start = current_line.len();
2716 current_line.push_str(&element_str);
2717 current_length += element_len;
2718 current_line_element_spans.push((start, current_line.len()));
2719 }
2720 } else if current_length > 0 && current_length + 1 + element_len > options.line_length {
2721 if !starts_block_construct(&element_str) {
2722 lines.push(current_line.trim().to_string());
2724 current_line.clone_from(&element_str);
2725 current_length = element_len;
2726 current_line_element_spans.clear();
2727 current_line_element_spans.push((0, element_str.len()));
2728 } else if let Some(last_space) = rfind_safe_space(¤t_line, ¤t_line_element_spans) {
2729 let before = current_line[..last_space].trim_end().to_string();
2733 let after = current_line[last_space + 1..].to_string();
2734 lines.push(before);
2735 current_line = format!("{after} {element_str}");
2736 current_length = display_len(¤t_line, length_mode);
2737 current_line_element_spans.clear();
2738 let start = after.len() + 1;
2739 current_line_element_spans.push((start, start + element_str.len()));
2740 } else {
2741 let ends_with_opener =
2744 current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
2745 if !ends_with_opener {
2746 current_line.push(' ');
2747 current_length += 1;
2748 }
2749 let start = current_line.len();
2750 current_line.push_str(&element_str);
2751 current_length += element_len;
2752 current_line_element_spans.push((start, current_line.len()));
2753 }
2754 } else {
2755 let ends_with_opener =
2757 current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
2758 if current_length > 0 && !ends_with_opener {
2759 current_line.push(' ');
2760 current_length += 1;
2761 }
2762 let start = current_line.len();
2763 current_line.push_str(&element_str);
2764 current_length += element_len;
2765 current_line_element_spans.push((start, current_line.len()));
2766 }
2767 }
2768 }
2769
2770 if !current_line.is_empty() {
2772 lines.push(current_line.trim_end().to_string());
2773 }
2774
2775 lines
2776}
2777
2778pub fn reflow_markdown(content: &str, options: &ReflowOptions) -> String {
2780 let lines: Vec<&str> = content.lines().collect();
2781 let mut result = Vec::new();
2782 let mut i = 0;
2783
2784 while i < lines.len() {
2785 let line = lines[i];
2786 let trimmed = line.trim();
2787
2788 if trimmed.is_empty() {
2790 result.push(String::new());
2791 i += 1;
2792 continue;
2793 }
2794
2795 if trimmed.starts_with('#') {
2797 result.push(line.to_string());
2798 i += 1;
2799 continue;
2800 }
2801
2802 if trimmed.starts_with(":::") {
2804 result.push(line.to_string());
2805 i += 1;
2806 continue;
2807 }
2808
2809 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
2811 result.push(line.to_string());
2812 i += 1;
2813 while i < lines.len() {
2815 result.push(lines[i].to_string());
2816 if lines[i].trim().starts_with("```") || lines[i].trim().starts_with("~~~") {
2817 i += 1;
2818 break;
2819 }
2820 i += 1;
2821 }
2822 continue;
2823 }
2824
2825 if calculate_indentation_width_default(line) >= 4 {
2827 result.push(line.to_string());
2829 i += 1;
2830 while i < lines.len() {
2831 let next_line = lines[i];
2832 if calculate_indentation_width_default(next_line) >= 4 || next_line.trim().is_empty() {
2834 result.push(next_line.to_string());
2835 i += 1;
2836 } else {
2837 break;
2838 }
2839 }
2840 continue;
2841 }
2842
2843 if trimmed.starts_with('>') {
2845 let gt_pos = line.find('>').expect("'>' must exist since trimmed.starts_with('>')");
2848 let quote_prefix = line[0..=gt_pos].to_string();
2849 let quote_content = &line[quote_prefix.len()..].trim_start();
2850
2851 let reflowed = reflow_line(quote_content, options);
2852 for reflowed_line in &reflowed {
2853 result.push(format!("{quote_prefix} {reflowed_line}"));
2854 }
2855 i += 1;
2856 continue;
2857 }
2858
2859 if is_horizontal_rule(trimmed) {
2861 result.push(line.to_string());
2862 i += 1;
2863 continue;
2864 }
2865
2866 if is_unordered_list_marker(trimmed) || is_numbered_list_item(trimmed) {
2868 let indent = line.len() - line.trim_start().len();
2870 let indent_str = " ".repeat(indent);
2871
2872 let mut marker_end = indent;
2875 let mut content_start = indent;
2876
2877 if trimmed.chars().next().is_some_and(char::is_numeric) {
2878 if let Some(period_pos) = line[indent..].find('.') {
2880 marker_end = indent + period_pos + 1; content_start = marker_end;
2882 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
2886 content_start += 1;
2887 }
2888 }
2889 } else {
2890 marker_end = indent + 1; content_start = marker_end;
2893 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
2897 content_start += 1;
2898 }
2899 }
2900
2901 let min_continuation_indent = content_start;
2903
2904 let rest = &line[content_start..];
2907 if rest.starts_with("[ ] ") || rest.starts_with("[x] ") || rest.starts_with("[X] ") {
2908 marker_end = content_start + 3; content_start += 4; }
2911
2912 let marker = &line[indent..marker_end];
2913
2914 let mut list_content = vec![trim_preserving_hard_break(&line[content_start..])];
2917 i += 1;
2918
2919 while i < lines.len() {
2923 let next_line = lines[i];
2924 let next_trimmed = next_line.trim();
2925
2926 if is_block_boundary(next_trimmed) {
2928 break;
2929 }
2930
2931 let next_indent = next_line.len() - next_line.trim_start().len();
2933 if next_indent >= min_continuation_indent {
2934 let trimmed_start = next_line.trim_start();
2937 list_content.push(trim_preserving_hard_break(trimmed_start));
2938 i += 1;
2939 } else {
2940 break;
2942 }
2943 }
2944
2945 let combined_content = if options.preserve_breaks {
2948 list_content[0].clone()
2949 } else {
2950 let has_hard_breaks = list_content.iter().any(|line| has_hard_break(line));
2952 if has_hard_breaks {
2953 list_content.join("\n")
2955 } else {
2956 list_content.join(" ")
2958 }
2959 };
2960
2961 let trimmed_marker = marker;
2963 let continuation_spaces = if let Some(max_indent) = options.max_list_continuation_indent {
2964 indent + (content_start - indent).min(max_indent)
2967 } else {
2968 content_start
2969 };
2970
2971 let prefix_length = indent + trimmed_marker.len() + 1;
2973
2974 let adjusted_options = ReflowOptions {
2976 line_length: options.line_length.saturating_sub(prefix_length),
2977 ..options.clone()
2978 };
2979
2980 let reflowed = reflow_line(&combined_content, &adjusted_options);
2981 for (j, reflowed_line) in reflowed.iter().enumerate() {
2982 if j == 0 {
2983 result.push(format!("{indent_str}{trimmed_marker} {reflowed_line}"));
2984 } else {
2985 let continuation_indent = " ".repeat(continuation_spaces);
2987 result.push(format!("{continuation_indent}{reflowed_line}"));
2988 }
2989 }
2990 continue;
2991 }
2992
2993 if crate::utils::table_utils::TableUtils::is_potential_table_row(line) {
2995 result.push(line.to_string());
2996 i += 1;
2997 continue;
2998 }
2999
3000 if trimmed.starts_with('[') && line.contains("]:") {
3002 result.push(line.to_string());
3003 i += 1;
3004 continue;
3005 }
3006
3007 if is_definition_list_item(trimmed) {
3009 result.push(line.to_string());
3010 i += 1;
3011 continue;
3012 }
3013
3014 let mut is_single_line_paragraph = true;
3016 if i + 1 < lines.len() {
3017 let next_trimmed = lines[i + 1].trim();
3018 if !is_block_boundary(next_trimmed) {
3020 is_single_line_paragraph = false;
3021 }
3022 }
3023
3024 if is_single_line_paragraph && display_len(line, options.length_mode) <= options.line_length {
3026 result.push(line.to_string());
3027 i += 1;
3028 continue;
3029 }
3030
3031 let mut paragraph_parts = Vec::new();
3033 let mut current_part = vec![line];
3034 i += 1;
3035
3036 if options.preserve_breaks {
3038 let hard_break_type = if line.strip_suffix('\r').unwrap_or(line).ends_with('\\') {
3040 Some("\\")
3041 } else if line.ends_with(" ") {
3042 Some(" ")
3043 } else {
3044 None
3045 };
3046 let reflowed = reflow_line(line, options);
3047
3048 if let Some(break_marker) = hard_break_type {
3050 if !reflowed.is_empty() {
3051 let mut reflowed_with_break = reflowed;
3052 let last_idx = reflowed_with_break.len() - 1;
3053 if !has_hard_break(&reflowed_with_break[last_idx]) {
3054 reflowed_with_break[last_idx].push_str(break_marker);
3055 }
3056 result.extend(reflowed_with_break);
3057 }
3058 } else {
3059 result.extend(reflowed);
3060 }
3061 } else {
3062 while i < lines.len() {
3064 let prev_line = if !current_part.is_empty() {
3065 current_part.last().unwrap()
3066 } else {
3067 ""
3068 };
3069 let next_line = lines[i];
3070 let next_trimmed = next_line.trim();
3071
3072 if is_block_boundary(next_trimmed) {
3074 break;
3075 }
3076
3077 let prev_trimmed = prev_line.trim();
3080 let abbreviations = get_abbreviations(&options.abbreviations);
3081 let ends_with_sentence = (prev_trimmed.ends_with('.')
3082 || prev_trimmed.ends_with('!')
3083 || prev_trimmed.ends_with('?')
3084 || prev_trimmed.ends_with(".*")
3085 || prev_trimmed.ends_with("!*")
3086 || prev_trimmed.ends_with("?*")
3087 || prev_trimmed.ends_with("._")
3088 || prev_trimmed.ends_with("!_")
3089 || prev_trimmed.ends_with("?_")
3090 || prev_trimmed.ends_with(".\"")
3092 || prev_trimmed.ends_with("!\"")
3093 || prev_trimmed.ends_with("?\"")
3094 || prev_trimmed.ends_with(".'")
3095 || prev_trimmed.ends_with("!'")
3096 || prev_trimmed.ends_with("?'")
3097 || prev_trimmed.ends_with(".\u{201D}")
3098 || prev_trimmed.ends_with("!\u{201D}")
3099 || prev_trimmed.ends_with("?\u{201D}")
3100 || prev_trimmed.ends_with(".\u{2019}")
3101 || prev_trimmed.ends_with("!\u{2019}")
3102 || prev_trimmed.ends_with("?\u{2019}"))
3103 && !text_ends_with_abbreviation(
3104 prev_trimmed.trim_end_matches(['*', '_', '"', '\'', '\u{201D}', '\u{2019}']),
3105 &abbreviations,
3106 );
3107
3108 if has_hard_break(prev_line) || (options.sentence_per_line && ends_with_sentence) {
3109 paragraph_parts.push(current_part.join(" "));
3111 current_part = vec![next_line];
3112 } else {
3113 current_part.push(next_line);
3114 }
3115 i += 1;
3116 }
3117
3118 if !current_part.is_empty() {
3120 if current_part.len() == 1 {
3121 paragraph_parts.push(current_part[0].to_string());
3123 } else {
3124 paragraph_parts.push(current_part.join(" "));
3125 }
3126 }
3127
3128 for (j, part) in paragraph_parts.iter().enumerate() {
3130 let reflowed = reflow_line(part, options);
3131 result.extend(reflowed);
3132
3133 if j < paragraph_parts.len() - 1 && !result.is_empty() && !options.sentence_per_line {
3137 let last_idx = result.len() - 1;
3138 if !has_hard_break(&result[last_idx]) {
3139 result[last_idx].push_str(" ");
3140 }
3141 }
3142 }
3143 }
3144 }
3145
3146 let result_text = result.join("\n");
3148 if content.ends_with('\n') && !result_text.ends_with('\n') {
3149 format!("{result_text}\n")
3150 } else {
3151 result_text
3152 }
3153}
3154
3155#[derive(Debug, Clone)]
3157pub struct ParagraphReflow {
3158 pub start_byte: usize,
3160 pub end_byte: usize,
3162 pub reflowed_text: String,
3164}
3165
3166#[derive(Debug, Clone)]
3172pub struct BlockquoteLineData {
3173 pub(crate) content: String,
3175 pub(crate) is_explicit: bool,
3177 pub(crate) prefix: Option<String>,
3179}
3180
3181impl BlockquoteLineData {
3182 pub fn explicit(content: String, prefix: String) -> Self {
3184 Self {
3185 content,
3186 is_explicit: true,
3187 prefix: Some(prefix),
3188 }
3189 }
3190
3191 pub fn lazy(content: String) -> Self {
3193 Self {
3194 content,
3195 is_explicit: false,
3196 prefix: None,
3197 }
3198 }
3199}
3200
3201#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3203pub enum BlockquoteContinuationStyle {
3204 Explicit,
3205 Lazy,
3206}
3207
3208pub fn blockquote_continuation_style(lines: &[BlockquoteLineData]) -> BlockquoteContinuationStyle {
3216 let mut explicit_count = 0usize;
3217 let mut lazy_count = 0usize;
3218
3219 for line in lines.iter().skip(1) {
3220 if line.is_explicit {
3221 explicit_count += 1;
3222 } else {
3223 lazy_count += 1;
3224 }
3225 }
3226
3227 if explicit_count > 0 && lazy_count == 0 {
3228 BlockquoteContinuationStyle::Explicit
3229 } else if lazy_count > 0 && explicit_count == 0 {
3230 BlockquoteContinuationStyle::Lazy
3231 } else if explicit_count >= lazy_count {
3232 BlockquoteContinuationStyle::Explicit
3233 } else {
3234 BlockquoteContinuationStyle::Lazy
3235 }
3236}
3237
3238pub fn dominant_blockquote_prefix(lines: &[BlockquoteLineData], fallback: &str) -> String {
3243 let mut counts: std::collections::HashMap<String, (usize, usize)> = std::collections::HashMap::new();
3244
3245 for (idx, line) in lines.iter().enumerate() {
3246 let Some(prefix) = line.prefix.as_ref() else {
3247 continue;
3248 };
3249 counts
3250 .entry(prefix.clone())
3251 .and_modify(|entry| entry.0 += 1)
3252 .or_insert((1, idx));
3253 }
3254
3255 counts
3256 .into_iter()
3257 .max_by(|(_, (count_a, first_idx_a)), (_, (count_b, first_idx_b))| {
3258 count_a.cmp(count_b).then_with(|| first_idx_b.cmp(first_idx_a))
3259 })
3260 .map_or_else(|| fallback.to_string(), |(prefix, _)| prefix)
3261}
3262
3263pub(crate) fn should_force_explicit_blockquote_line(content_line: &str) -> bool {
3268 let trimmed = content_line.trim_start();
3269 trimmed.starts_with('>')
3270 || trimmed.starts_with('#')
3271 || trimmed.starts_with("```")
3272 || trimmed.starts_with("~~~")
3273 || is_unordered_list_marker(trimmed)
3274 || is_numbered_list_item(trimmed)
3275 || is_horizontal_rule(trimmed)
3276 || is_definition_list_item(trimmed)
3277 || (trimmed.starts_with('[') && trimmed.contains("]:"))
3278 || trimmed.starts_with(":::")
3279 || (trimmed.starts_with('<')
3280 && !trimmed.starts_with("<http")
3281 && !trimmed.starts_with("<https")
3282 && !trimmed.starts_with("<mailto:"))
3283}
3284
3285pub fn reflow_blockquote_content(
3294 lines: &[BlockquoteLineData],
3295 explicit_prefix: &str,
3296 continuation_style: BlockquoteContinuationStyle,
3297 options: &ReflowOptions,
3298) -> Vec<String> {
3299 let content_strs: Vec<&str> = lines.iter().map(|l| l.content.as_str()).collect();
3300 let segments = split_into_segments_strs(&content_strs);
3301 let mut reflowed_content_lines: Vec<String> = Vec::new();
3302
3303 for segment in segments {
3304 let hard_break_type = segment.last().and_then(|&line| {
3305 let line = line.strip_suffix('\r').unwrap_or(line);
3306 if line.ends_with('\\') {
3307 Some("\\")
3308 } else if line.ends_with(" ") {
3309 Some(" ")
3310 } else {
3311 None
3312 }
3313 });
3314
3315 let pieces: Vec<&str> = segment
3316 .iter()
3317 .map(|&line| {
3318 if let Some(l) = line.strip_suffix('\\') {
3319 l.trim_end()
3320 } else if let Some(l) = line.strip_suffix(" ") {
3321 l.trim_end()
3322 } else {
3323 line.trim_end()
3324 }
3325 })
3326 .collect();
3327
3328 let segment_text = pieces.join(" ");
3329 let segment_text = segment_text.trim();
3330 if segment_text.is_empty() {
3331 continue;
3332 }
3333
3334 let mut reflowed = reflow_line(segment_text, options);
3335 if let Some(break_marker) = hard_break_type
3336 && !reflowed.is_empty()
3337 {
3338 let last_idx = reflowed.len() - 1;
3339 if !has_hard_break(&reflowed[last_idx]) {
3340 reflowed[last_idx].push_str(break_marker);
3341 }
3342 }
3343 reflowed_content_lines.extend(reflowed);
3344 }
3345
3346 let mut styled_lines: Vec<String> = Vec::new();
3347 for (idx, line) in reflowed_content_lines.iter().enumerate() {
3348 let force_explicit = idx == 0
3349 || continuation_style == BlockquoteContinuationStyle::Explicit
3350 || should_force_explicit_blockquote_line(line);
3351 if force_explicit {
3352 styled_lines.push(format!("{explicit_prefix}{line}"));
3353 } else {
3354 styled_lines.push(line.clone());
3355 }
3356 }
3357
3358 styled_lines
3359}
3360
3361fn is_blockquote_content_boundary(content: &str) -> bool {
3362 let trimmed = content.trim();
3363 trimmed.is_empty()
3364 || is_block_boundary(trimmed)
3365 || crate::utils::table_utils::TableUtils::is_potential_table_row(content)
3366 || trimmed.starts_with(":::")
3367 || crate::utils::is_template_directive_only(content)
3368 || is_standalone_attr_list(content)
3369 || is_snippet_block_delimiter(content)
3370}
3371
3372fn split_into_segments_strs<'a>(lines: &[&'a str]) -> Vec<Vec<&'a str>> {
3373 let mut segments = Vec::new();
3374 let mut current = Vec::new();
3375
3376 for &line in lines {
3377 current.push(line);
3378 if has_hard_break(line) {
3379 segments.push(current);
3380 current = Vec::new();
3381 }
3382 }
3383
3384 if !current.is_empty() {
3385 segments.push(current);
3386 }
3387
3388 segments
3389}
3390
3391fn reflow_blockquote_paragraph_at_line(
3392 content: &str,
3393 lines: &[&str],
3394 target_idx: usize,
3395 options: &ReflowOptions,
3396) -> Option<ParagraphReflow> {
3397 let mut anchor_idx = target_idx;
3398 let mut target_level = if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[target_idx]) {
3399 parsed.nesting_level
3400 } else {
3401 let mut found = None;
3402 let mut idx = target_idx;
3403 loop {
3404 if lines[idx].trim().is_empty() {
3405 break;
3406 }
3407 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[idx]) {
3408 found = Some((idx, parsed.nesting_level));
3409 break;
3410 }
3411 if idx == 0 {
3412 break;
3413 }
3414 idx -= 1;
3415 }
3416 let (idx, level) = found?;
3417 anchor_idx = idx;
3418 level
3419 };
3420
3421 let mut para_start = anchor_idx;
3423 while para_start > 0 {
3424 let prev_idx = para_start - 1;
3425 let prev_line = lines[prev_idx];
3426
3427 if prev_line.trim().is_empty() {
3428 break;
3429 }
3430
3431 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(prev_line) {
3432 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
3433 break;
3434 }
3435 para_start = prev_idx;
3436 continue;
3437 }
3438
3439 let prev_lazy = prev_line.trim_start();
3440 if is_blockquote_content_boundary(prev_lazy) {
3441 break;
3442 }
3443 para_start = prev_idx;
3444 }
3445
3446 while para_start < lines.len() {
3448 let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[para_start]) else {
3449 para_start += 1;
3450 continue;
3451 };
3452 target_level = parsed.nesting_level;
3453 break;
3454 }
3455
3456 if para_start >= lines.len() || para_start > target_idx {
3457 return None;
3458 }
3459
3460 let mut collected: Vec<(usize, BlockquoteLineData)> = Vec::new();
3463 let mut idx = para_start;
3464 while idx < lines.len() {
3465 if !collected.is_empty() && has_hard_break(&collected[collected.len() - 1].1.content) {
3466 break;
3467 }
3468
3469 let line = lines[idx];
3470 if line.trim().is_empty() {
3471 break;
3472 }
3473
3474 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(line) {
3475 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
3476 break;
3477 }
3478 collected.push((
3479 idx,
3480 BlockquoteLineData::explicit(trim_preserving_hard_break(parsed.content), parsed.prefix.to_string()),
3481 ));
3482 idx += 1;
3483 continue;
3484 }
3485
3486 let lazy_content = line.trim_start();
3487 if is_blockquote_content_boundary(lazy_content) {
3488 break;
3489 }
3490
3491 collected.push((idx, BlockquoteLineData::lazy(trim_preserving_hard_break(lazy_content))));
3492 idx += 1;
3493 }
3494
3495 if collected.is_empty() {
3496 return None;
3497 }
3498
3499 let para_end = collected[collected.len() - 1].0;
3500 if target_idx < para_start || target_idx > para_end {
3501 return None;
3502 }
3503
3504 let line_data: Vec<BlockquoteLineData> = collected.iter().map(|(_, d)| d.clone()).collect();
3505
3506 let fallback_prefix = line_data
3507 .iter()
3508 .find_map(|d| d.prefix.clone())
3509 .unwrap_or_else(|| "> ".to_string());
3510 let explicit_prefix = dominant_blockquote_prefix(&line_data, &fallback_prefix);
3511 let continuation_style = blockquote_continuation_style(&line_data);
3512
3513 let adjusted_line_length = options
3514 .line_length
3515 .saturating_sub(display_len(&explicit_prefix, options.length_mode))
3516 .max(1);
3517
3518 let adjusted_options = ReflowOptions {
3519 line_length: adjusted_line_length,
3520 ..options.clone()
3521 };
3522
3523 let styled_lines = reflow_blockquote_content(&line_data, &explicit_prefix, continuation_style, &adjusted_options);
3524
3525 if styled_lines.is_empty() {
3526 return None;
3527 }
3528
3529 let mut start_byte = 0;
3531 for line in lines.iter().take(para_start) {
3532 start_byte += line.len() + 1;
3533 }
3534
3535 let mut end_byte = start_byte;
3536 for line in lines.iter().take(para_end + 1).skip(para_start) {
3537 end_byte += line.len() + 1;
3538 }
3539
3540 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
3541 if !includes_trailing_newline {
3542 end_byte -= 1;
3543 }
3544
3545 let reflowed_joined = styled_lines.join("\n");
3546 let reflowed_text = if includes_trailing_newline {
3547 if reflowed_joined.ends_with('\n') {
3548 reflowed_joined
3549 } else {
3550 format!("{reflowed_joined}\n")
3551 }
3552 } else if reflowed_joined.ends_with('\n') {
3553 reflowed_joined.trim_end_matches('\n').to_string()
3554 } else {
3555 reflowed_joined
3556 };
3557
3558 Some(ParagraphReflow {
3559 start_byte,
3560 end_byte,
3561 reflowed_text,
3562 })
3563}
3564
3565pub fn reflow_paragraph_at_line(content: &str, line_number: usize, line_length: usize) -> Option<ParagraphReflow> {
3583 reflow_paragraph_at_line_with_mode(content, line_number, line_length, ReflowLengthMode::default())
3584}
3585
3586pub fn reflow_paragraph_at_line_with_mode(
3588 content: &str,
3589 line_number: usize,
3590 line_length: usize,
3591 length_mode: ReflowLengthMode,
3592) -> Option<ParagraphReflow> {
3593 let options = ReflowOptions {
3594 line_length,
3595 length_mode,
3596 ..Default::default()
3597 };
3598 reflow_paragraph_at_line_with_options(content, line_number, &options)
3599}
3600
3601pub fn reflow_paragraph_at_line_with_options(
3612 content: &str,
3613 line_number: usize,
3614 options: &ReflowOptions,
3615) -> Option<ParagraphReflow> {
3616 if line_number == 0 {
3617 return None;
3618 }
3619
3620 let lines: Vec<&str> = content.lines().collect();
3621
3622 if line_number > lines.len() {
3624 return None;
3625 }
3626
3627 let target_idx = line_number - 1; let target_line = lines[target_idx];
3629 let trimmed = target_line.trim();
3630
3631 if let Some(blockquote_reflow) = reflow_blockquote_paragraph_at_line(content, &lines, target_idx, options) {
3634 return Some(blockquote_reflow);
3635 }
3636
3637 if is_paragraph_boundary(trimmed, target_line) {
3639 return None;
3640 }
3641
3642 let mut para_start = target_idx;
3644 while para_start > 0 {
3645 let prev_idx = para_start - 1;
3646 let prev_line = lines[prev_idx];
3647 let prev_trimmed = prev_line.trim();
3648
3649 if is_paragraph_boundary(prev_trimmed, prev_line) {
3651 break;
3652 }
3653
3654 para_start = prev_idx;
3655 }
3656
3657 let mut para_end = target_idx;
3659 while para_end + 1 < lines.len() {
3660 let next_idx = para_end + 1;
3661 let next_line = lines[next_idx];
3662 let next_trimmed = next_line.trim();
3663
3664 if is_paragraph_boundary(next_trimmed, next_line) {
3666 break;
3667 }
3668
3669 para_end = next_idx;
3670 }
3671
3672 let paragraph_lines = &lines[para_start..=para_end];
3674
3675 let mut start_byte = 0;
3677 for line in lines.iter().take(para_start) {
3678 start_byte += line.len() + 1; }
3680
3681 let mut end_byte = start_byte;
3682 for line in paragraph_lines {
3683 end_byte += line.len() + 1; }
3685
3686 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
3689
3690 if !includes_trailing_newline {
3692 end_byte -= 1;
3693 }
3694
3695 let paragraph_text = paragraph_lines.join("\n");
3697
3698 let reflowed = reflow_markdown(¶graph_text, options);
3700
3701 let reflowed_text = if includes_trailing_newline {
3705 if reflowed.ends_with('\n') {
3707 reflowed
3708 } else {
3709 format!("{reflowed}\n")
3710 }
3711 } else {
3712 if reflowed.ends_with('\n') {
3714 reflowed.trim_end_matches('\n').to_string()
3715 } else {
3716 reflowed
3717 }
3718 };
3719
3720 Some(ParagraphReflow {
3721 start_byte,
3722 end_byte,
3723 reflowed_text,
3724 })
3725}
3726
3727#[cfg(test)]
3728mod tests {
3729 use super::*;
3730
3731 #[test]
3732 fn cascade_split_line_handles_a_very_long_line_without_overflowing() {
3733 let words: Vec<String> = (0..4000).map(|i| format!("word{i}")).collect();
3739 let line = words.join(" ");
3740
3741 let out = cascade_split_line(&line, 80, &None, ReflowLengthMode::Chars, false, false, None);
3742
3743 assert!(out.len() > 1, "a very long line should split into many lines");
3744 for segment in &out {
3745 assert!(
3746 display_len(segment, ReflowLengthMode::Chars) <= 80 || !segment.contains(' '),
3747 "each wrapped line should fit the width (or be a single unbreakable token)"
3748 );
3749 }
3750 let rejoined = out.join(" ");
3752 let original_words: Vec<&str> = line.split(' ').collect();
3753 let result_words: Vec<&str> = rejoined.split_whitespace().collect();
3754 assert_eq!(original_words, result_words, "reflow must preserve all words in order");
3755 }
3756
3757 #[test]
3762 fn test_helper_function_text_ends_with_abbreviation() {
3763 let abbreviations = get_abbreviations(&None);
3765
3766 assert!(text_ends_with_abbreviation("Dr.", &abbreviations));
3768 assert!(text_ends_with_abbreviation("word Dr.", &abbreviations));
3769 assert!(text_ends_with_abbreviation("e.g.", &abbreviations));
3770 assert!(text_ends_with_abbreviation("i.e.", &abbreviations));
3771 assert!(text_ends_with_abbreviation("Mr.", &abbreviations));
3772 assert!(text_ends_with_abbreviation("Mrs.", &abbreviations));
3773 assert!(text_ends_with_abbreviation("Ms.", &abbreviations));
3774 assert!(text_ends_with_abbreviation("Prof.", &abbreviations));
3775
3776 assert!(!text_ends_with_abbreviation("etc.", &abbreviations));
3778 assert!(!text_ends_with_abbreviation("paradigms.", &abbreviations));
3779 assert!(!text_ends_with_abbreviation("programs.", &abbreviations));
3780 assert!(!text_ends_with_abbreviation("items.", &abbreviations));
3781 assert!(!text_ends_with_abbreviation("systems.", &abbreviations));
3782 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)); }
3788
3789 #[test]
3790 fn test_footnote_after_period_splits_sentence() {
3791 let text = "First sentence.[^1] Second sentence.";
3795 let sentences = split_into_sentences(text);
3796 assert_eq!(
3797 sentences,
3798 vec!["First sentence.[^1]".to_string(), "Second sentence.".to_string()],
3799 "footnote glued to the period should keep the boundary and stay attached to the first sentence"
3800 );
3801 }
3802
3803 #[test]
3804 fn test_multiple_consecutive_footnotes_after_period_splits_sentence() {
3805 let text = "Notes here.[^1][^2] Second sentence.";
3807 let sentences = split_into_sentences(text);
3808 assert_eq!(
3809 sentences,
3810 vec!["Notes here.[^1][^2]".to_string(), "Second sentence.".to_string()]
3811 );
3812 }
3813
3814 #[test]
3815 fn test_footnote_before_period_still_splits_sentence() {
3816 let text = "Annotation here[^1]. Second sentence.";
3820 let sentences = split_into_sentences(text);
3821 assert_eq!(
3822 sentences,
3823 vec!["Annotation here[^1].".to_string(), "Second sentence.".to_string()]
3824 );
3825 }
3826
3827 #[test]
3828 fn test_mid_sentence_footnote_does_not_split() {
3829 let text = "The system word[^1] more words. Next sentence.";
3832 let sentences = split_into_sentences(text);
3833 assert_eq!(
3834 sentences,
3835 vec![
3836 "The system word[^1] more words.".to_string(),
3837 "Next sentence.".to_string()
3838 ]
3839 );
3840 }
3841
3842 #[test]
3843 fn test_bare_numeric_bracket_after_period_does_not_split() {
3844 let text = "Citation here.[1] Second sentence.";
3847 let sentences = split_into_sentences(text);
3848 assert_eq!(
3849 sentences,
3850 vec![text.to_string()],
3851 "a bare numeric bracket must not be treated as a sentence boundary"
3852 );
3853 }
3854
3855 #[test]
3856 fn test_footnote_glued_to_following_word_does_not_split() {
3857 let text = "First sentence.[^1]Continued glued text.";
3860 let sentences = split_into_sentences(text);
3861 assert_eq!(sentences, vec![text.to_string()]);
3862 }
3863
3864 #[test]
3865 fn test_footnote_at_end_of_text_is_preserved() {
3866 let text = "Sentence.[^1]";
3869 let sentences = split_into_sentences(text);
3870 assert_eq!(sentences, vec![text.to_string()]);
3871 }
3872
3873 #[test]
3874 fn test_abbreviation_before_footnote_does_not_split() {
3875 let text = "See the notes, e.g.[^1] this one.";
3878 let sentences = split_into_sentences(text);
3879 assert_eq!(
3880 sentences,
3881 vec![text.to_string()],
3882 "e.g. is an abbreviation, not a sentence boundary"
3883 );
3884 }
3885
3886 #[test]
3887 fn test_is_unordered_list_marker() {
3888 assert!(is_unordered_list_marker("- item"));
3890 assert!(is_unordered_list_marker("* item"));
3891 assert!(is_unordered_list_marker("+ item"));
3892 assert!(is_unordered_list_marker("-")); assert!(is_unordered_list_marker("*"));
3894 assert!(is_unordered_list_marker("+"));
3895
3896 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")); }
3907
3908 #[test]
3909 fn test_is_block_boundary() {
3910 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"));
3932 assert!(!is_block_boundary("*emphasis*")); assert!(!is_block_boundary("[link](url)")); assert!(!is_block_boundary("some words here"));
3935 }
3936
3937 #[test]
3938 fn test_definition_list_boundary_in_single_line_paragraph() {
3939 let options = ReflowOptions {
3942 line_length: 80,
3943 ..Default::default()
3944 };
3945 let input = "Term\n: Definition of the term";
3946 let result = reflow_markdown(input, &options);
3947 assert!(
3949 result.contains(": Definition"),
3950 "Definition list item should not be merged into previous line. Got: {result:?}"
3951 );
3952 let lines: Vec<&str> = result.lines().collect();
3953 assert_eq!(lines.len(), 2, "Should remain two separate lines. Got: {lines:?}");
3954 assert_eq!(lines[0], "Term");
3955 assert_eq!(lines[1], ": Definition of the term");
3956 }
3957
3958 #[test]
3959 fn test_is_paragraph_boundary() {
3960 assert!(is_paragraph_boundary("# Heading", "# Heading"));
3962 assert!(is_paragraph_boundary("- item", "- item"));
3963 assert!(is_paragraph_boundary(":::", ":::"));
3964 assert!(is_paragraph_boundary(": definition", ": definition"));
3965
3966 assert!(is_paragraph_boundary("code", " code"));
3968 assert!(is_paragraph_boundary("code", "\tcode"));
3969
3970 assert!(is_paragraph_boundary("| a | b |", "| a | b |"));
3972 assert!(is_paragraph_boundary("a | b", "a | b")); assert!(!is_paragraph_boundary("regular text", "regular text"));
3976 assert!(!is_paragraph_boundary("text", " text")); }
3978
3979 #[test]
3980 fn test_div_marker_boundary_in_reflow_paragraph_at_line() {
3981 let content = "Some paragraph text here.\n\n::: {.callout-note}\nThis is a callout.\n:::\n";
3984 let result = reflow_paragraph_at_line(content, 3, 80);
3986 assert!(result.is_none(), "Div marker line should not be reflowed");
3987 }
3988
3989 #[test]
3990 fn starts_block_construct_detects_block_openers() {
3991 for case in ["- item", "-", "* item", "*", "+ item", "+", "-\titem"] {
3993 assert!(starts_block_construct(case), "bullet: {case:?}");
3994 }
3995 for case in ["1. item", "1) item", "9. x", "123456789. x", "1.", "42) x"] {
3997 assert!(starts_block_construct(case), "ordered: {case:?}");
3998 }
3999 for case in ["> quote", ">quote", ">"] {
4001 assert!(starts_block_construct(case), "blockquote: {case:?}");
4002 }
4003 for case in ["# heading", "###### h6", "#", "##"] {
4005 assert!(starts_block_construct(case), "heading: {case:?}");
4006 }
4007 for case in ["```", "```rust", "````", "~~~", "~~~text"] {
4009 assert!(starts_block_construct(case), "fence: {case:?}");
4010 }
4011 for case in ["---", "--", "===", "=", "***", "___", "_ _ _", "- - -"] {
4013 assert!(starts_block_construct(case), "setext/thematic: {case:?}");
4014 }
4015 for case in [
4018 "[^1]: text",
4019 "[^note]:",
4020 "[ref]: http://example.com",
4021 "[wat]: url follows",
4022 ] {
4023 assert!(starts_block_construct(case), "definition: {case:?}");
4024 }
4025 for case in ["<div>content", "</div>", "<p>text", "<table>", "<pre>code", "<h1>x"] {
4027 assert!(starts_block_construct(case), "html block: {case:?}");
4028 }
4029 }
4030
4031 #[test]
4032 fn starts_block_construct_allows_ordinary_prose() {
4033 for case in [
4034 "",
4035 "word",
4036 "-5 degrees",
4037 "--flag",
4038 "-item",
4039 "#hashtag",
4040 "####### seven hashes is not a heading",
4041 "1.5 million",
4042 "1234567890. ten digits is not a list marker",
4043 "1:30 pm",
4044 "*emphasis*",
4045 "**bold** text",
4046 "__bold__ text",
4047 "_emphasis_ text",
4048 "`code` span",
4049 "`` double backtick span ``",
4050 "~~strikethrough~~",
4051 "=x",
4052 "== ==",
4053 "(parenthetical)",
4054 "[link](url)",
4055 "[text][ref] more",
4056 "[bracketed] aside",
4057 "[a](b) [ref]: first bracket is a link, not a label",
4058 "[esc\\]: not a close] text",
4059 "<span>inline</span>",
4060 "<b>bold</b>",
4061 "<https://example.com> autolink",
4062 "<mailto:a@b.com>",
4063 "<notarealtag>",
4064 ] {
4065 assert!(!starts_block_construct(case), "prose: {case:?}");
4066 }
4067 }
4068
4069 #[test]
4070 fn merge_block_construct_continuations_merges_marker_led_lines() {
4071 let lines = vec![
4072 "First sentence?".to_string(),
4073 "- looks like a list item".to_string(),
4074 "Second sentence.".to_string(),
4075 ];
4076 assert_eq!(
4077 merge_block_construct_continuations(lines),
4078 vec![
4079 "First sentence? - looks like a list item".to_string(),
4080 "Second sentence.".to_string(),
4081 ]
4082 );
4083
4084 let lines = vec!["- real list content".to_string(), "continuation".to_string()];
4087 assert_eq!(
4088 merge_block_construct_continuations(lines.clone()),
4089 lines,
4090 "first line must never be merged"
4091 );
4092 }
4093
4094 #[test]
4095 fn wrap_never_starts_a_line_with_a_block_marker() {
4096 let options = ReflowOptions {
4097 line_length: 25,
4098 ..Default::default()
4099 };
4100 let lines = reflow_line(
4103 "Some words here and then - a dash clause that wraps around the limit.",
4104 &options,
4105 );
4106 assert_eq!(
4107 lines,
4108 vec![
4109 "Some words here and",
4110 "then - a dash clause that",
4111 "wraps around the limit."
4112 ]
4113 );
4114
4115 for input in [
4117 "Alpha beta gamma delta epsilon - dash clause here to wrap",
4118 "Alpha beta gamma delta epsilon > quote lookalike here to wrap",
4119 "Alpha beta gamma delta epsilon # heading lookalike here to wrap",
4120 "Alpha beta gamma delta epsilon 1. ordered lookalike here to wrap",
4121 "Alpha beta gamma delta epsilon * star clause here to wrap",
4122 "Alpha beta gamma delta epsilon + plus clause here to wrap",
4123 ] {
4124 for width in 10..40 {
4125 let options = ReflowOptions {
4126 line_length: width,
4127 ..Default::default()
4128 };
4129 for line in reflow_line(input, &options) {
4130 assert!(
4131 !starts_block_construct(&line),
4132 "width {width}: wrapped line opens a block construct: {line:?} (input {input:?})"
4133 );
4134 }
4135 }
4136 }
4137 }
4138
4139 #[test]
4140 fn sentence_per_line_keeps_block_markers_mid_line() {
4141 let options = ReflowOptions {
4142 line_length: 80,
4143 sentence_per_line: true,
4144 ..Default::default()
4145 };
4146 let lines = reflow_line(
4149 "Google Calendar (Can't we get rid of this dependency? - I don't really see the need)",
4150 &options,
4151 );
4152 assert_eq!(
4153 lines,
4154 vec!["Google Calendar (Can't we get rid of this dependency? - I don't really see the need)".to_string()]
4155 );
4156
4157 let lines = reflow_line("See section 4? # is the marker we use. Fine.", &options);
4159 assert_eq!(lines, vec!["See section 4? # is the marker we use.", "Fine."]);
4160
4161 let lines = reflow_line("Is this a problem? > I quote someone here.", &options);
4162 assert_eq!(lines, vec!["Is this a problem? > I quote someone here."]);
4163
4164 let lines = reflow_line("Another case! 1. Not a list. More text follows here.", &options);
4165 for line in &lines {
4166 assert!(
4167 !starts_block_construct(line),
4168 "sentence-per-line output opens a block construct: {line:?}"
4169 );
4170 }
4171 }
4172
4173 #[test]
4174 fn inline_math_directly_after_display_math_stays_atomic() {
4175 let options = ReflowOptions {
4183 line_length: 8,
4184 ..Default::default()
4185 };
4186 let lines = reflow_line("$$a$$$bb cc dd$ x", &options);
4187 assert_eq!(lines, vec!["$$a$$$bb cc dd$".to_string(), "x".to_string()]);
4188 }
4189
4190 #[test]
4191 fn test_code_span_parsing() {
4192 let elements = parse_markdown_elements_inner("`code`", false, false, None);
4194 assert_eq!(elements.len(), 1);
4195 assert!(matches!(&elements[0], Element::Code(s) if s == "`code`"));
4196
4197 let elements = parse_markdown_elements_inner("``code``", false, false, None);
4199 assert_eq!(elements.len(), 1);
4200 assert!(matches!(&elements[0], Element::Code(s) if s == "``code``"));
4201
4202 let elements = parse_markdown_elements_inner("``code`inside``", false, false, None);
4204 assert_eq!(elements.len(), 1);
4205 assert!(matches!(&elements[0], Element::Code(s) if s == "``code`inside``"));
4206
4207 let elements = parse_markdown_elements_inner("`` code ``", false, false, None);
4209 assert_eq!(elements.len(), 1);
4210 assert!(matches!(&elements[0], Element::Code(s) if s == "`` code ``"));
4211
4212 let elements = parse_markdown_elements_inner("`unclosed", false, false, None);
4214 assert_eq!(elements.len(), 1);
4215 assert!(matches!(&elements[0], Element::Text(s) if s == "`unclosed"));
4216
4217 let elements = parse_markdown_elements_inner("`unclosed [link](url)", false, false, None);
4219 assert_eq!(elements.len(), 2);
4221 assert!(matches!(&elements[0], Element::Text(s) if s == "`unclosed "));
4222 assert!(matches!(&elements[1], Element::Link(s) if s == "[link](url)"));
4223 }
4224
4225 #[test]
4226 fn test_reflow_performance_long_input() {
4227 let mut text = String::new();
4230 for i in 1..400 {
4231 let backticks = "`".repeat(i);
4232 text.push_str(&backticks);
4233 text.push(' ');
4234 }
4235
4236 let start = std::time::Instant::now();
4237 let elements = parse_markdown_elements_inner(&text, false, false, None);
4238 let duration = start.elapsed();
4239
4240 assert!(duration.as_millis() < 100, "Parsing took too long: {duration:?}");
4242 assert!(!elements.is_empty());
4243 }
4244}