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
43fn is_non_breaking_space(c: char) -> bool {
47 matches!(c, '\u{00A0}' | '\u{202F}' | '\u{2007}')
48}
49
50fn is_breakable_whitespace(c: char) -> bool {
55 c.is_whitespace() && !is_non_breaking_space(c)
56}
57
58fn split_breakable_words(text: &str) -> impl Iterator<Item = &str> {
60 text.split(is_breakable_whitespace).filter(|word| !word.is_empty())
61}
62
63#[derive(Clone)]
65pub struct ReflowOptions {
66 pub line_length: usize,
68 pub break_on_sentences: bool,
70 pub preserve_breaks: bool,
72 pub sentence_per_line: bool,
74 pub semantic_line_breaks: bool,
76 pub abbreviations: Option<Vec<String>>,
80 pub length_mode: ReflowLengthMode,
82 pub attr_lists: bool,
85 pub myst_roles: bool,
89 pub require_sentence_capital: bool,
94 pub max_list_continuation_indent: Option<usize>,
98 pub defined_references: Option<HashSet<String>>,
112 pub emphasis_spans: bool,
115}
116
117impl Default for ReflowOptions {
118 fn default() -> Self {
119 Self {
120 line_length: 80,
121 break_on_sentences: true,
122 preserve_breaks: false,
123 sentence_per_line: false,
124 semantic_line_breaks: false,
125 abbreviations: None,
126 length_mode: ReflowLengthMode::default(),
127 attr_lists: false,
128 myst_roles: false,
129 require_sentence_capital: true,
130 max_list_continuation_indent: None,
131 defined_references: None,
132 emphasis_spans: false,
133 }
134 }
135}
136
137pub fn normalize_reference_label(label: &str) -> String {
144 label.split_whitespace().collect::<Vec<_>>().join(" ").to_lowercase()
145}
146
147fn compute_inline_code_mask(text: &str) -> Vec<bool> {
150 let code_spans = extract_code_spans(text);
151 let chars: Vec<char> = text.chars().collect();
152 let mut mask = vec![false; chars.len()];
153 let mut span_it = code_spans.iter().peekable();
154 let mut byte_idx = 0;
155 for (char_idx, ch) in chars.iter().enumerate() {
159 let next_byte_idx = byte_idx + ch.len_utf8();
160 while let Some(span) = span_it.peek() {
161 if span.end <= byte_idx {
162 span_it.next();
163 } else {
164 break;
165 }
166 }
167 if let Some(span) = span_it.peek()
168 && byte_idx >= span.start
169 && byte_idx < span.end
170 {
171 mask[char_idx] = true;
172 }
173 byte_idx = next_byte_idx;
174 }
175 mask
176}
177
178fn footnote_refs_end(chars: &[char], start: usize) -> Option<usize> {
184 let mut pos = start;
185 let mut found = false;
186
187 loop {
188 if chars.get(pos) != Some(&'[') || chars.get(pos + 1) != Some(&'^') {
189 break;
190 }
191 let label_start = pos + 2;
192 let mut label_end = label_start;
193 while matches!(chars.get(label_end), Some(c) if c.is_ascii_alphanumeric() || *c == '_' || *c == '-') {
194 label_end += 1;
195 }
196 if label_end == label_start || chars.get(label_end) != Some(&']') {
197 break;
198 }
199 pos = label_end + 1;
200 found = true;
201 }
202
203 found.then_some(pos)
204}
205
206fn is_sentence_boundary(
210 text: &str,
211 chars: &[char],
212 pos: usize,
213 abbreviations: &HashSet<String>,
214 require_sentence_capital: bool,
215) -> bool {
216 if pos + 1 >= chars.len() {
217 return false;
218 }
219
220 let c = chars[pos];
221 let next_char = chars[pos + 1];
222
223 if is_cjk_sentence_ending(c) {
226 let mut after_punct_pos = pos + 1;
228 while after_punct_pos < chars.len()
229 && (chars[after_punct_pos] == '*' || chars[after_punct_pos] == '_' || chars[after_punct_pos] == '~')
230 {
231 after_punct_pos += 1;
232 }
233
234 while after_punct_pos < chars.len() && chars[after_punct_pos].is_whitespace() {
236 after_punct_pos += 1;
237 }
238
239 if after_punct_pos >= chars.len() {
241 return false;
242 }
243
244 while after_punct_pos < chars.len()
246 && (chars[after_punct_pos] == '*' || chars[after_punct_pos] == '_' || chars[after_punct_pos] == '~')
247 {
248 after_punct_pos += 1;
249 }
250
251 if after_punct_pos >= chars.len() {
252 return false;
253 }
254
255 return true;
258 }
259
260 if c != '.' && c != '!' && c != '?' {
262 return false;
263 }
264
265 let (_space_pos, after_space_pos) = if next_char == ' ' {
267 (pos + 1, pos + 2)
269 } else if is_closing_quote(next_char) && pos + 2 < chars.len() {
270 if chars[pos + 2] == ' ' {
272 (pos + 2, pos + 3)
274 } else if (chars[pos + 2] == '*' || chars[pos + 2] == '_') && pos + 3 < chars.len() && chars[pos + 3] == ' ' {
275 (pos + 3, pos + 4)
277 } else if (chars[pos + 2] == '*' || chars[pos + 2] == '_')
278 && pos + 4 < chars.len()
279 && chars[pos + 3] == chars[pos + 2]
280 && chars[pos + 4] == ' '
281 {
282 (pos + 4, pos + 5)
284 } else {
285 return false;
286 }
287 } else if (next_char == '*' || next_char == '_') && pos + 2 < chars.len() && chars[pos + 2] == ' ' {
288 (pos + 2, pos + 3)
290 } else if (next_char == '*' || next_char == '_')
291 && pos + 3 < chars.len()
292 && chars[pos + 2] == next_char
293 && chars[pos + 3] == ' '
294 {
295 (pos + 3, pos + 4)
297 } else if next_char == '~' && pos + 3 < chars.len() && chars[pos + 2] == '~' && chars[pos + 3] == ' ' {
298 (pos + 3, pos + 4)
300 } else if next_char == '[' {
301 match footnote_refs_end(chars, pos + 1) {
307 Some(end_pos) if chars.get(end_pos) == Some(&' ') => (end_pos, end_pos + 1),
308 _ => return false,
309 }
310 } else {
311 return false;
312 };
313
314 let mut next_char_pos = after_space_pos;
316 while next_char_pos < chars.len() && chars[next_char_pos].is_whitespace() {
317 next_char_pos += 1;
318 }
319
320 if next_char_pos >= chars.len() {
322 return false;
323 }
324
325 let mut first_letter_pos = next_char_pos;
327 while first_letter_pos < chars.len()
328 && (chars[first_letter_pos] == '*'
329 || chars[first_letter_pos] == '_'
330 || chars[first_letter_pos] == '~'
331 || is_opening_quote(chars[first_letter_pos]))
332 {
333 first_letter_pos += 1;
334 }
335
336 if first_letter_pos >= chars.len() {
338 return false;
339 }
340
341 let first_char = chars[first_letter_pos];
342
343 if c == '!' || c == '?' {
345 return true;
346 }
347
348 if pos > 0 {
352 let byte_offset: usize = chars[..=pos].iter().map(|ch| ch.len_utf8()).sum();
354 if text_ends_with_abbreviation(&text[..byte_offset], abbreviations) {
355 return false;
356 }
357
358 if chars[pos - 1].is_numeric() && first_char.is_ascii_digit() {
360 return false;
361 }
362
363 if chars[pos - 1].is_ascii_uppercase() && (pos == 1 || (pos >= 2 && chars[pos - 2].is_whitespace())) {
367 return false;
368 }
369 }
370
371 if require_sentence_capital && !first_char.is_uppercase() && !is_cjk_char(first_char) {
374 return false;
375 }
376
377 true
378}
379
380pub fn split_into_sentences(text: &str) -> Vec<String> {
382 split_into_sentences_custom(text, &None)
383}
384
385pub fn split_into_sentences_custom(text: &str, custom_abbreviations: &Option<Vec<String>>) -> Vec<String> {
387 let abbreviations = get_abbreviations(custom_abbreviations);
388 split_into_sentences_with_set(text, &abbreviations, true)
389}
390
391fn split_into_sentences_with_set(
394 text: &str,
395 abbreviations: &HashSet<String>,
396 require_sentence_capital: bool,
397) -> Vec<String> {
398 let in_code = compute_inline_code_mask(text);
400 let char_vec: Vec<char> = text.chars().collect();
403
404 let mut sentences = Vec::new();
405 let mut current_sentence = String::new();
406 let mut chars = text.chars().peekable();
407 let mut pos = 0;
408
409 while let Some(c) = chars.next() {
410 current_sentence.push(c);
411
412 if !in_code[pos] && is_sentence_boundary(text, &char_vec, pos, abbreviations, require_sentence_capital) {
413 if let Some(end_pos) = footnote_refs_end(&char_vec, pos + 1) {
418 while pos + 1 < end_pos {
419 current_sentence.push(chars.next().unwrap());
420 pos += 1;
421 }
422 }
423
424 while let Some(&next) = chars.peek() {
426 if next == '*' || next == '_' || next == '~' || is_closing_quote(next) {
427 current_sentence.push(chars.next().unwrap());
428 pos += 1;
429 } else {
430 break;
431 }
432 }
433
434 if chars.peek() == Some(&' ') {
436 chars.next();
437 pos += 1;
438 }
439
440 sentences.push(current_sentence.trim().to_string());
441 current_sentence.clear();
442 }
443
444 pos += 1;
445 }
446
447 if !current_sentence.trim().is_empty() {
449 sentences.push(current_sentence.trim().to_string());
450 }
451 sentences
452}
453
454fn is_horizontal_rule(line: &str) -> bool {
456 if line.len() < 3 {
457 return false;
458 }
459
460 let mut chars = line.chars();
463 let Some(first_char) = chars.next() else {
464 return false;
465 };
466 if first_char != '-' && first_char != '_' && first_char != '*' {
467 return false;
468 }
469
470 let mut non_space_count = 1usize; for c in chars {
472 if c == ' ' {
473 continue;
474 }
475 if c != first_char {
476 return false;
477 }
478 non_space_count += 1;
479 }
480 non_space_count >= 3
481}
482
483fn is_numbered_list_item(line: &str) -> bool {
485 let mut chars = line.chars();
486
487 if !chars.next().is_some_and(char::is_numeric) {
489 return false;
490 }
491
492 while let Some(c) = chars.next() {
494 if c == '.' {
495 return chars.next() == Some(' ');
498 }
499 if !c.is_numeric() {
500 return false;
501 }
502 }
503
504 false
505}
506
507fn is_unordered_list_marker(s: &str) -> bool {
509 matches!(s.as_bytes().first(), Some(b'-' | b'*' | b'+'))
510 && !is_horizontal_rule(s)
511 && (s.len() == 1 || s.as_bytes().get(1) == Some(&b' '))
512}
513
514fn is_block_boundary_core(trimmed: &str) -> bool {
517 trimmed.is_empty()
518 || trimmed.starts_with('#')
519 || trimmed.starts_with("```")
520 || trimmed.starts_with("~~~")
521 || trimmed.starts_with('>')
522 || (trimmed.starts_with('[') && trimmed.contains("]:"))
523 || is_horizontal_rule(trimmed)
524 || is_unordered_list_marker(trimmed)
525 || is_numbered_list_item(trimmed)
526 || is_definition_list_item(trimmed)
527 || trimmed.starts_with(":::")
528}
529
530fn is_block_boundary(trimmed: &str) -> bool {
533 is_block_boundary_core(trimmed) || trimmed.starts_with('|')
534}
535
536fn is_paragraph_boundary(trimmed: &str, line: &str) -> bool {
540 is_block_boundary_core(trimmed)
541 || calculate_indentation_width_default(line) >= 4
542 || crate::utils::table_utils::TableUtils::is_potential_table_row(line)
543}
544
545fn has_hard_break(line: &str) -> bool {
551 let line = line.strip_suffix('\r').unwrap_or(line);
552 line.ends_with(" ") || line.ends_with('\\')
553}
554
555fn ends_with_sentence_punct(text: &str) -> bool {
557 text.ends_with('.') || text.ends_with('!') || text.ends_with('?')
558}
559
560fn trim_preserving_hard_break(s: &str) -> String {
566 let s = s.strip_suffix('\r').unwrap_or(s);
568
569 if s.ends_with('\\') {
571 return s.to_string();
573 }
574
575 if s.ends_with(" ") {
577 let content_end = s.trim_end().len();
579 if content_end == 0 {
580 return String::new();
582 }
583 format!("{} ", &s[..content_end])
585 } else {
586 s.trim_end().to_string()
588 }
589}
590
591fn parse_elements(text: &str, options: &ReflowOptions) -> Vec<Element> {
593 parse_markdown_elements_inner(
594 text,
595 options.attr_lists,
596 options.myst_roles,
597 options.defined_references.as_ref(),
598 )
599}
600
601pub fn reflow_line(line: &str, options: &ReflowOptions) -> Vec<String> {
602 if options.sentence_per_line {
604 let elements = parse_elements(line, options);
605 return merge_block_construct_continuations(reflow_elements_sentence_per_line(
606 &elements,
607 &options.abbreviations,
608 options.require_sentence_capital,
609 ));
610 }
611
612 if options.semantic_line_breaks {
614 let elements = parse_elements(line, options);
615 return merge_block_construct_continuations(reflow_elements_semantic(&elements, options));
616 }
617
618 if options.line_length == 0 || display_len(line, options.length_mode) <= options.line_length {
621 return vec![line.to_string()];
622 }
623
624 let elements = parse_elements(line, options);
626
627 merge_block_construct_continuations(reflow_elements(&elements, options))
629}
630
631#[derive(Debug, Clone)]
633enum Element {
634 Text(String),
636 Link(String),
638 ReferenceLink(String),
640 EmptyReferenceLink(String),
642 ShortcutReference(String),
644 InlineImage(String),
646 ReferenceImage(String),
648 EmptyReferenceImage(String),
650 LinkedImage(String),
652 FootnoteReference(String),
654 Strikethrough {
656 content: String,
657 double: bool,
659 },
660 WikiLink(String),
662 InlineMath(String),
664 DisplayMath(String),
666 EmojiShortcode(String),
668 Autolink(String),
670 HtmlTag(String),
672 HtmlEntity(String),
674 HugoShortcode(String),
676 AttrList(String),
678 MystRole(String),
682 Code(String),
684 Bold {
686 content: String,
687 underscore: bool,
689 },
690 Italic {
692 content: String,
693 underscore: bool,
695 },
696}
697
698impl std::fmt::Display for Element {
699 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
700 match self {
701 Element::Text(s) => write!(f, "{s}"),
702 Element::Link(s) => write!(f, "{s}"),
703 Element::ReferenceLink(s) => write!(f, "{s}"),
704 Element::EmptyReferenceLink(s) => write!(f, "{s}"),
705 Element::ShortcutReference(s) => write!(f, "{s}"),
706 Element::InlineImage(s) => write!(f, "{s}"),
707 Element::ReferenceImage(s) => write!(f, "{s}"),
708 Element::EmptyReferenceImage(s) => write!(f, "{s}"),
709 Element::LinkedImage(s) => write!(f, "{s}"),
710 Element::FootnoteReference(s) => write!(f, "{s}"),
711 Element::Strikethrough { content, double } => {
712 let marker = if *double { "~~" } else { "~" };
713 write!(f, "{marker}{content}{marker}")
714 }
715 Element::WikiLink(s) => write!(f, "[[{s}]]"),
716 Element::InlineMath(s) => write!(f, "${s}$"),
717 Element::DisplayMath(s) => write!(f, "$${s}$$"),
718 Element::EmojiShortcode(s) => write!(f, ":{s}:"),
719 Element::Autolink(s) => write!(f, "{s}"),
720 Element::HtmlTag(s) => write!(f, "{s}"),
721 Element::HtmlEntity(s) => write!(f, "{s}"),
722 Element::HugoShortcode(s) => write!(f, "{s}"),
723 Element::AttrList(s) => write!(f, "{s}"),
724 Element::MystRole(s) => write!(f, "{s}"),
725 Element::Code(s) => write!(f, "{s}"),
726 Element::Bold { content, underscore } => {
727 if *underscore {
728 write!(f, "__{content}__")
729 } else {
730 write!(f, "**{content}**")
731 }
732 }
733 Element::Italic { content, underscore } => {
734 if *underscore {
735 write!(f, "_{content}_")
736 } else {
737 write!(f, "*{content}*")
738 }
739 }
740 }
741 }
742}
743
744#[derive(Debug, Clone)]
746struct EmphasisSpan {
747 start: usize,
749 end: usize,
751 content: String,
753 is_strong: bool,
755 is_strikethrough: bool,
757 uses_underscore: bool,
759 strikethrough_double: bool,
762}
763
764fn extract_emphasis_and_code_spans(text: &str) -> (Vec<EmphasisSpan>, Vec<CodeSpan>) {
774 let has_emphasis = text.contains(['*', '_', '~']);
776 let has_code = text.contains('`');
777 if !has_emphasis && !has_code {
778 return (Vec::new(), Vec::new());
779 }
780
781 let mut emphasis_spans = Vec::new();
782 let mut code_spans = Vec::new();
783
784 let mut options = Options::empty();
785 if has_emphasis {
786 options.insert(Options::ENABLE_STRIKETHROUGH);
787 }
788
789 let mut emphasis_stack: Vec<(usize, bool)> = Vec::new(); let mut strong_stack: Vec<(usize, bool)> = Vec::new();
792 let mut strikethrough_stack: Vec<usize> = Vec::new();
793
794 let parser = Parser::new_ext(text, options).into_offset_iter();
795
796 for (event, range) in parser {
797 match event {
798 Event::Code(_) => {
799 code_spans.push(CodeSpan {
800 start: range.start,
801 end: range.end,
802 });
803 }
804 Event::Start(Tag::Emphasis) => {
805 let uses_underscore = text.get(range.start..range.start + 1) == Some("_");
807 emphasis_stack.push((range.start, uses_underscore));
808 }
809 Event::End(TagEnd::Emphasis) => {
810 if let Some((start_byte, uses_underscore)) = emphasis_stack.pop() {
811 let content_start = start_byte + 1;
812 let content_end = range.end - 1;
813 if content_end > content_start
814 && let Some(content) = text.get(content_start..content_end)
815 {
816 emphasis_spans.push(EmphasisSpan {
817 start: start_byte,
818 end: range.end,
819 content: content.to_string(),
820 is_strong: false,
821 is_strikethrough: false,
822 uses_underscore,
823 strikethrough_double: false,
824 });
825 }
826 }
827 }
828 Event::Start(Tag::Strong) => {
829 let uses_underscore = text.get(range.start..range.start + 2) == Some("__");
830 strong_stack.push((range.start, uses_underscore));
831 }
832 Event::End(TagEnd::Strong) => {
833 if let Some((start_byte, uses_underscore)) = strong_stack.pop() {
834 let content_start = start_byte + 2;
835 let content_end = range.end - 2;
836 if content_end > content_start
837 && let Some(content) = text.get(content_start..content_end)
838 {
839 emphasis_spans.push(EmphasisSpan {
840 start: start_byte,
841 end: range.end,
842 content: content.to_string(),
843 is_strong: true,
844 is_strikethrough: false,
845 uses_underscore,
846 strikethrough_double: false,
847 });
848 }
849 }
850 }
851 Event::Start(Tag::Strikethrough) => {
852 strikethrough_stack.push(range.start);
853 }
854 Event::End(TagEnd::Strikethrough) => {
855 if let Some(start_byte) = strikethrough_stack.pop() {
856 let double = text.get(start_byte..start_byte + 2) == Some("~~");
857 let marker_len = if double { 2 } else { 1 };
858 let content_start = start_byte + marker_len;
859 let content_end = range.end - marker_len;
860 if content_end > content_start
861 && let Some(content) = text.get(content_start..content_end)
862 {
863 emphasis_spans.push(EmphasisSpan {
864 start: start_byte,
865 end: range.end,
866 content: content.to_string(),
867 is_strong: false,
868 is_strikethrough: true,
869 uses_underscore: false,
870 strikethrough_double: double,
871 });
872 }
873 }
874 }
875 _ => {}
876 }
877 }
878
879 emphasis_spans.sort_by_key(|s| s.start);
880 (emphasis_spans, code_spans)
881}
882
883#[derive(Debug, Clone)]
884struct CodeSpan {
885 start: usize,
886 end: usize,
887}
888
889fn extract_code_spans(text: &str) -> Vec<CodeSpan> {
890 if !text.contains('`') {
892 return Vec::new();
893 }
894
895 let mut spans = Vec::new();
896 let parser = Parser::new(text).into_offset_iter();
897 for (event, range) in parser {
898 if let Event::Code(_) = event {
899 spans.push(CodeSpan {
900 start: range.start,
901 end: range.end,
902 });
903 }
904 }
905 spans
906}
907
908#[derive(Debug, Clone)]
909struct LinkSpan {
910 start: usize,
911 end: usize,
912 link_type: Option<LinkType>,
913 is_image: bool,
914 is_footnote: bool,
915}
916
917fn extract_link_spans(text: &str, defined_references: Option<&HashSet<String>>) -> Vec<LinkSpan> {
918 if !text.contains('[') {
921 return Vec::new();
922 }
923
924 let mut spans = Vec::new();
925 let mut options = Options::empty();
926 options.insert(Options::ENABLE_FOOTNOTES);
927
928 let resolve = move |link: BrokenLink<'_>| -> Option<(CowStr<'_>, CowStr<'_>)> {
945 let atomic = match link.link_type {
950 LinkType::Shortcut | LinkType::ShortcutUnknown => match defined_references {
951 Some(defs) => defs.contains(&normalize_reference_label(link.reference.as_ref())),
952 None => true,
953 },
954 _ => true,
955 };
956 atomic.then_some((CowStr::Borrowed(""), CowStr::Borrowed("")))
957 };
958 let parser = Parser::new_with_broken_link_callback(text, options, Some(resolve)).into_offset_iter();
959 let mut stack = Vec::new();
960
961 for (event, range) in parser {
962 match event {
963 Event::Start(Tag::Link { link_type, .. }) => {
964 stack.push((range.start, Some(link_type), false));
965 }
966 Event::Start(Tag::Image { link_type, .. }) => {
967 stack.push((range.start, Some(link_type), true));
968 }
969 Event::End(TagEnd::Link) => {
970 if let Some((start_byte, link_type, is_image)) = stack.pop()
971 && stack.is_empty()
972 {
973 spans.push(LinkSpan {
974 start: start_byte,
975 end: range.end,
976 link_type,
977 is_image,
978 is_footnote: false,
979 });
980 }
981 }
982 Event::End(TagEnd::Image) => {
983 if let Some((start_byte, link_type, is_image)) = stack.pop()
984 && stack.is_empty()
985 {
986 spans.push(LinkSpan {
987 start: start_byte,
988 end: range.end,
989 link_type,
990 is_image,
991 is_footnote: false,
992 });
993 }
994 }
995 Event::FootnoteReference(_) if stack.is_empty() => {
996 spans.push(LinkSpan {
997 start: range.start,
998 end: range.end,
999 link_type: None,
1000 is_image: false,
1001 is_footnote: true,
1002 });
1003 }
1004 _ => {}
1005 }
1006 }
1007
1008 spans.sort_by_key(|s| s.start);
1009 spans
1010}
1011
1012fn myst_role_len_at(text: &str, absolute_pos: usize, code_spans: &[CodeSpan]) -> Option<usize> {
1020 let bytes = text.as_bytes();
1021 if bytes.first() != Some(&b'{') {
1022 return None;
1023 }
1024
1025 let mut j = 1;
1027 match bytes.get(j) {
1028 Some(&b) if b.is_ascii_alphabetic() || b == b'_' => {}
1029 _ => return None,
1030 }
1031 while let Some(&b) = bytes.get(j) {
1032 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b':' | b'.') {
1033 j += 1;
1034 } else {
1035 break;
1036 }
1037 }
1038 if bytes.get(j) != Some(&b'}') {
1039 return None;
1040 }
1041 j += 1; let code_span_start = absolute_pos + j;
1045 if let Ok(idx) = code_spans.binary_search_by_key(&code_span_start, |span| span.start) {
1046 let span = &code_spans[idx];
1047 let code_span_len = span.end - span.start;
1048 return Some(j + code_span_len);
1049 }
1050
1051 None
1052}
1053
1054fn inline_math_len_at_start(s: &str) -> Option<usize> {
1061 let bytes = s.as_bytes();
1062 if bytes.first() != Some(&b'$') || bytes.get(1) == Some(&b'$') {
1064 return None;
1065 }
1066 let close = 1 + s[1..].find('$')?;
1069 if bytes.get(close + 1) == Some(&b'$') {
1071 return None;
1072 }
1073 Some(close + 1)
1074}
1075
1076#[derive(Clone, Copy, Debug)]
1078struct PatternMatch {
1079 start: usize,
1080 end: usize,
1081}
1082
1083#[derive(Clone, Copy)]
1097enum PatternCache {
1098 Unsearched,
1099 NotFound,
1100 Found(PatternMatch),
1101}
1102
1103impl PatternCache {
1104 fn earliest_in(
1108 &mut self,
1109 remaining: &str,
1110 cursor: usize,
1111 find: impl FnOnce(&str) -> Option<(usize, usize)>,
1112 ) -> Option<(usize, usize)> {
1113 let stale = match self {
1114 PatternCache::Found(pm) => pm.start < cursor,
1115 PatternCache::NotFound => false,
1116 PatternCache::Unsearched => true,
1117 };
1118 if stale {
1119 *self = match find(remaining) {
1120 Some((start, end)) => PatternCache::Found(PatternMatch {
1121 start: cursor + start,
1122 end: cursor + end,
1123 }),
1124 None => PatternCache::NotFound,
1125 };
1126 }
1127 match self {
1128 PatternCache::Found(pm) => Some((pm.start - cursor, pm.end - cursor)),
1129 _ => None,
1130 }
1131 }
1132}
1133
1134fn parse_markdown_elements_inner(
1145 text: &str,
1146 attr_lists: bool,
1147 myst_roles: bool,
1148 defined_references: Option<&HashSet<String>>,
1149) -> Vec<Element> {
1150 let mut elements = Vec::new();
1151 let mut remaining = text;
1152
1153 let (emphasis_spans, code_spans) = extract_emphasis_and_code_spans(text);
1158 let link_spans = extract_link_spans(text, defined_references);
1159
1160 let mut cached_wiki_link = PatternCache::Unsearched;
1163 let mut cached_display_math = PatternCache::Unsearched;
1164 let mut cached_inline_math = PatternCache::Unsearched;
1165 let mut cached_emoji = PatternCache::Unsearched;
1166 let mut cached_html_entity = PatternCache::Unsearched;
1167 let mut cached_hugo_shortcode = PatternCache::Unsearched;
1168 let mut cached_html_tag = PatternCache::Unsearched;
1169 let mut cached_next_curly = PatternCache::Unsearched;
1170
1171 let mut link_span_idx = 0usize;
1175 let mut emphasis_span_idx = 0usize;
1176 let mut code_span_idx = 0usize;
1177
1178 while !remaining.is_empty() {
1179 let current_offset = text.len() - remaining.len();
1181 let mut earliest_match: Option<(usize, usize, &str)> = None;
1184
1185 while link_span_idx < link_spans.len() && link_spans[link_span_idx].start < current_offset {
1187 link_span_idx += 1;
1188 }
1189 let next_link: Option<&LinkSpan> = link_spans.get(link_span_idx);
1190
1191 if let Some(span) = next_link {
1192 let pos_in_remaining = span.start - current_offset;
1193 if earliest_match
1194 .as_ref()
1195 .is_none_or(|(start, _, _)| pos_in_remaining < *start)
1196 {
1197 let match_end = span.end - current_offset;
1198 earliest_match = Some((pos_in_remaining, match_end, "link_span"));
1199 }
1200 }
1201
1202 if let Some((start, end)) = cached_wiki_link.earliest_in(remaining, current_offset, |suffix| {
1204 WIKI_LINK_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1205 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1206 {
1207 earliest_match = Some((start, end, "wiki_link"));
1208 }
1209
1210 if let Some((start, end)) = cached_display_math.earliest_in(remaining, current_offset, |suffix| {
1212 DISPLAY_MATH_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1213 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1214 {
1215 earliest_match = Some((start, end, "display_math"));
1216 }
1217
1218 let inline_math_probe = if current_offset > 0 && text.as_bytes()[current_offset - 1] == b'$' {
1232 inline_math_len_at_start(remaining).map(|len| (0, len))
1233 } else {
1234 None
1235 };
1236 if let Some((start, end)) = inline_math_probe.or_else(|| {
1237 cached_inline_math.earliest_in(remaining, current_offset, |suffix| {
1238 INLINE_MATH_REGEX
1239 .find(suffix)
1240 .ok()
1241 .flatten()
1242 .map(|m| (m.start(), m.end()))
1243 })
1244 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1245 {
1246 earliest_match = Some((start, end, "inline_math"));
1247 }
1248
1249 if let Some((start, end)) = cached_emoji.earliest_in(remaining, current_offset, |suffix| {
1251 EMOJI_SHORTCODE_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1252 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1253 {
1254 earliest_match = Some((start, end, "emoji"));
1255 }
1256
1257 if let Some((start, end)) = cached_html_entity.earliest_in(remaining, current_offset, |suffix| {
1259 HTML_ENTITY_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1260 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1261 {
1262 earliest_match = Some((start, end, "html_entity"));
1263 }
1264
1265 if let Some((start, end)) = cached_hugo_shortcode.earliest_in(remaining, current_offset, |suffix| {
1268 HUGO_SHORTCODE_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1269 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1270 {
1271 earliest_match = Some((start, end, "hugo_shortcode"));
1272 }
1273
1274 if let Some((start, end)) = cached_html_tag.earliest_in(remaining, current_offset, |suffix| {
1281 let mut from = 0;
1282 while let Some(m) = HTML_TAG_PATTERN.find(&suffix[from..]) {
1283 let (tag_start, tag_end) = (from + m.start(), from + m.end());
1284 let tag = &suffix[tag_start..tag_end];
1285 let is_url_autolink = tag.starts_with("<http://")
1287 || tag.starts_with("<https://")
1288 || tag.starts_with("<mailto:")
1289 || tag.starts_with("<ftp://")
1290 || tag.starts_with("<ftps://");
1291 let is_email_autolink = {
1294 let content = tag.trim_start_matches('<').trim_end_matches('>');
1295 EMAIL_PATTERN.is_match(content)
1296 };
1297 if is_url_autolink || is_email_autolink {
1298 from = tag_end;
1299 } else {
1300 return Some((tag_start, tag_end));
1301 }
1302 }
1303 None
1304 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1305 {
1306 earliest_match = Some((start, end, "html_tag"));
1307 }
1308
1309 let mut next_special = remaining.len();
1311 let mut special_type = "";
1312 let mut pulldown_emphasis: Option<&EmphasisSpan> = None;
1313 let mut attr_list_len: usize = 0;
1314 let mut myst_role_len: usize = 0;
1315
1316 while code_span_idx < code_spans.len() && code_spans[code_span_idx].start < current_offset {
1318 code_span_idx += 1;
1319 }
1320 let next_code_span: Option<&CodeSpan> = code_spans.get(code_span_idx);
1321 if let Some(span) = next_code_span {
1322 let pos_in_remaining = span.start - current_offset;
1323 if pos_in_remaining < next_special {
1324 next_special = pos_in_remaining;
1325 special_type = "pulldown_code";
1326 }
1327 }
1328
1329 let next_curly_pos = cached_next_curly
1332 .earliest_in(remaining, current_offset, |suffix| {
1333 suffix.find('{').map(|pos| (pos, pos + 1))
1334 })
1335 .map(|(start, _)| start);
1336
1337 if myst_roles
1342 && let Some(pos) = next_curly_pos
1343 && pos < next_special
1344 && let Some(role_len) = myst_role_len_at(&remaining[pos..], current_offset + pos, &code_spans)
1345 {
1346 next_special = pos;
1347 special_type = "myst_role";
1348 myst_role_len = role_len;
1349 }
1350
1351 if attr_lists
1353 && let Some(pos) = next_curly_pos
1354 && pos < next_special
1355 && let Some(m) = ATTR_LIST_PATTERN.find(&remaining[pos..])
1356 && m.start() == 0
1357 {
1358 next_special = pos;
1359 special_type = "attr_list";
1360 attr_list_len = m.end();
1361 }
1362
1363 while emphasis_span_idx < emphasis_spans.len() && emphasis_spans[emphasis_span_idx].start < current_offset {
1365 emphasis_span_idx += 1;
1366 }
1367 if let Some(span) = emphasis_spans.get(emphasis_span_idx) {
1368 let pos_in_remaining = span.start - current_offset;
1369 if pos_in_remaining < next_special {
1370 next_special = pos_in_remaining;
1371 special_type = "pulldown_emphasis";
1372 pulldown_emphasis = Some(span);
1373 }
1374 }
1375
1376 let should_process_markdown_link = if let Some((pos, _, _)) = earliest_match {
1378 pos < next_special
1379 } else {
1380 false
1381 };
1382
1383 if should_process_markdown_link {
1384 let (pos, match_end, pattern_type) = earliest_match.unwrap();
1385
1386 if pos > 0 {
1388 elements.push(Element::Text(remaining[..pos].to_string()));
1389 }
1390
1391 match pattern_type {
1393 "link_span" => {
1394 let span = next_link.unwrap();
1395 let raw_text = remaining[pos..match_end].to_string();
1396 if span.is_footnote {
1397 elements.push(Element::FootnoteReference(raw_text));
1398 } else if span.is_image {
1399 match span.link_type {
1400 Some(LinkType::Inline) => elements.push(Element::InlineImage(raw_text)),
1401 Some(LinkType::Reference)
1404 | Some(LinkType::ReferenceUnknown)
1405 | Some(LinkType::Shortcut)
1406 | Some(LinkType::ShortcutUnknown) => elements.push(Element::ReferenceImage(raw_text)),
1407 Some(LinkType::Collapsed) | Some(LinkType::CollapsedUnknown) => {
1408 elements.push(Element::EmptyReferenceImage(raw_text))
1409 }
1410 _ => elements.push(Element::InlineImage(raw_text)),
1411 }
1412 } else {
1413 match span.link_type {
1414 Some(LinkType::Inline) => {
1415 if raw_text.starts_with('[') && raw_text.contains("![") {
1416 elements.push(Element::LinkedImage(raw_text));
1417 } else {
1418 elements.push(Element::Link(raw_text));
1419 }
1420 }
1421 Some(LinkType::Reference) | Some(LinkType::ReferenceUnknown) => {
1424 elements.push(Element::ReferenceLink(raw_text))
1425 }
1426 Some(LinkType::Collapsed) | Some(LinkType::CollapsedUnknown) => {
1427 elements.push(Element::EmptyReferenceLink(raw_text))
1428 }
1429 Some(LinkType::Shortcut) | Some(LinkType::ShortcutUnknown) => {
1430 elements.push(Element::ShortcutReference(raw_text))
1431 }
1432 Some(LinkType::Autolink) | Some(LinkType::Email) => {
1433 elements.push(Element::Autolink(raw_text))
1434 }
1435 _ => elements.push(Element::Link(raw_text)),
1436 }
1437 }
1438 remaining = &remaining[match_end..];
1439 }
1440 "wiki_link" => {
1441 if let Some(caps) = WIKI_LINK_REGEX.captures(remaining) {
1442 let content = caps.get(1).map_or("", |m| m.as_str());
1443 elements.push(Element::WikiLink(content.to_string()));
1444 remaining = &remaining[match_end..];
1445 } else {
1446 elements.push(Element::Text("[[".to_string()));
1447 remaining = &remaining[2..];
1448 }
1449 }
1450 "display_math" => {
1451 if let Some(caps) = DISPLAY_MATH_REGEX.captures(remaining) {
1452 let math = caps.get(1).map_or("", |m| m.as_str());
1453 elements.push(Element::DisplayMath(math.to_string()));
1454 remaining = &remaining[match_end..];
1455 } else {
1456 elements.push(Element::Text("$$".to_string()));
1457 remaining = &remaining[2..];
1458 }
1459 }
1460 "inline_math" => {
1461 if let Ok(Some(caps)) = INLINE_MATH_REGEX.captures(remaining) {
1462 let math = caps.get(1).map_or("", |m| m.as_str());
1463 elements.push(Element::InlineMath(math.to_string()));
1464 remaining = &remaining[match_end..];
1465 } else {
1466 elements.push(Element::Text("$".to_string()));
1467 remaining = &remaining[1..];
1468 }
1469 }
1470 "emoji" => {
1471 if let Some(caps) = EMOJI_SHORTCODE_REGEX.captures(remaining) {
1472 let emoji = caps.get(1).map_or("", |m| m.as_str());
1473 elements.push(Element::EmojiShortcode(emoji.to_string()));
1474 remaining = &remaining[match_end..];
1475 } else {
1476 elements.push(Element::Text(":".to_string()));
1477 remaining = &remaining[1..];
1478 }
1479 }
1480 "html_entity" => {
1481 elements.push(Element::HtmlEntity(remaining[pos..match_end].to_string()));
1483 remaining = &remaining[match_end..];
1484 }
1485 "hugo_shortcode" => {
1486 elements.push(Element::HugoShortcode(remaining[pos..match_end].to_string()));
1488 remaining = &remaining[match_end..];
1489 }
1490 "html_tag" => {
1491 elements.push(Element::HtmlTag(remaining[pos..match_end].to_string()));
1493 remaining = &remaining[match_end..];
1494 }
1495 _ => unreachable!("unknown pattern type: {}", pattern_type),
1496 }
1497 } else {
1498 if next_special > 0 && next_special < remaining.len() {
1502 elements.push(Element::Text(remaining[..next_special].to_string()));
1503 remaining = &remaining[next_special..];
1504 }
1505
1506 match special_type {
1508 "pulldown_code" => {
1509 let span = next_code_span.unwrap();
1510 let span_len = span.end - span.start;
1511 let code = &remaining[..span_len];
1512 elements.push(Element::Code(code.to_string()));
1513 remaining = &remaining[span_len..];
1514 }
1515 "attr_list" => {
1516 elements.push(Element::AttrList(remaining[..attr_list_len].to_string()));
1517 remaining = &remaining[attr_list_len..];
1518 }
1519 "myst_role" => {
1520 elements.push(Element::MystRole(remaining[..myst_role_len].to_string()));
1521 remaining = &remaining[myst_role_len..];
1522 }
1523 "pulldown_emphasis" => {
1524 let span = pulldown_emphasis.expect("pulldown_emphasis must be set");
1526 let span_len = span.end - span.start;
1527 if span.is_strikethrough {
1528 elements.push(Element::Strikethrough {
1529 content: span.content.clone(),
1530 double: span.strikethrough_double,
1531 });
1532 } else if span.is_strong {
1533 elements.push(Element::Bold {
1534 content: span.content.clone(),
1535 underscore: span.uses_underscore,
1536 });
1537 } else {
1538 elements.push(Element::Italic {
1539 content: span.content.clone(),
1540 underscore: span.uses_underscore,
1541 });
1542 }
1543 remaining = &remaining[span_len..];
1544 }
1545 _ => {
1546 elements.push(Element::Text(remaining.to_string()));
1548 break;
1549 }
1550 }
1551 }
1552 }
1553
1554 let mut merged_elements = Vec::new();
1556 for el in elements {
1557 match el {
1558 Element::Text(s) => {
1559 if let Some(Element::Text(last_s)) = merged_elements.last_mut() {
1560 last_s.push_str(&s);
1561 } else {
1562 merged_elements.push(Element::Text(s));
1563 }
1564 }
1565 other => merged_elements.push(other),
1566 }
1567 }
1568 merged_elements
1569}
1570
1571fn should_insert_space_before_join(current: &str) -> bool {
1572 !current.is_empty()
1573 && !current.ends_with(' ')
1574 && !current.ends_with('(')
1575 && !current.ends_with('[')
1576 && !current.ends_with('-')
1577}
1578
1579fn is_setext_or_thematic(text: &str) -> bool {
1585 let mut marker = '\0';
1586 let mut count = 0usize;
1587 let mut has_space = false;
1588 for c in text.chars() {
1589 match c {
1590 ' ' | '\t' => has_space = true,
1591 '-' | '=' | '*' | '_' => {
1592 if marker == '\0' {
1593 marker = c;
1594 } else if c != marker {
1595 return false;
1596 }
1597 count += 1;
1598 }
1599 _ => return false,
1600 }
1601 }
1602 match marker {
1603 '=' => !has_space,
1604 '-' => !has_space || count >= 3,
1605 '*' | '_' => count >= 3,
1606 _ => false,
1607 }
1608}
1609
1610fn starts_block_construct(text: &str) -> bool {
1622 let text = text.trim_start();
1623 let bytes = text.as_bytes();
1624 let Some(&first) = bytes.first() else {
1625 return false;
1626 };
1627 let marker_then_boundary = |len: usize| bytes.len() == len || bytes[len] == b' ' || bytes[len] == b'\t';
1628 match first {
1629 b'>' => true,
1631 b'-' | b'*' | b'+' => marker_then_boundary(1) || is_setext_or_thematic(text),
1632 b'_' | b'=' => is_setext_or_thematic(text),
1633 b'#' => {
1634 let hashes = bytes.iter().take_while(|&&b| b == b'#').count();
1635 hashes <= 6 && marker_then_boundary(hashes)
1636 }
1637 b'`' => bytes.iter().take_while(|&&b| b == b'`').count() >= 3,
1638 b'~' => bytes.iter().take_while(|&&b| b == b'~').count() >= 3,
1639 b'0'..=b'9' => {
1640 let digits = bytes.iter().take_while(|b| b.is_ascii_digit()).count();
1641 digits <= 9
1642 && bytes.len() > digits
1643 && (bytes[digits] == b'.' || bytes[digits] == b')')
1644 && marker_then_boundary(digits + 1)
1645 }
1646 b'[' => {
1654 let mut escaped = false;
1655 let mut label_close = None;
1656 for (i, &b) in bytes.iter().enumerate().skip(1) {
1657 if escaped {
1658 escaped = false;
1659 } else if b == b'\\' {
1660 escaped = true;
1661 } else if b == b']' {
1662 label_close = Some(i);
1663 break;
1664 }
1665 }
1666 label_close.is_some_and(|i| bytes.get(i + 1) == Some(&b':'))
1667 }
1668 b'<' => crate::utils::html_block::parse_html_block_start(text).is_some(),
1671 _ => false,
1672 }
1673}
1674
1675fn merge_block_construct_continuations(lines: Vec<String>) -> Vec<String> {
1684 let mut merged: Vec<String> = Vec::with_capacity(lines.len());
1685 for line in lines {
1686 match merged.last_mut() {
1687 Some(prev) if starts_block_construct(&line) => {
1688 prev.push(' ');
1689 prev.push_str(line.trim_start());
1690 }
1691 _ => merged.push(line),
1692 }
1693 }
1694 merged
1695}
1696
1697fn reflow_elements_sentence_per_line(
1699 elements: &[Element],
1700 custom_abbreviations: &Option<Vec<String>>,
1701 require_sentence_capital: bool,
1702) -> Vec<String> {
1703 let abbreviations = get_abbreviations(custom_abbreviations);
1704 let mut lines = Vec::new();
1705 let mut current_line = String::new();
1706
1707 for (idx, element) in elements.iter().enumerate() {
1708 let element_str = format!("{element}");
1709
1710 if let Element::Text(text) = element {
1712 let combined = format!("{current_line}{text}");
1714 let sentences = split_into_sentences_with_set(&combined, &abbreviations, require_sentence_capital);
1716
1717 if sentences.len() > 1 {
1718 for (i, sentence) in sentences.iter().enumerate() {
1720 if i == 0 {
1721 let trimmed = sentence.trim();
1724
1725 if text_ends_with_abbreviation(trimmed, &abbreviations) {
1726 current_line.clone_from(sentence);
1728 } else {
1729 lines.push(sentence.clone());
1731 current_line.clear();
1732 }
1733 } else if i == sentences.len() - 1 {
1734 let trimmed = sentence.trim();
1736 let ends_with_sentence_punct = ends_with_sentence_punct(trimmed);
1737
1738 if ends_with_sentence_punct && !text_ends_with_abbreviation(trimmed, &abbreviations) {
1739 lines.push(sentence.clone());
1741 current_line.clear();
1742 } else {
1743 current_line.clone_from(sentence);
1745 }
1746 } else {
1747 lines.push(sentence.clone());
1749 }
1750 }
1751 } else {
1752 let trimmed = combined.trim();
1754
1755 if trimmed.is_empty() {
1759 continue;
1760 }
1761
1762 let ends_with_sentence_punct = ends_with_sentence_punct(trimmed);
1763
1764 if ends_with_sentence_punct && !text_ends_with_abbreviation(trimmed, &abbreviations) {
1765 lines.push(combined.trim_matches(is_breakable_whitespace).to_string());
1768 current_line.clear();
1769 } else {
1770 current_line = combined;
1772 }
1773 }
1774 } else if let Element::Italic { content, underscore } = element {
1775 let marker = if *underscore { "_" } else { "*" };
1777 handle_emphasis_sentence_split(
1778 content,
1779 marker,
1780 &abbreviations,
1781 require_sentence_capital,
1782 &mut current_line,
1783 &mut lines,
1784 );
1785 } else if let Element::Bold { content, underscore } = element {
1786 let marker = if *underscore { "__" } else { "**" };
1788 handle_emphasis_sentence_split(
1789 content,
1790 marker,
1791 &abbreviations,
1792 require_sentence_capital,
1793 &mut current_line,
1794 &mut lines,
1795 );
1796 } else if let Element::Strikethrough { content, double } = element {
1797 handle_emphasis_sentence_split(
1799 content,
1800 if *double { "~~" } else { "~" },
1801 &abbreviations,
1802 require_sentence_capital,
1803 &mut current_line,
1804 &mut lines,
1805 );
1806 } else {
1807 let is_adjacent = if idx > 0 {
1812 match &elements[idx - 1] {
1813 Element::Text(t) => !t.is_empty() && !t.ends_with(is_breakable_whitespace),
1814 _ => true,
1815 }
1816 } else {
1817 false
1818 };
1819
1820 if !is_adjacent && should_insert_space_before_join(¤t_line) {
1822 current_line.push(' ');
1823 }
1824 current_line.push_str(&element_str);
1825 }
1826 }
1827
1828 if !current_line.is_empty() {
1830 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
1831 }
1832 lines
1833}
1834
1835fn handle_emphasis_sentence_split(
1837 content: &str,
1838 marker: &str,
1839 abbreviations: &HashSet<String>,
1840 require_sentence_capital: bool,
1841 current_line: &mut String,
1842 lines: &mut Vec<String>,
1843) {
1844 let sentences = split_into_sentences_with_set(content, abbreviations, require_sentence_capital);
1846
1847 if sentences.len() <= 1 {
1848 if should_insert_space_before_join(current_line) {
1850 current_line.push(' ');
1851 }
1852 current_line.push_str(marker);
1853 current_line.push_str(content);
1854 current_line.push_str(marker);
1855
1856 let trimmed = content.trim();
1858 let ends_with_punct = ends_with_sentence_punct(trimmed);
1859 if ends_with_punct && !text_ends_with_abbreviation(trimmed, abbreviations) {
1860 lines.push(current_line.clone());
1861 current_line.clear();
1862 }
1863 } else {
1864 for (i, sentence) in sentences.iter().enumerate() {
1866 let trimmed = sentence.trim();
1867 if trimmed.is_empty() {
1868 continue;
1869 }
1870
1871 if i == 0 {
1872 if should_insert_space_before_join(current_line) {
1874 current_line.push(' ');
1875 }
1876 current_line.push_str(marker);
1877 current_line.push_str(trimmed);
1878 current_line.push_str(marker);
1879
1880 let ends_with_punct = ends_with_sentence_punct(trimmed);
1882 if ends_with_punct && !text_ends_with_abbreviation(trimmed, abbreviations) {
1883 lines.push(current_line.clone());
1884 current_line.clear();
1885 }
1886 } else if i == sentences.len() - 1 {
1887 let ends_with_punct = ends_with_sentence_punct(trimmed);
1889
1890 let mut line = String::new();
1891 line.push_str(marker);
1892 line.push_str(trimmed);
1893 line.push_str(marker);
1894
1895 if ends_with_punct && !text_ends_with_abbreviation(trimmed, abbreviations) {
1896 lines.push(line);
1897 } else {
1898 *current_line = line;
1900 }
1901 } else {
1902 let mut line = String::new();
1904 line.push_str(marker);
1905 line.push_str(trimmed);
1906 line.push_str(marker);
1907 lines.push(line);
1908 }
1909 }
1910 }
1911}
1912
1913const BREAK_WORDS: &[&str] = &[
1917 "and",
1918 "or",
1919 "but",
1920 "nor",
1921 "yet",
1922 "so",
1923 "for",
1924 "which",
1925 "that",
1926 "because",
1927 "when",
1928 "if",
1929 "while",
1930 "where",
1931 "although",
1932 "though",
1933 "unless",
1934 "since",
1935 "after",
1936 "before",
1937 "until",
1938 "as",
1939 "once",
1940 "whether",
1941 "however",
1942 "therefore",
1943 "moreover",
1944 "furthermore",
1945 "nevertheless",
1946 "whereas",
1947];
1948
1949fn is_clause_punctuation(c: char) -> bool {
1951 matches!(c, ',' | ';' | ':' | '\u{2014}') }
1953
1954fn clause_break_allowed_after(chars: &[char], i: usize) -> bool {
1962 if chars[i] == '\u{2014}' {
1963 return true;
1964 }
1965 match chars.get(i + 1) {
1966 None => true,
1967 Some(next) => next.is_whitespace(),
1968 }
1969}
1970
1971fn paren_group_end<'a>(slice: &'a str, element_spans: &[(usize, usize)], offset: usize) -> Option<(usize, &'a str)> {
1985 debug_assert!(slice.starts_with('('));
1986 let mut depth: i32 = 0;
1987 for (local_byte, c) in slice.char_indices() {
1988 let global_byte = offset + local_byte;
1989 if depth > 0 && is_inside_element(global_byte, element_spans) {
1994 continue;
1995 }
1996 match c {
1997 '(' => depth += 1,
1998 ')' => {
1999 depth -= 1;
2000 if depth == 0 {
2001 let end = local_byte + 1;
2002 let inner = &slice[1..local_byte];
2003 return Some((end, inner));
2004 }
2005 }
2006 _ => {}
2007 }
2008 }
2009 None
2010}
2011
2012fn split_at_parenthetical(
2029 text: &str,
2030 line_length: usize,
2031 element_spans: &[(usize, usize)],
2032 length_mode: ReflowLengthMode,
2033) -> Option<(String, String)> {
2034 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
2035
2036 if text.starts_with('(')
2038 && let Some((end_local, inner)) = paren_group_end(text, element_spans, 0)
2039 && inner.contains(' ')
2040 {
2041 let tail = &text[end_local..];
2045 let attached_len = tail
2046 .char_indices()
2047 .take_while(|(_, c)| is_closing_quote(*c) || is_clause_punctuation(*c))
2048 .last()
2049 .map_or(0, |(idx, c)| idx + c.len_utf8());
2050 let first_end = end_local + attached_len;
2051 let rest_start = first_end;
2052 let first = &text[..first_end];
2053 let first_len = display_len(first, length_mode);
2054 if first_len <= line_length {
2057 let rest = text[rest_start..].trim_start();
2058 if !rest.is_empty() {
2059 return Some((first.to_string(), rest.to_string()));
2060 }
2061 }
2062 }
2063
2064 let mut best_open_byte: Option<usize> = None;
2066 let mut pos = 0usize;
2067 while pos < text.len() {
2068 if text.as_bytes()[pos] != b'(' {
2070 let c = text[pos..].chars().next().unwrap();
2071 pos += c.len_utf8();
2072 continue;
2073 }
2074 if is_inside_element(pos, element_spans) {
2076 pos += 1;
2077 continue;
2078 }
2079 if let Some((end_local, inner)) = paren_group_end(&text[pos..], element_spans, pos) {
2080 let first = text[..pos].trim_end();
2081 let first_len = display_len(first, length_mode);
2082 if !first.is_empty()
2083 && first_len >= min_first_len
2084 && first_len <= line_length
2085 && inner.contains(' ')
2086 && best_open_byte.is_none_or(|prev| pos > prev)
2087 {
2088 best_open_byte = Some(pos);
2089 }
2090 pos += end_local;
2091 } else {
2092 pos += 1;
2093 }
2094 }
2095
2096 let open_byte = best_open_byte?;
2097 let first = text[..open_byte].trim_end().to_string();
2098 let rest = text[open_byte..].to_string();
2099 if first.is_empty() || rest.trim().is_empty() {
2100 return None;
2101 }
2102 Some((first, rest))
2103}
2104
2105fn compute_element_spans(elements: &[Element]) -> Vec<(usize, usize)> {
2109 let mut spans = Vec::new();
2110 let mut offset = 0;
2111 for element in elements {
2112 let rendered = format!("{element}");
2113 let len = rendered.len();
2114 if !matches!(element, Element::Text(_)) {
2115 spans.push((offset, offset + len));
2116 }
2117 offset += len;
2118 }
2119 spans
2120}
2121
2122fn is_inside_element(pos: usize, spans: &[(usize, usize)]) -> bool {
2124 spans.iter().any(|(start, end)| pos > *start && pos < *end)
2125}
2126
2127const MIN_SPLIT_RATIO: f64 = 0.3;
2130
2131fn split_at_clause_punctuation(
2135 text: &str,
2136 line_length: usize,
2137 element_spans: &[(usize, usize)],
2138 length_mode: ReflowLengthMode,
2139) -> Option<(String, String)> {
2140 let chars: Vec<char> = text.chars().collect();
2141 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
2142
2143 let mut width_acc = 0;
2145 let mut search_end_char = 0;
2146 for (idx, &c) in chars.iter().enumerate() {
2147 let c_width = display_len(&c.to_string(), length_mode);
2148 if width_acc + c_width > line_length {
2149 break;
2150 }
2151 width_acc += c_width;
2152 search_end_char = idx + 1;
2153 }
2154
2155 let mut paren_depth: i32 = 0;
2162 let mut best_pos = None;
2163 for i in (0..search_end_char).rev() {
2164 let byte_start: usize = chars[..i].iter().map(|c| c.len_utf8()).sum();
2166 let byte_after: usize = byte_start + chars[i].len_utf8();
2168
2169 if !is_inside_element(byte_start, element_spans) {
2170 match chars[i] {
2171 ')' => paren_depth += 1,
2172 '(' => paren_depth = paren_depth.saturating_sub(1),
2173 _ => {}
2174 }
2175 }
2176
2177 if paren_depth == 0
2178 && is_clause_punctuation(chars[i])
2179 && clause_break_allowed_after(&chars, i)
2180 && !is_inside_element(byte_after, element_spans)
2181 {
2182 best_pos = Some(i);
2183 break;
2184 }
2185 }
2186
2187 let pos = best_pos?;
2188
2189 let first: String = chars[..=pos].iter().collect();
2191 let first_display_len = display_len(&first, length_mode);
2192 if first_display_len < min_first_len {
2193 return None;
2194 }
2195
2196 let rest: String = chars[pos + 1..].iter().collect();
2198 let rest = rest.trim_start().to_string();
2199
2200 if rest.is_empty() {
2201 return None;
2202 }
2203
2204 Some((first, rest))
2205}
2206
2207fn paren_depth_map(text: &str, element_spans: &[(usize, usize)]) -> Vec<i32> {
2214 let mut map = vec![0i32; text.len()];
2215 let mut depth = 0i32;
2216 for (byte, c) in text.char_indices() {
2217 if !is_inside_element(byte, element_spans) {
2218 match c {
2219 '(' => depth += 1,
2220 ')' => depth = depth.saturating_sub(1),
2221 _ => {}
2222 }
2223 }
2224 let end = (byte + c.len_utf8()).min(map.len());
2226 for slot in &mut map[byte..end] {
2227 *slot = depth;
2228 }
2229 }
2230 map
2231}
2232
2233fn is_standalone_parenthetical(line: &str) -> bool {
2242 let trimmed = line.trim();
2243 if !trimmed.starts_with('(') {
2244 return false;
2245 }
2246 let core = trimmed.trim_end_matches(|c: char| is_clause_punctuation(c));
2248 if !core.ends_with(')') {
2249 return false;
2250 }
2251 let inner = &core[1..core.len() - 1];
2253 if !inner.contains(' ') {
2254 return false;
2255 }
2256 let mut depth = 0i32;
2258 for c in core.chars() {
2259 match c {
2260 '(' => depth += 1,
2261 ')' => depth -= 1,
2262 _ => {}
2263 }
2264 if depth < 0 {
2265 return false;
2266 }
2267 }
2268 depth == 0
2269}
2270
2271fn split_at_break_word(
2275 text: &str,
2276 line_length: usize,
2277 element_spans: &[(usize, usize)],
2278 length_mode: ReflowLengthMode,
2279) -> Option<(String, String)> {
2280 let lower = text.to_lowercase();
2281 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
2282 let mut best_split: Option<(usize, usize)> = None; let depth_map = paren_depth_map(text, element_spans);
2287
2288 for &word in BREAK_WORDS {
2289 let mut search_start = 0;
2290 while let Some(pos) = lower[search_start..].find(word) {
2291 let abs_pos = search_start + pos;
2292
2293 let preceded_by_space = abs_pos == 0 || text.as_bytes().get(abs_pos - 1) == Some(&b' ');
2295 let followed_by_space = text.as_bytes().get(abs_pos + word.len()) == Some(&b' ');
2296
2297 if preceded_by_space && followed_by_space {
2298 let first_part = text[..abs_pos].trim_end();
2300 let first_part_len = display_len(first_part, length_mode);
2301
2302 let inside_paren = depth_map.get(abs_pos).is_some_and(|&d| d > 0);
2304
2305 if first_part_len >= min_first_len
2306 && first_part_len <= line_length
2307 && !is_inside_element(abs_pos, element_spans)
2308 && !inside_paren
2309 {
2310 if best_split.is_none_or(|(prev_pos, _)| abs_pos > prev_pos) {
2312 best_split = Some((abs_pos, word.len()));
2313 }
2314 }
2315 }
2316
2317 search_start = abs_pos + word.len();
2318 }
2319 }
2320
2321 let (byte_start, _word_len) = best_split?;
2322
2323 let first = text[..byte_start].trim_end().to_string();
2324 let rest = text[byte_start..].to_string();
2325
2326 if first.is_empty() || rest.trim().is_empty() {
2327 return None;
2328 }
2329
2330 Some((first, rest))
2331}
2332
2333fn cascade_split_line(text: &str, options: &ReflowOptions) -> Vec<String> {
2344 let line_length = options.line_length;
2345 let length_mode = options.length_mode;
2346 let attr_lists = options.attr_lists;
2347 let myst_roles = options.myst_roles;
2348 let defined_references = options.defined_references.as_ref();
2349 if line_length == 0 || display_len(text, length_mode) <= line_length {
2350 return vec![text.to_string()];
2351 }
2352
2353 let elements = parse_markdown_elements_inner(text, attr_lists, myst_roles, defined_references);
2354 let element_spans = compute_element_spans(&elements);
2355
2356 let rebased_spans = |start: usize| -> Vec<(usize, usize)> {
2360 if start == 0 {
2361 return element_spans.clone();
2362 }
2363 element_spans
2364 .iter()
2365 .filter(|&&(_, end)| end > start)
2366 .map(|&(s, e)| (s.saturating_sub(start), e.saturating_sub(start)))
2367 .collect()
2368 };
2369
2370 let mut result = Vec::new();
2371 let mut start = 0usize;
2372
2373 loop {
2374 let remaining = &text[start..];
2375 if display_len(remaining, length_mode) <= line_length {
2376 result.push(remaining.to_string());
2377 return result;
2378 }
2379
2380 let spans = rebased_spans(start);
2381
2382 let split = split_at_parenthetical(remaining, line_length, &spans, length_mode)
2386 .or_else(|| split_at_clause_punctuation(remaining, line_length, &spans, length_mode))
2387 .or_else(|| split_at_break_word(remaining, line_length, &spans, length_mode));
2388
2389 if let Some((first, rest)) = split {
2390 let consumed = remaining.len().saturating_sub(rest.len());
2391 if consumed == 0 {
2394 break;
2395 }
2396 result.push(first);
2397 start += consumed;
2398 continue;
2399 }
2400
2401 break;
2403 }
2404
2405 let mut fallback_options = options.clone();
2407 fallback_options.break_on_sentences = false;
2408 fallback_options.preserve_breaks = false;
2409 fallback_options.sentence_per_line = false;
2410 fallback_options.semantic_line_breaks = false;
2411 fallback_options.require_sentence_capital = true;
2412 fallback_options.max_list_continuation_indent = None;
2413 fallback_options.defined_references = None;
2414 let remaining = &text[start..];
2415 let tail_elements = if start == 0 {
2416 elements
2417 } else {
2418 parse_markdown_elements_inner(remaining, attr_lists, myst_roles, defined_references)
2419 };
2420 result.extend(reflow_elements(&tail_elements, &fallback_options));
2421 result
2422}
2423
2424fn reflow_elements_semantic(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
2428 let sentence_lines =
2430 reflow_elements_sentence_per_line(elements, &options.abbreviations, options.require_sentence_capital);
2431
2432 if options.line_length == 0 {
2435 return sentence_lines;
2436 }
2437
2438 let length_mode = options.length_mode;
2439 let mut result = Vec::new();
2440 for line in sentence_lines {
2441 if display_len(&line, length_mode) <= options.line_length {
2442 result.push(line);
2443 } else {
2444 result.extend(cascade_split_line(&line, options));
2445 }
2446 }
2447
2448 let min_line_len = ((options.line_length as f64) * MIN_SPLIT_RATIO) as usize;
2451 let mut merged: Vec<String> = Vec::with_capacity(result.len());
2452 for line in result {
2453 if !merged.is_empty() && display_len(&line, length_mode) < min_line_len && !line.trim().is_empty() {
2454 if is_standalone_parenthetical(&line) {
2457 merged.push(line);
2458 continue;
2459 }
2460
2461 let prev_ends_at_sentence = {
2463 let trimmed = merged.last().unwrap().trim_end();
2464 trimmed
2465 .chars()
2466 .rev()
2467 .find(|c| !matches!(c, '"' | '\'' | '\u{201D}' | '\u{2019}' | ')' | ']'))
2468 .is_some_and(|c| matches!(c, '.' | '!' | '?'))
2469 };
2470
2471 if !prev_ends_at_sentence {
2472 let prev = merged.last_mut().unwrap();
2473 let combined = format!("{prev} {line}");
2474 if display_len(&combined, length_mode) <= options.line_length {
2476 *prev = combined;
2477 continue;
2478 }
2479 }
2480 }
2481 merged.push(line);
2482 }
2483 merged
2484}
2485
2486fn rfind_safe_space(line: &str, element_spans: &[(usize, usize)]) -> Option<usize> {
2496 line.char_indices().rev().map(|(pos, _)| pos).find(|&pos| {
2497 line.as_bytes()[pos] == b' '
2498 && !element_spans.iter().any(|(s, e)| pos > *s && pos < *e)
2499 && !starts_block_construct(&line[pos + 1..])
2500 })
2501}
2502
2503fn break_before_attached(
2510 lines: &mut Vec<String>,
2511 current_line: &mut String,
2512 current_length: &mut usize,
2513 element_spans: &mut Vec<(usize, usize)>,
2514 attach: &str,
2515 separator: &str,
2516 length_mode: ReflowLengthMode,
2517) -> Option<usize> {
2518 let last_space = rfind_safe_space(current_line, element_spans)?;
2519 let before = current_line[..last_space]
2520 .trim_end_matches(is_breakable_whitespace)
2521 .to_string();
2522 let after = current_line[last_space + 1..].to_string();
2523 lines.push(before);
2524 let carried = after.len();
2525 *current_line = format!("{after}{separator}{attach}");
2526 *current_length = display_len(current_line, length_mode);
2527 element_spans.clear();
2528 Some(carried)
2529}
2530
2531fn reflow_elements(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
2533 let mut lines = Vec::new();
2534 let mut current_line = String::new();
2535 let mut current_length = 0;
2536 let mut current_line_element_spans: Vec<(usize, usize)> = Vec::new();
2538 let length_mode = options.length_mode;
2539
2540 for (idx, element) in elements.iter().enumerate() {
2541 let element_str = format!("{element}");
2544 let element_len = display_len(&element_str, length_mode);
2545
2546 let is_adjacent_to_prev = if idx > 0 {
2555 match (&elements[idx - 1], element) {
2556 (Element::Text(t), _) => !t.is_empty() && !t.ends_with(is_breakable_whitespace),
2557 (_, Element::Text(t)) => !t.is_empty() && !t.starts_with(is_breakable_whitespace),
2558 _ => true,
2559 }
2560 } else {
2561 false
2562 };
2563
2564 if let Element::Text(text) = element {
2566 let has_leading_space = text.starts_with(is_breakable_whitespace);
2568 let words: Vec<&str> = split_breakable_words(text).collect();
2570
2571 for (i, word) in words.iter().enumerate() {
2572 let word_len = display_len(word, length_mode);
2573 let is_trailing_punct = word.chars().all(|c| {
2579 matches!(c, ',' | '.' | ':' | ';' | '!' | '?' | ')' | ']' | '}') || is_non_breaking_space(c)
2580 });
2581
2582 let is_first_adjacent = i == 0 && is_adjacent_to_prev;
2585
2586 if is_first_adjacent {
2587 if current_length + word_len > options.line_length
2589 && current_length > 0
2590 && break_before_attached(
2591 &mut lines,
2592 &mut current_line,
2593 &mut current_length,
2594 &mut current_line_element_spans,
2595 word,
2596 "",
2597 length_mode,
2598 )
2599 .is_some()
2600 {
2601 } else {
2606 current_line.push_str(word);
2607 current_length += word_len;
2608 }
2609 } else if current_length > 0 && current_length + 1 + word_len > options.line_length {
2610 if is_trailing_punct {
2611 if break_before_attached(
2618 &mut lines,
2619 &mut current_line,
2620 &mut current_length,
2621 &mut current_line_element_spans,
2622 word,
2623 " ",
2624 length_mode,
2625 )
2626 .is_none()
2627 {
2628 current_line.push(' ');
2629 current_line.push_str(word);
2630 current_length += 1 + word_len;
2631 }
2632 } else if !starts_block_construct(word) {
2633 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
2635 current_line = word.to_string();
2636 current_length = word_len;
2637 current_line_element_spans.clear();
2638 } else if break_before_attached(
2639 &mut lines,
2640 &mut current_line,
2641 &mut current_length,
2642 &mut current_line_element_spans,
2643 word,
2644 " ",
2645 length_mode,
2646 )
2647 .is_some()
2648 {
2649 } else {
2654 if i > 0 || has_leading_space {
2657 current_line.push(' ');
2658 current_length += 1;
2659 }
2660 current_line.push_str(word);
2661 current_length += word_len;
2662 }
2663 } else {
2664 let add_space = current_length > 0 && (i > 0 || has_leading_space);
2676 if add_space {
2677 current_line.push(' ');
2678 current_length += 1;
2679 }
2680 current_line.push_str(word);
2681 current_length += word_len;
2682 }
2683 }
2684 } else if matches!(
2685 element,
2686 Element::Italic { .. } | Element::Bold { .. } | Element::Strikethrough { .. }
2687 ) && (options.emphasis_spans || element_len > options.line_length)
2688 {
2689 let (content, marker): (&str, &str) = match element {
2693 Element::Italic { content, underscore } => (content.as_str(), if *underscore { "_" } else { "*" }),
2694 Element::Bold { content, underscore } => (content.as_str(), if *underscore { "__" } else { "**" }),
2695 Element::Strikethrough { content, double } => (content.as_str(), if *double { "~~" } else { "~" }),
2696 _ => unreachable!(),
2697 };
2698
2699 let words: Vec<&str> = split_breakable_words(content).collect();
2700 let n = words.len();
2701
2702 if n == 0 {
2703 let full = format!("{marker}{marker}");
2705 let full_len = display_len(&full, length_mode);
2706 if !is_adjacent_to_prev && current_length > 0 {
2707 current_line.push(' ');
2708 current_length += 1;
2709 }
2710 current_line.push_str(&full);
2711 current_length += full_len;
2712 } else {
2713 for (i, word) in words.iter().enumerate() {
2714 let is_first = i == 0;
2715 let is_last = i == n - 1;
2716 let word_str: String = match (is_first, is_last) {
2717 (true, true) => format!("{marker}{word}{marker}"),
2718 (true, false) => format!("{marker}{word}"),
2719 (false, true) => format!("{word}{marker}"),
2720 (false, false) => word.to_string(),
2721 };
2722 let word_len = display_len(&word_str, length_mode);
2723
2724 let needs_space = if is_first {
2725 !is_adjacent_to_prev && current_length > 0
2726 } else {
2727 current_length > 0
2728 };
2729
2730 if needs_space
2731 && current_length + 1 + word_len > options.line_length
2732 && !starts_block_construct(&word_str)
2733 {
2734 lines.push(current_line.trim_end_matches(is_breakable_whitespace).to_string());
2735 current_line = word_str;
2736 current_length = word_len;
2737 current_line_element_spans.clear();
2738 } else {
2739 if needs_space {
2740 current_line.push(' ');
2741 current_length += 1;
2742 }
2743 current_line.push_str(&word_str);
2744 current_length += word_len;
2745 }
2746 }
2747 }
2748 } else {
2749 if is_adjacent_to_prev {
2753 if current_length + element_len > options.line_length
2755 && let Some(carried) = break_before_attached(
2756 &mut lines,
2757 &mut current_line,
2758 &mut current_length,
2759 &mut current_line_element_spans,
2760 &element_str,
2761 "",
2762 length_mode,
2763 )
2764 {
2765 current_line_element_spans.push((carried, carried + element_str.len()));
2769 } else {
2770 let start = current_line.len();
2772 current_line.push_str(&element_str);
2773 current_length += element_len;
2774 current_line_element_spans.push((start, current_line.len()));
2775 }
2776 } else if current_length > 0 && current_length + 1 + element_len > options.line_length {
2777 if !starts_block_construct(&element_str) {
2778 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
2780 current_line.clone_from(&element_str);
2781 current_length = element_len;
2782 current_line_element_spans.clear();
2783 current_line_element_spans.push((0, element_str.len()));
2784 } else if let Some(carried) = break_before_attached(
2785 &mut lines,
2786 &mut current_line,
2787 &mut current_length,
2788 &mut current_line_element_spans,
2789 &element_str,
2790 " ",
2791 length_mode,
2792 ) {
2793 let start = carried + 1;
2797 current_line_element_spans.push((start, start + element_str.len()));
2798 } else {
2799 let ends_with_opener =
2802 current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
2803 if !ends_with_opener {
2804 current_line.push(' ');
2805 current_length += 1;
2806 }
2807 let start = current_line.len();
2808 current_line.push_str(&element_str);
2809 current_length += element_len;
2810 current_line_element_spans.push((start, current_line.len()));
2811 }
2812 } else {
2813 let ends_with_opener =
2815 current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
2816 if current_length > 0 && !ends_with_opener {
2817 current_line.push(' ');
2818 current_length += 1;
2819 }
2820 let start = current_line.len();
2821 current_line.push_str(&element_str);
2822 current_length += element_len;
2823 current_line_element_spans.push((start, current_line.len()));
2824 }
2825 }
2826 }
2827
2828 if !current_line.is_empty() {
2830 lines.push(current_line.trim_end_matches(is_breakable_whitespace).to_string());
2831 }
2832
2833 lines
2834}
2835
2836pub fn reflow_markdown(content: &str, options: &ReflowOptions) -> String {
2838 let lines: Vec<&str> = content.lines().collect();
2839 let mut result = Vec::new();
2840 let mut i = 0;
2841
2842 while i < lines.len() {
2843 let line = lines[i];
2844 let trimmed = line.trim();
2845
2846 if trimmed.is_empty() {
2848 result.push(String::new());
2849 i += 1;
2850 continue;
2851 }
2852
2853 if trimmed.starts_with('#') {
2855 result.push(line.to_string());
2856 i += 1;
2857 continue;
2858 }
2859
2860 if trimmed.starts_with(":::") {
2862 result.push(line.to_string());
2863 i += 1;
2864 continue;
2865 }
2866
2867 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
2869 result.push(line.to_string());
2870 i += 1;
2871 while i < lines.len() {
2873 result.push(lines[i].to_string());
2874 if lines[i].trim().starts_with("```") || lines[i].trim().starts_with("~~~") {
2875 i += 1;
2876 break;
2877 }
2878 i += 1;
2879 }
2880 continue;
2881 }
2882
2883 if calculate_indentation_width_default(line) >= 4 {
2885 result.push(line.to_string());
2887 i += 1;
2888 while i < lines.len() {
2889 let next_line = lines[i];
2890 if calculate_indentation_width_default(next_line) >= 4 || next_line.trim().is_empty() {
2892 result.push(next_line.to_string());
2893 i += 1;
2894 } else {
2895 break;
2896 }
2897 }
2898 continue;
2899 }
2900
2901 if trimmed.starts_with('>') {
2903 let gt_pos = line.find('>').expect("'>' must exist since trimmed.starts_with('>')");
2906 let quote_prefix = line[0..=gt_pos].to_string();
2907 let quote_content = &line[quote_prefix.len()..].trim_start();
2908
2909 let reflowed = reflow_line(quote_content, options);
2910 for reflowed_line in &reflowed {
2911 result.push(format!("{quote_prefix} {reflowed_line}"));
2912 }
2913 i += 1;
2914 continue;
2915 }
2916
2917 if is_horizontal_rule(trimmed) {
2919 result.push(line.to_string());
2920 i += 1;
2921 continue;
2922 }
2923
2924 if is_unordered_list_marker(trimmed) || is_numbered_list_item(trimmed) {
2926 let indent = line.len() - line.trim_start().len();
2928 let indent_str = " ".repeat(indent);
2929
2930 let mut marker_end = indent;
2933 let mut content_start = indent;
2934
2935 if trimmed.chars().next().is_some_and(char::is_numeric) {
2936 if let Some(period_pos) = line[indent..].find('.') {
2938 marker_end = indent + period_pos + 1; content_start = marker_end;
2940 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
2944 content_start += 1;
2945 }
2946 }
2947 } else {
2948 marker_end = indent + 1; content_start = marker_end;
2951 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
2955 content_start += 1;
2956 }
2957 }
2958
2959 let min_continuation_indent = content_start;
2961
2962 let rest = &line[content_start..];
2965 if rest.starts_with("[ ] ") || rest.starts_with("[x] ") || rest.starts_with("[X] ") {
2966 marker_end = content_start + 3; content_start += 4; }
2969
2970 let marker = &line[indent..marker_end];
2971
2972 let mut list_content = vec![trim_preserving_hard_break(&line[content_start..])];
2975 i += 1;
2976
2977 while i < lines.len() {
2981 let next_line = lines[i];
2982 let next_trimmed = next_line.trim();
2983
2984 if is_block_boundary(next_trimmed) {
2986 break;
2987 }
2988
2989 let next_indent = next_line.len() - next_line.trim_start().len();
2991 if next_indent >= min_continuation_indent {
2992 let trimmed_start = next_line.trim_start();
2995 list_content.push(trim_preserving_hard_break(trimmed_start));
2996 i += 1;
2997 } else {
2998 break;
3000 }
3001 }
3002
3003 let combined_content = if options.preserve_breaks {
3006 list_content[0].clone()
3007 } else {
3008 let has_hard_breaks = list_content.iter().any(|line| has_hard_break(line));
3010 if has_hard_breaks {
3011 list_content.join("\n")
3013 } else {
3014 list_content.join(" ")
3016 }
3017 };
3018
3019 let trimmed_marker = marker;
3021 let continuation_spaces = if let Some(max_indent) = options.max_list_continuation_indent {
3022 indent + (content_start - indent).min(max_indent)
3025 } else {
3026 content_start
3027 };
3028
3029 let prefix_length = indent + trimmed_marker.len() + 1;
3031
3032 let adjusted_options = ReflowOptions {
3034 line_length: options.line_length.saturating_sub(prefix_length),
3035 ..options.clone()
3036 };
3037
3038 let reflowed = reflow_line(&combined_content, &adjusted_options);
3039 for (j, reflowed_line) in reflowed.iter().enumerate() {
3040 if j == 0 {
3041 result.push(format!("{indent_str}{trimmed_marker} {reflowed_line}"));
3042 } else {
3043 let continuation_indent = " ".repeat(continuation_spaces);
3045 result.push(format!("{continuation_indent}{reflowed_line}"));
3046 }
3047 }
3048 continue;
3049 }
3050
3051 if crate::utils::table_utils::TableUtils::is_potential_table_row(line) {
3053 result.push(line.to_string());
3054 i += 1;
3055 continue;
3056 }
3057
3058 if trimmed.starts_with('[') && line.contains("]:") {
3060 result.push(line.to_string());
3061 i += 1;
3062 continue;
3063 }
3064
3065 if is_definition_list_item(trimmed) {
3067 result.push(line.to_string());
3068 i += 1;
3069 continue;
3070 }
3071
3072 let mut is_single_line_paragraph = true;
3074 if i + 1 < lines.len() {
3075 let next_trimmed = lines[i + 1].trim();
3076 if !is_block_boundary(next_trimmed) {
3078 is_single_line_paragraph = false;
3079 }
3080 }
3081
3082 if is_single_line_paragraph && display_len(line, options.length_mode) <= options.line_length {
3084 result.push(line.to_string());
3085 i += 1;
3086 continue;
3087 }
3088
3089 let mut paragraph_parts = Vec::new();
3091 let mut current_part = vec![line];
3092 i += 1;
3093
3094 if options.preserve_breaks {
3096 let hard_break_type = if line.strip_suffix('\r').unwrap_or(line).ends_with('\\') {
3098 Some("\\")
3099 } else if line.ends_with(" ") {
3100 Some(" ")
3101 } else {
3102 None
3103 };
3104 let reflowed = reflow_line(line, options);
3105
3106 if let Some(break_marker) = hard_break_type {
3108 if !reflowed.is_empty() {
3109 let mut reflowed_with_break = reflowed;
3110 let last_idx = reflowed_with_break.len() - 1;
3111 if !has_hard_break(&reflowed_with_break[last_idx]) {
3112 reflowed_with_break[last_idx].push_str(break_marker);
3113 }
3114 result.extend(reflowed_with_break);
3115 }
3116 } else {
3117 result.extend(reflowed);
3118 }
3119 } else {
3120 while i < lines.len() {
3122 let prev_line = if !current_part.is_empty() {
3123 current_part.last().unwrap()
3124 } else {
3125 ""
3126 };
3127 let next_line = lines[i];
3128 let next_trimmed = next_line.trim();
3129
3130 if is_block_boundary(next_trimmed) {
3132 break;
3133 }
3134
3135 let prev_trimmed = prev_line.trim();
3138 let abbreviations = get_abbreviations(&options.abbreviations);
3139 let ends_with_sentence = (prev_trimmed.ends_with('.')
3140 || prev_trimmed.ends_with('!')
3141 || prev_trimmed.ends_with('?')
3142 || prev_trimmed.ends_with(".*")
3143 || prev_trimmed.ends_with("!*")
3144 || prev_trimmed.ends_with("?*")
3145 || prev_trimmed.ends_with("._")
3146 || prev_trimmed.ends_with("!_")
3147 || prev_trimmed.ends_with("?_")
3148 || prev_trimmed.ends_with(".\"")
3150 || prev_trimmed.ends_with("!\"")
3151 || prev_trimmed.ends_with("?\"")
3152 || prev_trimmed.ends_with(".'")
3153 || prev_trimmed.ends_with("!'")
3154 || prev_trimmed.ends_with("?'")
3155 || prev_trimmed.ends_with(".\u{201D}")
3156 || prev_trimmed.ends_with("!\u{201D}")
3157 || prev_trimmed.ends_with("?\u{201D}")
3158 || prev_trimmed.ends_with(".\u{2019}")
3159 || prev_trimmed.ends_with("!\u{2019}")
3160 || prev_trimmed.ends_with("?\u{2019}"))
3161 && !text_ends_with_abbreviation(
3162 prev_trimmed.trim_end_matches(['*', '_', '"', '\'', '\u{201D}', '\u{2019}']),
3163 &abbreviations,
3164 );
3165
3166 if has_hard_break(prev_line) || (options.sentence_per_line && ends_with_sentence) {
3167 paragraph_parts.push(current_part.join(" "));
3169 current_part = vec![next_line];
3170 } else {
3171 current_part.push(next_line);
3172 }
3173 i += 1;
3174 }
3175
3176 if !current_part.is_empty() {
3178 if current_part.len() == 1 {
3179 paragraph_parts.push(current_part[0].to_string());
3181 } else {
3182 paragraph_parts.push(current_part.join(" "));
3183 }
3184 }
3185
3186 for (j, part) in paragraph_parts.iter().enumerate() {
3188 let reflowed = reflow_line(part, options);
3189 result.extend(reflowed);
3190
3191 if j < paragraph_parts.len() - 1 && !result.is_empty() && !options.sentence_per_line {
3195 let last_idx = result.len() - 1;
3196 if !has_hard_break(&result[last_idx]) {
3197 result[last_idx].push_str(" ");
3198 }
3199 }
3200 }
3201 }
3202 }
3203
3204 let result_text = result.join("\n");
3206 if content.ends_with('\n') && !result_text.ends_with('\n') {
3207 format!("{result_text}\n")
3208 } else {
3209 result_text
3210 }
3211}
3212
3213#[derive(Debug, Clone)]
3215pub struct ParagraphReflow {
3216 pub start_byte: usize,
3218 pub end_byte: usize,
3220 pub reflowed_text: String,
3222}
3223
3224#[derive(Debug, Clone)]
3230pub struct BlockquoteLineData {
3231 pub(crate) content: String,
3233 pub(crate) is_explicit: bool,
3235 pub(crate) prefix: Option<String>,
3237}
3238
3239impl BlockquoteLineData {
3240 pub fn explicit(content: String, prefix: String) -> Self {
3242 Self {
3243 content,
3244 is_explicit: true,
3245 prefix: Some(prefix),
3246 }
3247 }
3248
3249 pub fn lazy(content: String) -> Self {
3251 Self {
3252 content,
3253 is_explicit: false,
3254 prefix: None,
3255 }
3256 }
3257}
3258
3259#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3261pub enum BlockquoteContinuationStyle {
3262 Explicit,
3263 Lazy,
3264}
3265
3266pub fn blockquote_continuation_style(lines: &[BlockquoteLineData]) -> BlockquoteContinuationStyle {
3274 let mut explicit_count = 0usize;
3275 let mut lazy_count = 0usize;
3276
3277 for line in lines.iter().skip(1) {
3278 if line.is_explicit {
3279 explicit_count += 1;
3280 } else {
3281 lazy_count += 1;
3282 }
3283 }
3284
3285 if explicit_count > 0 && lazy_count == 0 {
3286 BlockquoteContinuationStyle::Explicit
3287 } else if lazy_count > 0 && explicit_count == 0 {
3288 BlockquoteContinuationStyle::Lazy
3289 } else if explicit_count >= lazy_count {
3290 BlockquoteContinuationStyle::Explicit
3291 } else {
3292 BlockquoteContinuationStyle::Lazy
3293 }
3294}
3295
3296pub fn dominant_blockquote_prefix(lines: &[BlockquoteLineData], fallback: &str) -> String {
3301 let mut counts: std::collections::HashMap<String, (usize, usize)> = std::collections::HashMap::new();
3302
3303 for (idx, line) in lines.iter().enumerate() {
3304 let Some(prefix) = line.prefix.as_ref() else {
3305 continue;
3306 };
3307 counts
3308 .entry(prefix.clone())
3309 .and_modify(|entry| entry.0 += 1)
3310 .or_insert((1, idx));
3311 }
3312
3313 counts
3314 .into_iter()
3315 .max_by(|(_, (count_a, first_idx_a)), (_, (count_b, first_idx_b))| {
3316 count_a.cmp(count_b).then_with(|| first_idx_b.cmp(first_idx_a))
3317 })
3318 .map_or_else(|| fallback.to_string(), |(prefix, _)| prefix)
3319}
3320
3321pub(crate) fn should_force_explicit_blockquote_line(content_line: &str) -> bool {
3326 let trimmed = content_line.trim_start();
3327 trimmed.starts_with('>')
3328 || trimmed.starts_with('#')
3329 || trimmed.starts_with("```")
3330 || trimmed.starts_with("~~~")
3331 || is_unordered_list_marker(trimmed)
3332 || is_numbered_list_item(trimmed)
3333 || is_horizontal_rule(trimmed)
3334 || is_definition_list_item(trimmed)
3335 || (trimmed.starts_with('[') && trimmed.contains("]:"))
3336 || trimmed.starts_with(":::")
3337 || (trimmed.starts_with('<')
3338 && !trimmed.starts_with("<http")
3339 && !trimmed.starts_with("<https")
3340 && !trimmed.starts_with("<mailto:"))
3341}
3342
3343pub fn reflow_blockquote_content(
3352 lines: &[BlockquoteLineData],
3353 explicit_prefix: &str,
3354 continuation_style: BlockquoteContinuationStyle,
3355 options: &ReflowOptions,
3356) -> Vec<String> {
3357 let content_strs: Vec<&str> = lines.iter().map(|l| l.content.as_str()).collect();
3358 let segments = split_into_segments_strs(&content_strs);
3359 let mut reflowed_content_lines: Vec<String> = Vec::new();
3360
3361 for segment in segments {
3362 let hard_break_type = segment.last().and_then(|&line| {
3363 let line = line.strip_suffix('\r').unwrap_or(line);
3364 if line.ends_with('\\') {
3365 Some("\\")
3366 } else if line.ends_with(" ") {
3367 Some(" ")
3368 } else {
3369 None
3370 }
3371 });
3372
3373 let pieces: Vec<&str> = segment
3374 .iter()
3375 .map(|&line| {
3376 if let Some(l) = line.strip_suffix('\\') {
3377 l.trim_end()
3378 } else if let Some(l) = line.strip_suffix(" ") {
3379 l.trim_end()
3380 } else {
3381 line.trim_end()
3382 }
3383 })
3384 .collect();
3385
3386 let segment_text = pieces.join(" ");
3387 let segment_text = segment_text.trim();
3388 if segment_text.is_empty() {
3389 continue;
3390 }
3391
3392 let mut reflowed = reflow_line(segment_text, options);
3393 if let Some(break_marker) = hard_break_type
3394 && !reflowed.is_empty()
3395 {
3396 let last_idx = reflowed.len() - 1;
3397 if !has_hard_break(&reflowed[last_idx]) {
3398 reflowed[last_idx].push_str(break_marker);
3399 }
3400 }
3401 reflowed_content_lines.extend(reflowed);
3402 }
3403
3404 let mut styled_lines: Vec<String> = Vec::new();
3405 for (idx, line) in reflowed_content_lines.iter().enumerate() {
3406 let force_explicit = idx == 0
3407 || continuation_style == BlockquoteContinuationStyle::Explicit
3408 || should_force_explicit_blockquote_line(line);
3409 if force_explicit {
3410 styled_lines.push(format!("{explicit_prefix}{line}"));
3411 } else {
3412 styled_lines.push(line.clone());
3413 }
3414 }
3415
3416 styled_lines
3417}
3418
3419fn is_blockquote_content_boundary(content: &str) -> bool {
3420 let trimmed = content.trim();
3421 trimmed.is_empty()
3422 || is_block_boundary(trimmed)
3423 || crate::utils::table_utils::TableUtils::is_potential_table_row(content)
3424 || trimmed.starts_with(":::")
3425 || crate::utils::is_template_directive_only(content)
3426 || is_standalone_attr_list(content)
3427 || is_snippet_block_delimiter(content)
3428}
3429
3430fn split_into_segments_strs<'a>(lines: &[&'a str]) -> Vec<Vec<&'a str>> {
3431 let mut segments = Vec::new();
3432 let mut current = Vec::new();
3433
3434 for &line in lines {
3435 current.push(line);
3436 if has_hard_break(line) {
3437 segments.push(current);
3438 current = Vec::new();
3439 }
3440 }
3441
3442 if !current.is_empty() {
3443 segments.push(current);
3444 }
3445
3446 segments
3447}
3448
3449fn reflow_blockquote_paragraph_at_line(
3450 content: &str,
3451 lines: &[&str],
3452 target_idx: usize,
3453 options: &ReflowOptions,
3454) -> Option<ParagraphReflow> {
3455 let mut anchor_idx = target_idx;
3456 let mut target_level = if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[target_idx]) {
3457 parsed.nesting_level
3458 } else {
3459 let mut found = None;
3460 let mut idx = target_idx;
3461 loop {
3462 if lines[idx].trim().is_empty() {
3463 break;
3464 }
3465 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[idx]) {
3466 found = Some((idx, parsed.nesting_level));
3467 break;
3468 }
3469 if idx == 0 {
3470 break;
3471 }
3472 idx -= 1;
3473 }
3474 let (idx, level) = found?;
3475 anchor_idx = idx;
3476 level
3477 };
3478
3479 let mut para_start = anchor_idx;
3481 while para_start > 0 {
3482 let prev_idx = para_start - 1;
3483 let prev_line = lines[prev_idx];
3484
3485 if prev_line.trim().is_empty() {
3486 break;
3487 }
3488
3489 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(prev_line) {
3490 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
3491 break;
3492 }
3493 para_start = prev_idx;
3494 continue;
3495 }
3496
3497 let prev_lazy = prev_line.trim_start();
3498 if is_blockquote_content_boundary(prev_lazy) {
3499 break;
3500 }
3501 para_start = prev_idx;
3502 }
3503
3504 while para_start < lines.len() {
3506 let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[para_start]) else {
3507 para_start += 1;
3508 continue;
3509 };
3510 target_level = parsed.nesting_level;
3511 break;
3512 }
3513
3514 if para_start >= lines.len() || para_start > target_idx {
3515 return None;
3516 }
3517
3518 let mut collected: Vec<(usize, BlockquoteLineData)> = Vec::new();
3521 let mut idx = para_start;
3522 while idx < lines.len() {
3523 if !collected.is_empty() && has_hard_break(&collected[collected.len() - 1].1.content) {
3524 break;
3525 }
3526
3527 let line = lines[idx];
3528 if line.trim().is_empty() {
3529 break;
3530 }
3531
3532 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(line) {
3533 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
3534 break;
3535 }
3536 collected.push((
3537 idx,
3538 BlockquoteLineData::explicit(trim_preserving_hard_break(parsed.content), parsed.prefix.to_string()),
3539 ));
3540 idx += 1;
3541 continue;
3542 }
3543
3544 let lazy_content = line.trim_start();
3545 if is_blockquote_content_boundary(lazy_content) {
3546 break;
3547 }
3548
3549 collected.push((idx, BlockquoteLineData::lazy(trim_preserving_hard_break(lazy_content))));
3550 idx += 1;
3551 }
3552
3553 if collected.is_empty() {
3554 return None;
3555 }
3556
3557 let para_end = collected[collected.len() - 1].0;
3558 if target_idx < para_start || target_idx > para_end {
3559 return None;
3560 }
3561
3562 let line_data: Vec<BlockquoteLineData> = collected.iter().map(|(_, d)| d.clone()).collect();
3563
3564 let fallback_prefix = line_data
3565 .iter()
3566 .find_map(|d| d.prefix.clone())
3567 .unwrap_or_else(|| "> ".to_string());
3568 let explicit_prefix = dominant_blockquote_prefix(&line_data, &fallback_prefix);
3569 let continuation_style = blockquote_continuation_style(&line_data);
3570
3571 let adjusted_line_length = options
3572 .line_length
3573 .saturating_sub(display_len(&explicit_prefix, options.length_mode))
3574 .max(1);
3575
3576 let adjusted_options = ReflowOptions {
3577 line_length: adjusted_line_length,
3578 ..options.clone()
3579 };
3580
3581 let styled_lines = reflow_blockquote_content(&line_data, &explicit_prefix, continuation_style, &adjusted_options);
3582
3583 if styled_lines.is_empty() {
3584 return None;
3585 }
3586
3587 let mut start_byte = 0;
3589 for line in lines.iter().take(para_start) {
3590 start_byte += line.len() + 1;
3591 }
3592
3593 let mut end_byte = start_byte;
3594 for line in lines.iter().take(para_end + 1).skip(para_start) {
3595 end_byte += line.len() + 1;
3596 }
3597
3598 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
3599 if !includes_trailing_newline {
3600 end_byte -= 1;
3601 }
3602
3603 let reflowed_joined = styled_lines.join("\n");
3604 let reflowed_text = if includes_trailing_newline {
3605 if reflowed_joined.ends_with('\n') {
3606 reflowed_joined
3607 } else {
3608 format!("{reflowed_joined}\n")
3609 }
3610 } else if reflowed_joined.ends_with('\n') {
3611 reflowed_joined.trim_end_matches('\n').to_string()
3612 } else {
3613 reflowed_joined
3614 };
3615
3616 Some(ParagraphReflow {
3617 start_byte,
3618 end_byte,
3619 reflowed_text,
3620 })
3621}
3622
3623pub fn reflow_paragraph_at_line(content: &str, line_number: usize, line_length: usize) -> Option<ParagraphReflow> {
3641 reflow_paragraph_at_line_with_mode(content, line_number, line_length, ReflowLengthMode::default())
3642}
3643
3644pub fn reflow_paragraph_at_line_with_mode(
3646 content: &str,
3647 line_number: usize,
3648 line_length: usize,
3649 length_mode: ReflowLengthMode,
3650) -> Option<ParagraphReflow> {
3651 let options = ReflowOptions {
3652 line_length,
3653 length_mode,
3654 ..Default::default()
3655 };
3656 reflow_paragraph_at_line_with_options(content, line_number, &options)
3657}
3658
3659pub fn reflow_paragraph_at_line_with_options(
3670 content: &str,
3671 line_number: usize,
3672 options: &ReflowOptions,
3673) -> Option<ParagraphReflow> {
3674 if line_number == 0 {
3675 return None;
3676 }
3677
3678 let lines: Vec<&str> = content.lines().collect();
3679
3680 if line_number > lines.len() {
3682 return None;
3683 }
3684
3685 let target_idx = line_number - 1; let target_line = lines[target_idx];
3687 let trimmed = target_line.trim();
3688
3689 if let Some(blockquote_reflow) = reflow_blockquote_paragraph_at_line(content, &lines, target_idx, options) {
3692 return Some(blockquote_reflow);
3693 }
3694
3695 if is_paragraph_boundary(trimmed, target_line) {
3697 return None;
3698 }
3699
3700 let mut para_start = target_idx;
3702 while para_start > 0 {
3703 let prev_idx = para_start - 1;
3704 let prev_line = lines[prev_idx];
3705 let prev_trimmed = prev_line.trim();
3706
3707 if is_paragraph_boundary(prev_trimmed, prev_line) {
3709 break;
3710 }
3711
3712 para_start = prev_idx;
3713 }
3714
3715 let mut para_end = target_idx;
3717 while para_end + 1 < lines.len() {
3718 let next_idx = para_end + 1;
3719 let next_line = lines[next_idx];
3720 let next_trimmed = next_line.trim();
3721
3722 if is_paragraph_boundary(next_trimmed, next_line) {
3724 break;
3725 }
3726
3727 para_end = next_idx;
3728 }
3729
3730 let paragraph_lines = &lines[para_start..=para_end];
3732
3733 let mut start_byte = 0;
3735 for line in lines.iter().take(para_start) {
3736 start_byte += line.len() + 1; }
3738
3739 let mut end_byte = start_byte;
3740 for line in paragraph_lines {
3741 end_byte += line.len() + 1; }
3743
3744 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
3747
3748 if !includes_trailing_newline {
3750 end_byte -= 1;
3751 }
3752
3753 let paragraph_text = paragraph_lines.join("\n");
3755
3756 let reflowed = reflow_markdown(¶graph_text, options);
3758
3759 let reflowed_text = if includes_trailing_newline {
3763 if reflowed.ends_with('\n') {
3765 reflowed
3766 } else {
3767 format!("{reflowed}\n")
3768 }
3769 } else {
3770 if reflowed.ends_with('\n') {
3772 reflowed.trim_end_matches('\n').to_string()
3773 } else {
3774 reflowed
3775 }
3776 };
3777
3778 Some(ParagraphReflow {
3779 start_byte,
3780 end_byte,
3781 reflowed_text,
3782 })
3783}
3784
3785#[cfg(test)]
3786mod tests {
3787 use super::*;
3788
3789 #[test]
3790 fn cascade_split_line_handles_a_very_long_line_without_overflowing() {
3791 let words: Vec<String> = (0..4000).map(|i| format!("word{i}")).collect();
3797 let line = words.join(" ");
3798
3799 let options = ReflowOptions {
3800 line_length: 80,
3801 length_mode: ReflowLengthMode::Chars,
3802 ..Default::default()
3803 };
3804 let out = cascade_split_line(&line, &options);
3805
3806 assert!(out.len() > 1, "a very long line should split into many lines");
3807 for segment in &out {
3808 assert!(
3809 display_len(segment, ReflowLengthMode::Chars) <= 80 || !segment.contains(' '),
3810 "each wrapped line should fit the width (or be a single unbreakable token)"
3811 );
3812 }
3813 let rejoined = out.join(" ");
3815 let original_words: Vec<&str> = line.split(' ').collect();
3816 let result_words: Vec<&str> = rejoined.split_whitespace().collect();
3817 assert_eq!(original_words, result_words, "reflow must preserve all words in order");
3818 }
3819
3820 #[test]
3825 fn test_helper_function_text_ends_with_abbreviation() {
3826 let abbreviations = get_abbreviations(&None);
3828
3829 assert!(text_ends_with_abbreviation("Dr.", &abbreviations));
3831 assert!(text_ends_with_abbreviation("word Dr.", &abbreviations));
3832 assert!(text_ends_with_abbreviation("e.g.", &abbreviations));
3833 assert!(text_ends_with_abbreviation("i.e.", &abbreviations));
3834 assert!(text_ends_with_abbreviation("Mr.", &abbreviations));
3835 assert!(text_ends_with_abbreviation("Mrs.", &abbreviations));
3836 assert!(text_ends_with_abbreviation("Ms.", &abbreviations));
3837 assert!(text_ends_with_abbreviation("Prof.", &abbreviations));
3838
3839 assert!(!text_ends_with_abbreviation("etc.", &abbreviations));
3841 assert!(!text_ends_with_abbreviation("paradigms.", &abbreviations));
3842 assert!(!text_ends_with_abbreviation("programs.", &abbreviations));
3843 assert!(!text_ends_with_abbreviation("items.", &abbreviations));
3844 assert!(!text_ends_with_abbreviation("systems.", &abbreviations));
3845 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)); }
3851
3852 #[test]
3853 fn test_footnote_after_period_splits_sentence() {
3854 let text = "First sentence.[^1] Second sentence.";
3858 let sentences = split_into_sentences(text);
3859 assert_eq!(
3860 sentences,
3861 vec!["First sentence.[^1]".to_string(), "Second sentence.".to_string()],
3862 "footnote glued to the period should keep the boundary and stay attached to the first sentence"
3863 );
3864 }
3865
3866 #[test]
3867 fn test_multiple_consecutive_footnotes_after_period_splits_sentence() {
3868 let text = "Notes here.[^1][^2] Second sentence.";
3870 let sentences = split_into_sentences(text);
3871 assert_eq!(
3872 sentences,
3873 vec!["Notes here.[^1][^2]".to_string(), "Second sentence.".to_string()]
3874 );
3875 }
3876
3877 #[test]
3878 fn test_footnote_before_period_still_splits_sentence() {
3879 let text = "Annotation here[^1]. Second sentence.";
3883 let sentences = split_into_sentences(text);
3884 assert_eq!(
3885 sentences,
3886 vec!["Annotation here[^1].".to_string(), "Second sentence.".to_string()]
3887 );
3888 }
3889
3890 #[test]
3891 fn test_mid_sentence_footnote_does_not_split() {
3892 let text = "The system word[^1] more words. Next sentence.";
3895 let sentences = split_into_sentences(text);
3896 assert_eq!(
3897 sentences,
3898 vec![
3899 "The system word[^1] more words.".to_string(),
3900 "Next sentence.".to_string()
3901 ]
3902 );
3903 }
3904
3905 #[test]
3906 fn test_bare_numeric_bracket_after_period_does_not_split() {
3907 let text = "Citation here.[1] Second sentence.";
3910 let sentences = split_into_sentences(text);
3911 assert_eq!(
3912 sentences,
3913 vec![text.to_string()],
3914 "a bare numeric bracket must not be treated as a sentence boundary"
3915 );
3916 }
3917
3918 #[test]
3919 fn test_footnote_glued_to_following_word_does_not_split() {
3920 let text = "First sentence.[^1]Continued glued text.";
3923 let sentences = split_into_sentences(text);
3924 assert_eq!(sentences, vec![text.to_string()]);
3925 }
3926
3927 #[test]
3928 fn test_footnote_at_end_of_text_is_preserved() {
3929 let text = "Sentence.[^1]";
3932 let sentences = split_into_sentences(text);
3933 assert_eq!(sentences, vec![text.to_string()]);
3934 }
3935
3936 #[test]
3937 fn test_abbreviation_before_footnote_does_not_split() {
3938 let text = "See the notes, e.g.[^1] this one.";
3941 let sentences = split_into_sentences(text);
3942 assert_eq!(
3943 sentences,
3944 vec![text.to_string()],
3945 "e.g. is an abbreviation, not a sentence boundary"
3946 );
3947 }
3948
3949 #[test]
3950 fn test_is_unordered_list_marker() {
3951 assert!(is_unordered_list_marker("- item"));
3953 assert!(is_unordered_list_marker("* item"));
3954 assert!(is_unordered_list_marker("+ item"));
3955 assert!(is_unordered_list_marker("-")); assert!(is_unordered_list_marker("*"));
3957 assert!(is_unordered_list_marker("+"));
3958
3959 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")); }
3970
3971 #[test]
3972 fn test_is_block_boundary() {
3973 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"));
3995 assert!(!is_block_boundary("*emphasis*")); assert!(!is_block_boundary("[link](url)")); assert!(!is_block_boundary("some words here"));
3998 }
3999
4000 #[test]
4001 fn test_definition_list_boundary_in_single_line_paragraph() {
4002 let options = ReflowOptions {
4005 line_length: 80,
4006 ..Default::default()
4007 };
4008 let input = "Term\n: Definition of the term";
4009 let result = reflow_markdown(input, &options);
4010 assert!(
4012 result.contains(": Definition"),
4013 "Definition list item should not be merged into previous line. Got: {result:?}"
4014 );
4015 let lines: Vec<&str> = result.lines().collect();
4016 assert_eq!(lines.len(), 2, "Should remain two separate lines. Got: {lines:?}");
4017 assert_eq!(lines[0], "Term");
4018 assert_eq!(lines[1], ": Definition of the term");
4019 }
4020
4021 #[test]
4022 fn test_is_paragraph_boundary() {
4023 assert!(is_paragraph_boundary("# Heading", "# Heading"));
4025 assert!(is_paragraph_boundary("- item", "- item"));
4026 assert!(is_paragraph_boundary(":::", ":::"));
4027 assert!(is_paragraph_boundary(": definition", ": definition"));
4028
4029 assert!(is_paragraph_boundary("code", " code"));
4031 assert!(is_paragraph_boundary("code", "\tcode"));
4032
4033 assert!(is_paragraph_boundary("| a | b |", "| a | b |"));
4035 assert!(is_paragraph_boundary("a | b", "a | b")); assert!(!is_paragraph_boundary("regular text", "regular text"));
4039 assert!(!is_paragraph_boundary("text", " text")); }
4041
4042 #[test]
4043 fn test_div_marker_boundary_in_reflow_paragraph_at_line() {
4044 let content = "Some paragraph text here.\n\n::: {.callout-note}\nThis is a callout.\n:::\n";
4047 let result = reflow_paragraph_at_line(content, 3, 80);
4049 assert!(result.is_none(), "Div marker line should not be reflowed");
4050 }
4051
4052 #[test]
4053 fn starts_block_construct_detects_block_openers() {
4054 for case in ["- item", "-", "* item", "*", "+ item", "+", "-\titem"] {
4056 assert!(starts_block_construct(case), "bullet: {case:?}");
4057 }
4058 for case in ["1. item", "1) item", "9. x", "123456789. x", "1.", "42) x"] {
4060 assert!(starts_block_construct(case), "ordered: {case:?}");
4061 }
4062 for case in ["> quote", ">quote", ">"] {
4064 assert!(starts_block_construct(case), "blockquote: {case:?}");
4065 }
4066 for case in ["# heading", "###### h6", "#", "##"] {
4068 assert!(starts_block_construct(case), "heading: {case:?}");
4069 }
4070 for case in ["```", "```rust", "````", "~~~", "~~~text"] {
4072 assert!(starts_block_construct(case), "fence: {case:?}");
4073 }
4074 for case in ["---", "--", "===", "=", "***", "___", "_ _ _", "- - -"] {
4076 assert!(starts_block_construct(case), "setext/thematic: {case:?}");
4077 }
4078 for case in [
4081 "[^1]: text",
4082 "[^note]:",
4083 "[ref]: http://example.com",
4084 "[wat]: url follows",
4085 ] {
4086 assert!(starts_block_construct(case), "definition: {case:?}");
4087 }
4088 for case in ["<div>content", "</div>", "<p>text", "<table>", "<pre>code", "<h1>x"] {
4090 assert!(starts_block_construct(case), "html block: {case:?}");
4091 }
4092 }
4093
4094 #[test]
4095 fn starts_block_construct_allows_ordinary_prose() {
4096 for case in [
4097 "",
4098 "word",
4099 "-5 degrees",
4100 "--flag",
4101 "-item",
4102 "#hashtag",
4103 "####### seven hashes is not a heading",
4104 "1.5 million",
4105 "1234567890. ten digits is not a list marker",
4106 "1:30 pm",
4107 "*emphasis*",
4108 "**bold** text",
4109 "__bold__ text",
4110 "_emphasis_ text",
4111 "`code` span",
4112 "`` double backtick span ``",
4113 "~~strikethrough~~",
4114 "=x",
4115 "== ==",
4116 "(parenthetical)",
4117 "[link](url)",
4118 "[text][ref] more",
4119 "[bracketed] aside",
4120 "[a](b) [ref]: first bracket is a link, not a label",
4121 "[esc\\]: not a close] text",
4122 "<span>inline</span>",
4123 "<b>bold</b>",
4124 "<https://example.com> autolink",
4125 "<mailto:a@b.com>",
4126 "<notarealtag>",
4127 ] {
4128 assert!(!starts_block_construct(case), "prose: {case:?}");
4129 }
4130 }
4131
4132 #[test]
4133 fn merge_block_construct_continuations_merges_marker_led_lines() {
4134 let lines = vec![
4135 "First sentence?".to_string(),
4136 "- looks like a list item".to_string(),
4137 "Second sentence.".to_string(),
4138 ];
4139 assert_eq!(
4140 merge_block_construct_continuations(lines),
4141 vec![
4142 "First sentence? - looks like a list item".to_string(),
4143 "Second sentence.".to_string(),
4144 ]
4145 );
4146
4147 let lines = vec!["- real list content".to_string(), "continuation".to_string()];
4150 assert_eq!(
4151 merge_block_construct_continuations(lines.clone()),
4152 lines,
4153 "first line must never be merged"
4154 );
4155 }
4156
4157 #[test]
4158 fn wrap_never_starts_a_line_with_a_block_marker() {
4159 let options = ReflowOptions {
4160 line_length: 25,
4161 ..Default::default()
4162 };
4163 let lines = reflow_line(
4166 "Some words here and then - a dash clause that wraps around the limit.",
4167 &options,
4168 );
4169 assert_eq!(
4170 lines,
4171 vec![
4172 "Some words here and",
4173 "then - a dash clause that",
4174 "wraps around the limit."
4175 ]
4176 );
4177
4178 for input in [
4180 "Alpha beta gamma delta epsilon - dash clause here to wrap",
4181 "Alpha beta gamma delta epsilon > quote lookalike here to wrap",
4182 "Alpha beta gamma delta epsilon # heading lookalike here to wrap",
4183 "Alpha beta gamma delta epsilon 1. ordered lookalike here to wrap",
4184 "Alpha beta gamma delta epsilon * star clause here to wrap",
4185 "Alpha beta gamma delta epsilon + plus clause here to wrap",
4186 ] {
4187 for width in 10..40 {
4188 let options = ReflowOptions {
4189 line_length: width,
4190 ..Default::default()
4191 };
4192 for line in reflow_line(input, &options) {
4193 assert!(
4194 !starts_block_construct(&line),
4195 "width {width}: wrapped line opens a block construct: {line:?} (input {input:?})"
4196 );
4197 }
4198 }
4199 }
4200 }
4201
4202 #[test]
4203 fn sentence_per_line_keeps_block_markers_mid_line() {
4204 let options = ReflowOptions {
4205 line_length: 80,
4206 sentence_per_line: true,
4207 ..Default::default()
4208 };
4209 let lines = reflow_line(
4212 "Google Calendar (Can't we get rid of this dependency? - I don't really see the need)",
4213 &options,
4214 );
4215 assert_eq!(
4216 lines,
4217 vec!["Google Calendar (Can't we get rid of this dependency? - I don't really see the need)".to_string()]
4218 );
4219
4220 let lines = reflow_line("See section 4? # is the marker we use. Fine.", &options);
4222 assert_eq!(lines, vec!["See section 4? # is the marker we use.", "Fine."]);
4223
4224 let lines = reflow_line("Is this a problem? > I quote someone here.", &options);
4225 assert_eq!(lines, vec!["Is this a problem? > I quote someone here."]);
4226
4227 let lines = reflow_line("Another case! 1. Not a list. More text follows here.", &options);
4228 for line in &lines {
4229 assert!(
4230 !starts_block_construct(line),
4231 "sentence-per-line output opens a block construct: {line:?}"
4232 );
4233 }
4234 }
4235
4236 #[test]
4237 fn inline_math_directly_after_display_math_stays_atomic() {
4238 let options = ReflowOptions {
4246 line_length: 8,
4247 ..Default::default()
4248 };
4249 let lines = reflow_line("$$a$$$bb cc dd$ x", &options);
4250 assert_eq!(lines, vec!["$$a$$$bb cc dd$".to_string(), "x".to_string()]);
4251 }
4252
4253 #[test]
4254 fn test_code_span_parsing() {
4255 let elements = parse_markdown_elements_inner("`code`", false, false, None);
4257 assert_eq!(elements.len(), 1);
4258 assert!(matches!(&elements[0], Element::Code(s) if s == "`code`"));
4259
4260 let elements = parse_markdown_elements_inner("``code``", false, false, None);
4262 assert_eq!(elements.len(), 1);
4263 assert!(matches!(&elements[0], Element::Code(s) if s == "``code``"));
4264
4265 let elements = parse_markdown_elements_inner("``code`inside``", false, false, None);
4267 assert_eq!(elements.len(), 1);
4268 assert!(matches!(&elements[0], Element::Code(s) if s == "``code`inside``"));
4269
4270 let elements = parse_markdown_elements_inner("`` code ``", false, false, None);
4272 assert_eq!(elements.len(), 1);
4273 assert!(matches!(&elements[0], Element::Code(s) if s == "`` code ``"));
4274
4275 let elements = parse_markdown_elements_inner("`unclosed", false, false, None);
4277 assert_eq!(elements.len(), 1);
4278 assert!(matches!(&elements[0], Element::Text(s) if s == "`unclosed"));
4279
4280 let elements = parse_markdown_elements_inner("`unclosed [link](url)", false, false, None);
4282 assert_eq!(elements.len(), 2);
4284 assert!(matches!(&elements[0], Element::Text(s) if s == "`unclosed "));
4285 assert!(matches!(&elements[1], Element::Link(s) if s == "[link](url)"));
4286 }
4287
4288 #[test]
4289 fn test_reflow_performance_long_input() {
4290 let mut text = String::new();
4293 for i in 1..400 {
4294 let backticks = "`".repeat(i);
4295 text.push_str(&backticks);
4296 text.push(' ');
4297 }
4298
4299 let start = std::time::Instant::now();
4300 let elements = parse_markdown_elements_inner(&text, false, false, None);
4301 let duration = start.elapsed();
4302
4303 assert!(duration.as_millis() < 100, "Parsing took too long: {duration:?}");
4305 assert!(!elements.is_empty());
4306 }
4307
4308 #[test]
4309 fn test_reflow_performance_display_math_heavy() {
4310 let text = "$$a$$".repeat(4000);
4315
4316 let start = std::time::Instant::now();
4317 let elements = parse_markdown_elements_inner(&text, false, false, None);
4318 let duration = start.elapsed();
4319
4320 assert!(duration.as_millis() < 100, "Parsing took too long: {duration:?}");
4321 assert_eq!(elements.len(), 4000);
4322 }
4323
4324 #[test]
4325 fn inline_math_len_at_start_matches_regex_at_slice_start() {
4326 let alphabet = ['$', 'a', ' '];
4331 let mut inputs: Vec<String> = vec![String::new()];
4332 let mut frontier: Vec<String> = vec![String::new()];
4333 for _ in 0..6 {
4334 let mut longer = Vec::new();
4335 for prefix in &frontier {
4336 for ch in alphabet {
4337 let mut s = prefix.clone();
4338 s.push(ch);
4339 longer.push(s);
4340 }
4341 }
4342 inputs.extend(longer.iter().cloned());
4343 frontier = longer;
4344 }
4345 inputs.push("$αβ$x".to_string());
4347 inputs.push("$α$$".to_string());
4348
4349 for s in &inputs {
4350 let expected = INLINE_MATH_REGEX
4351 .find(s)
4352 .ok()
4353 .flatten()
4354 .filter(|m| m.start() == 0)
4355 .map(|m| m.end());
4356 assert_eq!(inline_math_len_at_start(s), expected, "input: {s:?}");
4357 }
4358 }
4359
4360 #[test]
4361 fn inline_math_probe_after_dollar_matches_uncached_parse() {
4362 let cases = [
4368 ("$$a$$$b c$ x", r#"[DisplayMath("a"), InlineMath("b c"), Text(" x")]"#),
4369 (
4370 "$$a$$$b$ $$a$$$b$",
4371 r#"[DisplayMath("a"), InlineMath("b"), Text(" "), DisplayMath("a"), InlineMath("b")]"#,
4372 ),
4373 (
4375 "$$a$$$ x $y z$",
4376 r#"[DisplayMath("a"), InlineMath(" x "), Text("y z$")]"#,
4377 ),
4378 ("$$a$$$$ x", r#"[DisplayMath("a"), Text("$$ x")]"#),
4380 ("$$a$$$$b$$ x", r#"[DisplayMath("a"), DisplayMath("b"), Text(" x")]"#),
4381 (
4383 "$a$$b$$c$$d$ tail",
4384 r#"[Text("$a"), DisplayMath("b"), Text("c$$d$ tail")]"#,
4385 ),
4386 ];
4387 for (input, expected) in cases {
4388 let elements = parse_markdown_elements_inner(input, false, false, None);
4389 assert_eq!(format!("{elements:?}"), expected, "input: {input:?}");
4390 }
4391 }
4392
4393 #[test]
4394 fn test_emphasis_spans() {
4395 let text = "hello **word1 word2**";
4396
4397 let options_disabled = ReflowOptions {
4400 line_length: 18,
4401 emphasis_spans: false,
4402 ..Default::default()
4403 };
4404 let lines_disabled = reflow_line(text, &options_disabled);
4405 assert_eq!(lines_disabled, vec!["hello", "**word1 word2**"]);
4406
4407 let options_enabled = ReflowOptions {
4409 line_length: 18,
4410 emphasis_spans: true,
4411 ..Default::default()
4412 };
4413 let lines_enabled = reflow_line(text, &options_enabled);
4414 assert_eq!(lines_enabled, vec!["hello **word1", "word2**"]);
4415
4416 let text_italic = "hello *word1 word2*";
4418 let lines_italic = reflow_line(text_italic, &options_enabled);
4419 assert_eq!(lines_italic, vec!["hello *word1", "word2*"]);
4420
4421 let text_strike = "hello ~~word1 word2~~";
4422 let lines_strike = reflow_line(text_strike, &options_enabled);
4423 assert_eq!(lines_strike, vec!["hello ~~word1", "word2~~"]);
4424 }
4425}