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
63fn code_span_wraps_losslessly(content: &str) -> bool {
72 let mut prev_ws = false;
73 for c in content.chars() {
74 let ws = is_breakable_whitespace(c);
75 if ws && (prev_ws || c != ' ') {
76 return false;
77 }
78 prev_ws = ws;
79 }
80 true
81}
82
83fn nested_construct_ranges(content: &str) -> Vec<(usize, usize)> {
89 let mut options = Options::empty();
90 options.insert(Options::ENABLE_STRIKETHROUGH);
91
92 let mut ranges: Vec<(usize, usize)> = Vec::new();
93 for (event, range) in Parser::new_ext(content, options).into_offset_iter() {
94 let protect = matches!(
95 event,
96 Event::Code(_)
97 | Event::InlineHtml(_)
98 | Event::Start(Tag::Emphasis | Tag::Strong | Tag::Strikethrough | Tag::Link { .. } | Tag::Image { .. })
99 );
100 if protect {
101 ranges.push((range.start, range.end));
102 }
103 }
104
105 ranges.sort_unstable();
108 let mut merged: Vec<(usize, usize)> = Vec::with_capacity(ranges.len());
109 for (start, end) in ranges {
110 match merged.last_mut() {
111 Some(last) if start <= last.1 => last.1 = last.1.max(end),
112 _ => merged.push((start, end)),
113 }
114 }
115 merged
116}
117
118fn breakable_units(content: &str) -> Option<Vec<&str>> {
133 if !content.contains(['`', '*', '_', '~', '[', '<']) {
136 return Some(split_breakable_words(content).collect());
137 }
138
139 let protected = nested_construct_ranges(content);
140
141 let mut units = Vec::new();
142 let mut unit_start = None;
143 let mut next_range = 0;
144 for (offset, ch) in content.char_indices() {
145 while protected.get(next_range).is_some_and(|&(_, end)| end <= offset) {
146 next_range += 1;
147 }
148 if protected.get(next_range).is_some_and(|&(start, _)| offset >= start) {
149 if unit_start.is_none() {
152 unit_start = Some(offset);
153 }
154 continue;
155 }
156 if matches!(ch, '`' | '*' | '_' | '~') {
157 return None;
158 }
159 if is_breakable_whitespace(ch) {
160 if let Some(start) = unit_start.take() {
161 units.push(&content[start..offset]);
162 }
163 } else if unit_start.is_none() {
164 unit_start = Some(offset);
165 }
166 }
167 if let Some(start) = unit_start {
168 units.push(&content[start..]);
169 }
170 Some(units)
171}
172
173#[derive(Clone)]
175pub struct ReflowOptions {
176 pub line_length: usize,
178 pub break_on_sentences: bool,
180 pub preserve_breaks: bool,
182 pub sentence_per_line: bool,
184 pub semantic_line_breaks: bool,
186 pub abbreviations: Option<Vec<String>>,
190 pub length_mode: ReflowLengthMode,
192 pub attr_lists: bool,
195 pub myst_roles: bool,
199 pub require_sentence_capital: bool,
204 pub max_list_continuation_indent: Option<usize>,
208 pub defined_references: Option<HashSet<String>>,
222 pub atomic_spans: bool,
226}
227
228impl Default for ReflowOptions {
229 fn default() -> Self {
230 Self {
231 line_length: 80,
232 break_on_sentences: true,
233 preserve_breaks: false,
234 sentence_per_line: false,
235 semantic_line_breaks: false,
236 abbreviations: None,
237 length_mode: ReflowLengthMode::default(),
238 attr_lists: false,
239 myst_roles: false,
240 require_sentence_capital: true,
241 max_list_continuation_indent: None,
242 defined_references: None,
243 atomic_spans: true,
244 }
245 }
246}
247
248pub fn normalize_reference_label(label: &str) -> String {
255 label.split_whitespace().collect::<Vec<_>>().join(" ").to_lowercase()
256}
257
258fn footnote_refs_end(chars: &[char], start: usize) -> Option<usize> {
264 let mut pos = start;
265 let mut found = false;
266
267 loop {
268 if chars.get(pos) != Some(&'[') || chars.get(pos + 1) != Some(&'^') {
269 break;
270 }
271 let label_start = pos + 2;
272 let mut label_end = label_start;
273 while matches!(chars.get(label_end), Some(c) if c.is_ascii_alphanumeric() || *c == '_' || *c == '-') {
274 label_end += 1;
275 }
276 if label_end == label_start || chars.get(label_end) != Some(&']') {
277 break;
278 }
279 pos = label_end + 1;
280 found = true;
281 }
282
283 found.then_some(pos)
284}
285
286fn is_sentence_boundary(
290 text: &str,
291 chars: &[char],
292 pos: usize,
293 byte_offset_after_punct: usize,
294 abbreviations: &HashSet<String>,
295 require_sentence_capital: bool,
296) -> bool {
297 if pos + 1 >= chars.len() {
298 return false;
299 }
300
301 let c = chars[pos];
302 let next_char = chars[pos + 1];
303
304 if is_cjk_sentence_ending(c) {
307 let mut after_punct_pos = pos + 1;
309 while after_punct_pos < chars.len()
310 && (chars[after_punct_pos] == '*' || chars[after_punct_pos] == '_' || chars[after_punct_pos] == '~')
311 {
312 after_punct_pos += 1;
313 }
314
315 while after_punct_pos < chars.len() && chars[after_punct_pos].is_whitespace() {
317 after_punct_pos += 1;
318 }
319
320 if after_punct_pos >= chars.len() {
322 return false;
323 }
324
325 while after_punct_pos < chars.len()
327 && (chars[after_punct_pos] == '*' || chars[after_punct_pos] == '_' || chars[after_punct_pos] == '~')
328 {
329 after_punct_pos += 1;
330 }
331
332 if after_punct_pos >= chars.len() {
333 return false;
334 }
335
336 return true;
339 }
340
341 if c != '.' && c != '!' && c != '?' {
343 return false;
344 }
345
346 let (_space_pos, after_space_pos) = if next_char == ' ' {
348 (pos + 1, pos + 2)
350 } else if is_closing_quote(next_char) && pos + 2 < chars.len() {
351 if chars[pos + 2] == ' ' {
353 (pos + 2, pos + 3)
355 } else if (chars[pos + 2] == '*' || chars[pos + 2] == '_') && pos + 3 < chars.len() && chars[pos + 3] == ' ' {
356 (pos + 3, pos + 4)
358 } else if (chars[pos + 2] == '*' || chars[pos + 2] == '_')
359 && pos + 4 < chars.len()
360 && chars[pos + 3] == chars[pos + 2]
361 && chars[pos + 4] == ' '
362 {
363 (pos + 4, pos + 5)
365 } else {
366 return false;
367 }
368 } else if (next_char == '*' || next_char == '_') && pos + 2 < chars.len() && chars[pos + 2] == ' ' {
369 (pos + 2, pos + 3)
371 } else if (next_char == '*' || next_char == '_')
372 && pos + 3 < chars.len()
373 && chars[pos + 2] == next_char
374 && chars[pos + 3] == ' '
375 {
376 (pos + 3, pos + 4)
378 } else if next_char == '~' && pos + 3 < chars.len() && chars[pos + 2] == '~' && chars[pos + 3] == ' ' {
379 (pos + 3, pos + 4)
381 } else if next_char == '[' {
382 match footnote_refs_end(chars, pos + 1) {
388 Some(end_pos) if chars.get(end_pos) == Some(&' ') => (end_pos, end_pos + 1),
389 _ => return false,
390 }
391 } else {
392 return false;
393 };
394
395 let mut next_char_pos = after_space_pos;
397 while next_char_pos < chars.len() && chars[next_char_pos].is_whitespace() {
398 next_char_pos += 1;
399 }
400
401 if next_char_pos >= chars.len() {
403 return false;
404 }
405
406 let mut first_letter_pos = next_char_pos;
408 while first_letter_pos < chars.len()
409 && (chars[first_letter_pos] == '*'
410 || chars[first_letter_pos] == '_'
411 || chars[first_letter_pos] == '~'
412 || is_opening_quote(chars[first_letter_pos]))
413 {
414 first_letter_pos += 1;
415 }
416
417 if first_letter_pos >= chars.len() {
419 return false;
420 }
421
422 let first_char = chars[first_letter_pos];
423
424 if c == '!' || c == '?' {
426 return true;
427 }
428
429 if pos > 0 {
433 if text_ends_with_abbreviation(&text[..byte_offset_after_punct], abbreviations) {
435 return false;
436 }
437
438 if chars[pos - 1].is_numeric() && first_char.is_ascii_digit() {
440 return false;
441 }
442
443 if chars[pos - 1].is_ascii_uppercase() && (pos == 1 || (pos >= 2 && chars[pos - 2].is_whitespace())) {
447 return false;
448 }
449 }
450
451 if require_sentence_capital && !first_char.is_uppercase() && !is_cjk_char(first_char) {
454 return false;
455 }
456
457 true
458}
459
460pub fn split_into_sentences(text: &str) -> Vec<String> {
462 split_into_sentences_custom(text, &None)
463}
464
465pub fn split_into_sentences_custom(text: &str, custom_abbreviations: &Option<Vec<String>>) -> Vec<String> {
467 let abbreviations = get_abbreviations(custom_abbreviations);
468 split_into_sentences_with_set(text, &abbreviations, true)
469}
470
471fn split_into_sentences_with_set(
474 text: &str,
475 abbreviations: &HashSet<String>,
476 require_sentence_capital: bool,
477) -> Vec<String> {
478 let char_vec: Vec<char> = text.chars().collect();
479
480 let mut char_offsets = Vec::with_capacity(char_vec.len() + 1);
484 let mut offset = 0;
485 for c in &char_vec {
486 char_offsets.push(offset);
487 offset += c.len_utf8();
488 }
489 char_offsets.push(offset);
490
491 let code_spans = extract_code_spans(text);
493 let mut span_it = code_spans.iter().peekable();
494
495 let mut sentences = Vec::new();
496 let mut current_sentence = String::new();
497 let mut pos = 0;
498
499 while pos < char_vec.len() {
500 let c = char_vec[pos];
501 current_sentence.push(c);
502
503 let byte_idx = char_offsets[pos];
504
505 while let Some(span) = span_it.peek() {
507 if span.end <= byte_idx {
508 span_it.next();
509 } else {
510 break;
511 }
512 }
513
514 let in_code = if let Some(span) = span_it.peek() {
516 byte_idx >= span.start && byte_idx < span.end
517 } else {
518 false
519 };
520
521 if !in_code
522 && is_sentence_boundary(
523 text,
524 &char_vec,
525 pos,
526 char_offsets[pos + 1],
527 abbreviations,
528 require_sentence_capital,
529 )
530 {
531 if let Some(end_pos) = footnote_refs_end(&char_vec, pos + 1) {
533 while pos + 1 < end_pos {
534 pos += 1;
535 current_sentence.push(char_vec[pos]);
536 }
537 }
538
539 while pos + 1 < char_vec.len() {
541 let next = char_vec[pos + 1];
542 if next == '*' || next == '_' || next == '~' || is_closing_quote(next) {
543 pos += 1;
544 current_sentence.push(char_vec[pos]);
545 } else {
546 break;
547 }
548 }
549
550 if pos + 1 < char_vec.len() && char_vec[pos + 1] == ' ' {
552 pos += 1; }
554
555 sentences.push(current_sentence.trim().to_string());
556 current_sentence.clear();
557 }
558
559 pos += 1;
560 }
561
562 if !current_sentence.trim().is_empty() {
564 sentences.push(current_sentence.trim().to_string());
565 }
566 sentences
567}
568
569fn is_horizontal_rule(line: &str) -> bool {
571 if line.len() < 3 {
572 return false;
573 }
574
575 let mut chars = line.chars();
578 let Some(first_char) = chars.next() else {
579 return false;
580 };
581 if first_char != '-' && first_char != '_' && first_char != '*' {
582 return false;
583 }
584
585 let mut non_space_count = 1usize; for c in chars {
587 if c == ' ' {
588 continue;
589 }
590 if c != first_char {
591 return false;
592 }
593 non_space_count += 1;
594 }
595 non_space_count >= 3
596}
597
598fn is_numbered_list_item(line: &str) -> bool {
600 let mut chars = line.chars();
601
602 if !chars.next().is_some_and(char::is_numeric) {
604 return false;
605 }
606
607 while let Some(c) = chars.next() {
609 if c == '.' {
610 return chars.next() == Some(' ');
613 }
614 if !c.is_numeric() {
615 return false;
616 }
617 }
618
619 false
620}
621
622fn is_unordered_list_marker(s: &str) -> bool {
624 matches!(s.as_bytes().first(), Some(b'-' | b'*' | b'+'))
625 && !is_horizontal_rule(s)
626 && (s.len() == 1 || s.as_bytes().get(1) == Some(&b' '))
627}
628
629fn is_block_boundary_core(trimmed: &str) -> bool {
632 trimmed.is_empty()
633 || trimmed.starts_with('#')
634 || trimmed.starts_with("```")
635 || trimmed.starts_with("~~~")
636 || trimmed.starts_with('>')
637 || (trimmed.starts_with('[') && trimmed.contains("]:"))
638 || is_horizontal_rule(trimmed)
639 || is_unordered_list_marker(trimmed)
640 || is_numbered_list_item(trimmed)
641 || is_definition_list_item(trimmed)
642 || trimmed.starts_with(":::")
643}
644
645fn is_block_boundary(trimmed: &str) -> bool {
648 is_block_boundary_core(trimmed) || trimmed.starts_with('|')
649}
650
651fn is_paragraph_boundary(trimmed: &str, line: &str) -> bool {
655 is_block_boundary_core(trimmed)
656 || calculate_indentation_width_default(line) >= 4
657 || crate::utils::table_utils::TableUtils::is_potential_table_row(line)
658}
659
660fn has_hard_break(line: &str) -> bool {
666 let line = line.strip_suffix('\r').unwrap_or(line);
667 line.ends_with(" ") || line.ends_with('\\')
668}
669
670fn ends_with_sentence_punct(text: &str) -> bool {
672 text.ends_with('.') || text.ends_with('!') || text.ends_with('?')
673}
674
675fn trim_preserving_hard_break(s: &str) -> String {
681 let s = s.strip_suffix('\r').unwrap_or(s);
683
684 if s.ends_with('\\') {
686 return s.to_string();
688 }
689
690 if s.ends_with(" ") {
692 let content_end = s.trim_end().len();
694 if content_end == 0 {
695 return String::new();
697 }
698 format!("{} ", &s[..content_end])
700 } else {
701 s.trim_end().to_string()
703 }
704}
705
706fn parse_elements(text: &str, options: &ReflowOptions) -> Vec<Element> {
708 parse_markdown_elements_inner(
709 text,
710 options.attr_lists,
711 options.myst_roles,
712 options.defined_references.as_ref(),
713 )
714}
715
716pub fn reflow_line(line: &str, options: &ReflowOptions) -> Vec<String> {
717 if options.sentence_per_line {
719 let elements = parse_elements(line, options);
720 return merge_block_construct_continuations(reflow_elements_sentence_per_line(
721 &elements,
722 &options.abbreviations,
723 options.require_sentence_capital,
724 ));
725 }
726
727 if options.semantic_line_breaks {
729 let elements = parse_elements(line, options);
730 return merge_block_construct_continuations(reflow_elements_semantic(&elements, options));
731 }
732
733 if options.line_length == 0 || display_len(line, options.length_mode) <= options.line_length {
736 return vec![line.to_string()];
737 }
738
739 let elements = parse_elements(line, options);
741
742 merge_block_construct_continuations(reflow_elements(&elements, options))
744}
745
746#[derive(Debug, Clone)]
748enum Element {
749 Text(String),
751 Link(String),
753 ReferenceLink(String),
755 EmptyReferenceLink(String),
757 ShortcutReference(String),
759 InlineImage(String),
761 ReferenceImage(String),
763 EmptyReferenceImage(String),
765 LinkedImage(String),
767 FootnoteReference(String),
769 Strikethrough {
771 content: String,
772 double: bool,
774 },
775 WikiLink(String),
777 InlineMath(String),
779 DisplayMath(String),
781 EmojiShortcode(String),
783 Autolink(String),
785 HtmlTag(String),
787 HtmlEntity(String),
789 HugoShortcode(String),
791 AttrList(String),
793 MystRole(String),
797 Code { content: String, marker: String },
799 Bold {
801 content: String,
802 underscore: bool,
804 },
805 Italic {
807 content: String,
808 underscore: bool,
810 },
811}
812
813impl std::fmt::Display for Element {
814 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
815 match self {
816 Element::Text(s) => write!(f, "{s}"),
817 Element::Link(s) => write!(f, "{s}"),
818 Element::ReferenceLink(s) => write!(f, "{s}"),
819 Element::EmptyReferenceLink(s) => write!(f, "{s}"),
820 Element::ShortcutReference(s) => write!(f, "{s}"),
821 Element::InlineImage(s) => write!(f, "{s}"),
822 Element::ReferenceImage(s) => write!(f, "{s}"),
823 Element::EmptyReferenceImage(s) => write!(f, "{s}"),
824 Element::LinkedImage(s) => write!(f, "{s}"),
825 Element::FootnoteReference(s) => write!(f, "{s}"),
826 Element::Strikethrough { content, double } => {
827 let marker = if *double { "~~" } else { "~" };
828 write!(f, "{marker}{content}{marker}")
829 }
830 Element::WikiLink(s) => write!(f, "[[{s}]]"),
831 Element::InlineMath(s) => write!(f, "${s}$"),
832 Element::DisplayMath(s) => write!(f, "$${s}$$"),
833 Element::EmojiShortcode(s) => write!(f, ":{s}:"),
834 Element::Autolink(s) => write!(f, "{s}"),
835 Element::HtmlTag(s) => write!(f, "{s}"),
836 Element::HtmlEntity(s) => write!(f, "{s}"),
837 Element::HugoShortcode(s) => write!(f, "{s}"),
838 Element::AttrList(s) => write!(f, "{s}"),
839 Element::MystRole(s) => write!(f, "{s}"),
840 Element::Code { content, marker } => write!(f, "{marker}{content}{marker}"),
841 Element::Bold { content, underscore } => {
842 if *underscore {
843 write!(f, "__{content}__")
844 } else {
845 write!(f, "**{content}**")
846 }
847 }
848 Element::Italic { content, underscore } => {
849 if *underscore {
850 write!(f, "_{content}_")
851 } else {
852 write!(f, "*{content}*")
853 }
854 }
855 }
856 }
857}
858
859impl Element {
860 fn display_len(&self, mode: ReflowLengthMode) -> usize {
861 match self {
862 Element::Text(s)
863 | Element::Link(s)
864 | Element::ReferenceLink(s)
865 | Element::EmptyReferenceLink(s)
866 | Element::ShortcutReference(s)
867 | Element::InlineImage(s)
868 | Element::ReferenceImage(s)
869 | Element::EmptyReferenceImage(s)
870 | Element::LinkedImage(s)
871 | Element::FootnoteReference(s)
872 | Element::Autolink(s)
873 | Element::HtmlTag(s)
874 | Element::HtmlEntity(s)
875 | Element::HugoShortcode(s)
876 | Element::AttrList(s)
877 | Element::MystRole(s) => display_len(s, mode),
878 Element::WikiLink(s) => display_len(s, mode) + 4,
879 Element::InlineMath(s) => display_len(s, mode) + 2,
880 Element::DisplayMath(s) => display_len(s, mode) + 4,
881 Element::EmojiShortcode(s) => display_len(s, mode) + 2,
882 Element::Strikethrough { content, double } => display_len(content, mode) + if *double { 4 } else { 2 },
883 Element::Code { content, marker } => display_len(content, mode) + display_len(marker, mode) * 2,
884 Element::Bold { content, .. } => display_len(content, mode) + 4,
885 Element::Italic { content, .. } => display_len(content, mode) + 2,
886 }
887 }
888}
889
890#[derive(Debug, Clone)]
892struct EmphasisSpan {
893 start: usize,
895 end: usize,
897 content: String,
899 is_strong: bool,
901 is_strikethrough: bool,
903 uses_underscore: bool,
905 strikethrough_double: bool,
908}
909
910fn extract_emphasis_and_code_spans(text: &str) -> (Vec<EmphasisSpan>, Vec<CodeSpan>) {
920 let has_emphasis = text.contains(['*', '_', '~']);
922 let has_code = text.contains('`');
923 if !has_emphasis && !has_code {
924 return (Vec::new(), Vec::new());
925 }
926
927 let mut emphasis_spans = Vec::new();
928 let mut code_spans = Vec::new();
929
930 let mut options = Options::empty();
931 if has_emphasis {
932 options.insert(Options::ENABLE_STRIKETHROUGH);
933 }
934
935 let mut emphasis_stack: Vec<(usize, bool)> = Vec::new(); let mut strong_stack: Vec<(usize, bool)> = Vec::new();
938 let mut strikethrough_stack: Vec<usize> = Vec::new();
939
940 let parser = Parser::new_ext(text, options).into_offset_iter();
941
942 for (event, range) in parser {
943 match event {
944 Event::Code(_) => {
945 code_spans.push(CodeSpan {
946 start: range.start,
947 end: range.end,
948 });
949 }
950 Event::Start(Tag::Emphasis) => {
951 let uses_underscore = text.get(range.start..range.start + 1) == Some("_");
953 emphasis_stack.push((range.start, uses_underscore));
954 }
955 Event::End(TagEnd::Emphasis) => {
956 if let Some((start_byte, uses_underscore)) = emphasis_stack.pop() {
957 let content_start = start_byte + 1;
958 let content_end = range.end - 1;
959 if content_end > content_start
960 && let Some(content) = text.get(content_start..content_end)
961 {
962 emphasis_spans.push(EmphasisSpan {
963 start: start_byte,
964 end: range.end,
965 content: content.to_string(),
966 is_strong: false,
967 is_strikethrough: false,
968 uses_underscore,
969 strikethrough_double: false,
970 });
971 }
972 }
973 }
974 Event::Start(Tag::Strong) => {
975 let uses_underscore = text.get(range.start..range.start + 2) == Some("__");
976 strong_stack.push((range.start, uses_underscore));
977 }
978 Event::End(TagEnd::Strong) => {
979 if let Some((start_byte, uses_underscore)) = strong_stack.pop() {
980 let content_start = start_byte + 2;
981 let content_end = range.end - 2;
982 if content_end > content_start
983 && let Some(content) = text.get(content_start..content_end)
984 {
985 emphasis_spans.push(EmphasisSpan {
986 start: start_byte,
987 end: range.end,
988 content: content.to_string(),
989 is_strong: true,
990 is_strikethrough: false,
991 uses_underscore,
992 strikethrough_double: false,
993 });
994 }
995 }
996 }
997 Event::Start(Tag::Strikethrough) => {
998 strikethrough_stack.push(range.start);
999 }
1000 Event::End(TagEnd::Strikethrough) => {
1001 if let Some(start_byte) = strikethrough_stack.pop() {
1002 let double = text.get(start_byte..start_byte + 2) == Some("~~");
1003 let marker_len = if double { 2 } else { 1 };
1004 let content_start = start_byte + marker_len;
1005 let content_end = range.end - marker_len;
1006 if content_end > content_start
1007 && let Some(content) = text.get(content_start..content_end)
1008 {
1009 emphasis_spans.push(EmphasisSpan {
1010 start: start_byte,
1011 end: range.end,
1012 content: content.to_string(),
1013 is_strong: false,
1014 is_strikethrough: true,
1015 uses_underscore: false,
1016 strikethrough_double: double,
1017 });
1018 }
1019 }
1020 }
1021 _ => {}
1022 }
1023 }
1024
1025 emphasis_spans.sort_by_key(|s| s.start);
1026 (emphasis_spans, code_spans)
1027}
1028
1029#[derive(Debug, Clone)]
1030struct CodeSpan {
1031 start: usize,
1032 end: usize,
1033}
1034
1035fn extract_code_spans(text: &str) -> Vec<CodeSpan> {
1036 if !text.contains('`') {
1038 return Vec::new();
1039 }
1040
1041 let mut spans = Vec::new();
1042 let parser = Parser::new(text).into_offset_iter();
1043 for (event, range) in parser {
1044 if let Event::Code(_) = event {
1045 spans.push(CodeSpan {
1046 start: range.start,
1047 end: range.end,
1048 });
1049 }
1050 }
1051 spans
1052}
1053
1054#[derive(Debug, Clone)]
1055struct LinkSpan {
1056 start: usize,
1057 end: usize,
1058 link_type: Option<LinkType>,
1059 is_image: bool,
1060 is_footnote: bool,
1061}
1062
1063fn extract_link_spans(text: &str, defined_references: Option<&HashSet<String>>) -> Vec<LinkSpan> {
1064 if !text.contains('[') {
1067 return Vec::new();
1068 }
1069
1070 let mut spans = Vec::new();
1071 let mut options = Options::empty();
1072 options.insert(Options::ENABLE_FOOTNOTES);
1073
1074 let resolve = move |link: BrokenLink<'_>| -> Option<(CowStr<'_>, CowStr<'_>)> {
1091 let atomic = match link.link_type {
1096 LinkType::Shortcut | LinkType::ShortcutUnknown => match defined_references {
1097 Some(defs) => defs.contains(&normalize_reference_label(link.reference.as_ref())),
1098 None => true,
1099 },
1100 _ => true,
1101 };
1102 atomic.then_some((CowStr::Borrowed(""), CowStr::Borrowed("")))
1103 };
1104 let parser = Parser::new_with_broken_link_callback(text, options, Some(resolve)).into_offset_iter();
1105 let mut stack = Vec::new();
1106
1107 for (event, range) in parser {
1108 match event {
1109 Event::Start(Tag::Link { link_type, .. }) => {
1110 stack.push((range.start, Some(link_type), false));
1111 }
1112 Event::Start(Tag::Image { link_type, .. }) => {
1113 stack.push((range.start, Some(link_type), true));
1114 }
1115 Event::End(TagEnd::Link) => {
1116 if let Some((start_byte, link_type, is_image)) = stack.pop()
1117 && stack.is_empty()
1118 {
1119 spans.push(LinkSpan {
1120 start: start_byte,
1121 end: range.end,
1122 link_type,
1123 is_image,
1124 is_footnote: false,
1125 });
1126 }
1127 }
1128 Event::End(TagEnd::Image) => {
1129 if let Some((start_byte, link_type, is_image)) = stack.pop()
1130 && stack.is_empty()
1131 {
1132 spans.push(LinkSpan {
1133 start: start_byte,
1134 end: range.end,
1135 link_type,
1136 is_image,
1137 is_footnote: false,
1138 });
1139 }
1140 }
1141 Event::FootnoteReference(_) if stack.is_empty() => {
1142 spans.push(LinkSpan {
1143 start: range.start,
1144 end: range.end,
1145 link_type: None,
1146 is_image: false,
1147 is_footnote: true,
1148 });
1149 }
1150 _ => {}
1151 }
1152 }
1153
1154 spans.sort_by_key(|s| s.start);
1155 spans
1156}
1157
1158fn myst_role_len_at(text: &str, absolute_pos: usize, code_spans: &[CodeSpan]) -> Option<usize> {
1166 let bytes = text.as_bytes();
1167 if bytes.first() != Some(&b'{') {
1168 return None;
1169 }
1170
1171 let mut j = 1;
1173 match bytes.get(j) {
1174 Some(&b) if b.is_ascii_alphabetic() || b == b'_' => {}
1175 _ => return None,
1176 }
1177 while let Some(&b) = bytes.get(j) {
1178 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b':' | b'.') {
1179 j += 1;
1180 } else {
1181 break;
1182 }
1183 }
1184 if bytes.get(j) != Some(&b'}') {
1185 return None;
1186 }
1187 j += 1; let code_span_start = absolute_pos + j;
1191 if let Ok(idx) = code_spans.binary_search_by_key(&code_span_start, |span| span.start) {
1192 let span = &code_spans[idx];
1193 let code_span_len = span.end - span.start;
1194 return Some(j + code_span_len);
1195 }
1196
1197 None
1198}
1199
1200fn inline_math_len_at_start(s: &str) -> Option<usize> {
1207 let bytes = s.as_bytes();
1208 if bytes.first() != Some(&b'$') || bytes.get(1) == Some(&b'$') {
1210 return None;
1211 }
1212 let close = 1 + s[1..].find('$')?;
1215 if bytes.get(close + 1) == Some(&b'$') {
1217 return None;
1218 }
1219 Some(close + 1)
1220}
1221
1222#[derive(Clone, Copy, Debug)]
1224struct PatternMatch {
1225 start: usize,
1226 end: usize,
1227}
1228
1229#[derive(Clone, Copy)]
1243enum PatternCache {
1244 Unsearched,
1245 NotFound,
1246 Found(PatternMatch),
1247}
1248
1249impl PatternCache {
1250 fn earliest_in(
1254 &mut self,
1255 remaining: &str,
1256 cursor: usize,
1257 find: impl FnOnce(&str) -> Option<(usize, usize)>,
1258 ) -> Option<(usize, usize)> {
1259 let stale = match self {
1260 PatternCache::Found(pm) => pm.start < cursor,
1261 PatternCache::NotFound => false,
1262 PatternCache::Unsearched => true,
1263 };
1264 if stale {
1265 *self = match find(remaining) {
1266 Some((start, end)) => PatternCache::Found(PatternMatch {
1267 start: cursor + start,
1268 end: cursor + end,
1269 }),
1270 None => PatternCache::NotFound,
1271 };
1272 }
1273 match self {
1274 PatternCache::Found(pm) => Some((pm.start - cursor, pm.end - cursor)),
1275 _ => None,
1276 }
1277 }
1278}
1279
1280fn parse_markdown_elements_inner(
1291 text: &str,
1292 attr_lists: bool,
1293 myst_roles: bool,
1294 defined_references: Option<&HashSet<String>>,
1295) -> Vec<Element> {
1296 let mut elements = Vec::new();
1297 let mut remaining = text;
1298
1299 let (emphasis_spans, code_spans) = extract_emphasis_and_code_spans(text);
1304 let link_spans = extract_link_spans(text, defined_references);
1305
1306 let mut cached_wiki_link = PatternCache::Unsearched;
1309 let mut cached_display_math = PatternCache::Unsearched;
1310 let mut cached_inline_math = PatternCache::Unsearched;
1311 let mut cached_emoji = PatternCache::Unsearched;
1312 let mut cached_html_entity = PatternCache::Unsearched;
1313 let mut cached_hugo_shortcode = PatternCache::Unsearched;
1314 let mut cached_html_tag = PatternCache::Unsearched;
1315 let mut cached_next_curly = PatternCache::Unsearched;
1316
1317 let mut link_span_idx = 0usize;
1321 let mut emphasis_span_idx = 0usize;
1322 let mut code_span_idx = 0usize;
1323
1324 while !remaining.is_empty() {
1325 let current_offset = text.len() - remaining.len();
1327 let mut earliest_match: Option<(usize, usize, &str)> = None;
1330
1331 while link_span_idx < link_spans.len() && link_spans[link_span_idx].start < current_offset {
1333 link_span_idx += 1;
1334 }
1335 let next_link: Option<&LinkSpan> = link_spans.get(link_span_idx);
1336
1337 if let Some(span) = next_link {
1338 let pos_in_remaining = span.start - current_offset;
1339 if earliest_match
1340 .as_ref()
1341 .is_none_or(|(start, _, _)| pos_in_remaining < *start)
1342 {
1343 let match_end = span.end - current_offset;
1344 earliest_match = Some((pos_in_remaining, match_end, "link_span"));
1345 }
1346 }
1347
1348 if let Some((start, end)) = cached_wiki_link.earliest_in(remaining, current_offset, |suffix| {
1350 WIKI_LINK_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1351 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1352 {
1353 earliest_match = Some((start, end, "wiki_link"));
1354 }
1355
1356 if let Some((start, end)) = cached_display_math.earliest_in(remaining, current_offset, |suffix| {
1358 DISPLAY_MATH_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1359 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1360 {
1361 earliest_match = Some((start, end, "display_math"));
1362 }
1363
1364 let inline_math_probe = if current_offset > 0 && text.as_bytes()[current_offset - 1] == b'$' {
1378 inline_math_len_at_start(remaining).map(|len| (0, len))
1379 } else {
1380 None
1381 };
1382 if let Some((start, end)) = inline_math_probe.or_else(|| {
1383 cached_inline_math.earliest_in(remaining, current_offset, |suffix| {
1384 INLINE_MATH_REGEX
1385 .find(suffix)
1386 .ok()
1387 .flatten()
1388 .map(|m| (m.start(), m.end()))
1389 })
1390 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1391 {
1392 earliest_match = Some((start, end, "inline_math"));
1393 }
1394
1395 if let Some((start, end)) = cached_emoji.earliest_in(remaining, current_offset, |suffix| {
1397 EMOJI_SHORTCODE_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1398 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1399 {
1400 earliest_match = Some((start, end, "emoji"));
1401 }
1402
1403 if let Some((start, end)) = cached_html_entity.earliest_in(remaining, current_offset, |suffix| {
1405 HTML_ENTITY_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1406 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1407 {
1408 earliest_match = Some((start, end, "html_entity"));
1409 }
1410
1411 if let Some((start, end)) = cached_hugo_shortcode.earliest_in(remaining, current_offset, |suffix| {
1414 HUGO_SHORTCODE_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1415 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1416 {
1417 earliest_match = Some((start, end, "hugo_shortcode"));
1418 }
1419
1420 if let Some((start, end)) = cached_html_tag.earliest_in(remaining, current_offset, |suffix| {
1427 let mut from = 0;
1428 while let Some(m) = HTML_TAG_PATTERN.find(&suffix[from..]) {
1429 let (tag_start, tag_end) = (from + m.start(), from + m.end());
1430 let tag = &suffix[tag_start..tag_end];
1431 let is_url_autolink = tag.starts_with("<http://")
1433 || tag.starts_with("<https://")
1434 || tag.starts_with("<mailto:")
1435 || tag.starts_with("<ftp://")
1436 || tag.starts_with("<ftps://");
1437 let is_email_autolink = {
1440 let content = tag.trim_start_matches('<').trim_end_matches('>');
1441 EMAIL_PATTERN.is_match(content)
1442 };
1443 if is_url_autolink || is_email_autolink {
1444 from = tag_end;
1445 } else {
1446 return Some((tag_start, tag_end));
1447 }
1448 }
1449 None
1450 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1451 {
1452 earliest_match = Some((start, end, "html_tag"));
1453 }
1454
1455 let mut next_special = remaining.len();
1457 let mut special_type = "";
1458 let mut pulldown_emphasis: Option<&EmphasisSpan> = None;
1459 let mut attr_list_len: usize = 0;
1460 let mut myst_role_len: usize = 0;
1461
1462 while code_span_idx < code_spans.len() && code_spans[code_span_idx].start < current_offset {
1464 code_span_idx += 1;
1465 }
1466 let next_code_span: Option<&CodeSpan> = code_spans.get(code_span_idx);
1467 if let Some(span) = next_code_span {
1468 let pos_in_remaining = span.start - current_offset;
1469 if pos_in_remaining < next_special {
1470 next_special = pos_in_remaining;
1471 special_type = "pulldown_code";
1472 }
1473 }
1474
1475 let next_curly_pos = cached_next_curly
1478 .earliest_in(remaining, current_offset, |suffix| {
1479 suffix.find('{').map(|pos| (pos, pos + 1))
1480 })
1481 .map(|(start, _)| start);
1482
1483 if myst_roles
1488 && let Some(pos) = next_curly_pos
1489 && pos < next_special
1490 && let Some(role_len) = myst_role_len_at(&remaining[pos..], current_offset + pos, &code_spans)
1491 {
1492 next_special = pos;
1493 special_type = "myst_role";
1494 myst_role_len = role_len;
1495 }
1496
1497 if attr_lists
1499 && let Some(pos) = next_curly_pos
1500 && pos < next_special
1501 && let Some(m) = ATTR_LIST_PATTERN.find(&remaining[pos..])
1502 && m.start() == 0
1503 {
1504 next_special = pos;
1505 special_type = "attr_list";
1506 attr_list_len = m.end();
1507 }
1508
1509 while emphasis_span_idx < emphasis_spans.len() && emphasis_spans[emphasis_span_idx].start < current_offset {
1511 emphasis_span_idx += 1;
1512 }
1513 if let Some(span) = emphasis_spans.get(emphasis_span_idx) {
1514 let pos_in_remaining = span.start - current_offset;
1515 if pos_in_remaining < next_special {
1516 next_special = pos_in_remaining;
1517 special_type = "pulldown_emphasis";
1518 pulldown_emphasis = Some(span);
1519 }
1520 }
1521
1522 let should_process_markdown_link = if let Some((pos, _, _)) = earliest_match {
1524 pos < next_special
1525 } else {
1526 false
1527 };
1528
1529 if should_process_markdown_link {
1530 let (pos, match_end, pattern_type) = earliest_match.unwrap();
1531
1532 if pos > 0 {
1534 elements.push(Element::Text(remaining[..pos].to_string()));
1535 }
1536
1537 match pattern_type {
1539 "link_span" => {
1540 let span = next_link.unwrap();
1541 let raw_text = remaining[pos..match_end].to_string();
1542 if span.is_footnote {
1543 elements.push(Element::FootnoteReference(raw_text));
1544 } else if span.is_image {
1545 match span.link_type {
1546 Some(LinkType::Inline) => elements.push(Element::InlineImage(raw_text)),
1547 Some(LinkType::Reference)
1550 | Some(LinkType::ReferenceUnknown)
1551 | Some(LinkType::Shortcut)
1552 | Some(LinkType::ShortcutUnknown) => elements.push(Element::ReferenceImage(raw_text)),
1553 Some(LinkType::Collapsed) | Some(LinkType::CollapsedUnknown) => {
1554 elements.push(Element::EmptyReferenceImage(raw_text))
1555 }
1556 _ => elements.push(Element::InlineImage(raw_text)),
1557 }
1558 } else {
1559 match span.link_type {
1560 Some(LinkType::Inline) => {
1561 if raw_text.starts_with('[') && raw_text.contains("![") {
1562 elements.push(Element::LinkedImage(raw_text));
1563 } else {
1564 elements.push(Element::Link(raw_text));
1565 }
1566 }
1567 Some(LinkType::Reference) | Some(LinkType::ReferenceUnknown) => {
1570 elements.push(Element::ReferenceLink(raw_text))
1571 }
1572 Some(LinkType::Collapsed) | Some(LinkType::CollapsedUnknown) => {
1573 elements.push(Element::EmptyReferenceLink(raw_text))
1574 }
1575 Some(LinkType::Shortcut) | Some(LinkType::ShortcutUnknown) => {
1576 elements.push(Element::ShortcutReference(raw_text))
1577 }
1578 Some(LinkType::Autolink) | Some(LinkType::Email) => {
1579 elements.push(Element::Autolink(raw_text))
1580 }
1581 _ => elements.push(Element::Link(raw_text)),
1582 }
1583 }
1584 remaining = &remaining[match_end..];
1585 }
1586 "wiki_link" => {
1587 if let Some(caps) = WIKI_LINK_REGEX.captures(remaining) {
1588 let content = caps.get(1).map_or("", |m| m.as_str());
1589 elements.push(Element::WikiLink(content.to_string()));
1590 remaining = &remaining[match_end..];
1591 } else {
1592 elements.push(Element::Text("[[".to_string()));
1593 remaining = &remaining[2..];
1594 }
1595 }
1596 "display_math" => {
1597 if let Some(caps) = DISPLAY_MATH_REGEX.captures(remaining) {
1598 let math = caps.get(1).map_or("", |m| m.as_str());
1599 elements.push(Element::DisplayMath(math.to_string()));
1600 remaining = &remaining[match_end..];
1601 } else {
1602 elements.push(Element::Text("$$".to_string()));
1603 remaining = &remaining[2..];
1604 }
1605 }
1606 "inline_math" => {
1607 if let Ok(Some(caps)) = INLINE_MATH_REGEX.captures(remaining) {
1608 let math = caps.get(1).map_or("", |m| m.as_str());
1609 elements.push(Element::InlineMath(math.to_string()));
1610 remaining = &remaining[match_end..];
1611 } else {
1612 elements.push(Element::Text("$".to_string()));
1613 remaining = &remaining[1..];
1614 }
1615 }
1616 "emoji" => {
1617 if let Some(caps) = EMOJI_SHORTCODE_REGEX.captures(remaining) {
1618 let emoji = caps.get(1).map_or("", |m| m.as_str());
1619 elements.push(Element::EmojiShortcode(emoji.to_string()));
1620 remaining = &remaining[match_end..];
1621 } else {
1622 elements.push(Element::Text(":".to_string()));
1623 remaining = &remaining[1..];
1624 }
1625 }
1626 "html_entity" => {
1627 elements.push(Element::HtmlEntity(remaining[pos..match_end].to_string()));
1629 remaining = &remaining[match_end..];
1630 }
1631 "hugo_shortcode" => {
1632 elements.push(Element::HugoShortcode(remaining[pos..match_end].to_string()));
1634 remaining = &remaining[match_end..];
1635 }
1636 "html_tag" => {
1637 elements.push(Element::HtmlTag(remaining[pos..match_end].to_string()));
1639 remaining = &remaining[match_end..];
1640 }
1641 _ => unreachable!("unknown pattern type: {}", pattern_type),
1642 }
1643 } else {
1644 if next_special > 0 && next_special < remaining.len() {
1648 elements.push(Element::Text(remaining[..next_special].to_string()));
1649 remaining = &remaining[next_special..];
1650 }
1651
1652 match special_type {
1654 "pulldown_code" => {
1655 let span = next_code_span.unwrap();
1656 let span_len = span.end - span.start;
1657 let code_raw = &remaining[..span_len];
1658 if let Some((content, marker)) = decompose_code_span(code_raw) {
1659 elements.push(Element::Code {
1660 content: content.to_string(),
1661 marker: marker.to_string(),
1662 });
1663 } else {
1664 elements.push(Element::Text(code_raw.to_string()));
1665 }
1666 remaining = &remaining[span_len..];
1667 }
1668 "attr_list" => {
1669 elements.push(Element::AttrList(remaining[..attr_list_len].to_string()));
1670 remaining = &remaining[attr_list_len..];
1671 }
1672 "myst_role" => {
1673 elements.push(Element::MystRole(remaining[..myst_role_len].to_string()));
1674 remaining = &remaining[myst_role_len..];
1675 }
1676 "pulldown_emphasis" => {
1677 let span = pulldown_emphasis.expect("pulldown_emphasis must be set");
1679 let span_len = span.end - span.start;
1680 if span.is_strikethrough {
1681 elements.push(Element::Strikethrough {
1682 content: span.content.clone(),
1683 double: span.strikethrough_double,
1684 });
1685 } else if span.is_strong {
1686 elements.push(Element::Bold {
1687 content: span.content.clone(),
1688 underscore: span.uses_underscore,
1689 });
1690 } else {
1691 elements.push(Element::Italic {
1692 content: span.content.clone(),
1693 underscore: span.uses_underscore,
1694 });
1695 }
1696 remaining = &remaining[span_len..];
1697 }
1698 _ => {
1699 elements.push(Element::Text(remaining.to_string()));
1701 break;
1702 }
1703 }
1704 }
1705 }
1706
1707 let mut merged_elements = Vec::new();
1709 for el in elements {
1710 match el {
1711 Element::Text(s) => {
1712 if let Some(Element::Text(last_s)) = merged_elements.last_mut() {
1713 last_s.push_str(&s);
1714 } else {
1715 merged_elements.push(Element::Text(s));
1716 }
1717 }
1718 other => merged_elements.push(other),
1719 }
1720 }
1721 merged_elements
1722}
1723
1724fn should_insert_space_before_join(current: &str) -> bool {
1725 !current.is_empty()
1726 && !current.ends_with(' ')
1727 && !current.ends_with('(')
1728 && !current.ends_with('[')
1729 && !current.ends_with('-')
1730}
1731
1732fn is_setext_or_thematic(text: &str) -> bool {
1738 let mut marker = 0u8;
1739 let mut count = 0usize;
1740 let mut has_space = false;
1741 for &b in text.as_bytes() {
1742 match b {
1743 b' ' | b'\t' => has_space = true,
1744 b'-' | b'=' | b'*' | b'_' => {
1745 if marker == 0 {
1746 marker = b;
1747 } else if b != marker {
1748 return false;
1749 }
1750 count += 1;
1751 }
1752 _ => return false,
1753 }
1754 }
1755 match marker {
1756 b'=' => !has_space,
1757 b'-' => !has_space || count >= 3,
1758 b'*' | b'_' => count >= 3,
1759 _ => false,
1760 }
1761}
1762
1763fn starts_block_construct(text: &str) -> bool {
1775 let text = text.trim_start();
1776 let bytes = text.as_bytes();
1777 let Some(&first) = bytes.first() else {
1778 return false;
1779 };
1780 let marker_then_boundary = |len: usize| bytes.len() == len || bytes[len] == b' ' || bytes[len] == b'\t';
1781 match first {
1782 b'>' => true,
1784 b'-' | b'*' | b'+' => marker_then_boundary(1) || is_setext_or_thematic(text),
1785 b'_' | b'=' => is_setext_or_thematic(text),
1786 b':' => is_definition_list_item(text) || text.starts_with(":::"),
1787 b'|' => true,
1788 b'#' => {
1789 let hashes = bytes.iter().take_while(|&&b| b == b'#').count();
1790 hashes <= 6 && marker_then_boundary(hashes)
1791 }
1792 b'`' => bytes.iter().take_while(|&&b| b == b'`').count() >= 3,
1793 b'~' => bytes.iter().take_while(|&&b| b == b'~').count() >= 3,
1794 b'0'..=b'9' => {
1795 let digits = bytes.iter().take_while(|b| b.is_ascii_digit()).count();
1796 digits <= 9
1797 && bytes.len() > digits
1798 && (bytes[digits] == b'.' || bytes[digits] == b')')
1799 && marker_then_boundary(digits + 1)
1800 }
1801 b'[' => {
1809 let mut escaped = false;
1810 let mut label_close = None;
1811 for (i, &b) in bytes.iter().enumerate().skip(1) {
1812 if escaped {
1813 escaped = false;
1814 } else if b == b'\\' {
1815 escaped = true;
1816 } else if b == b']' {
1817 label_close = Some(i);
1818 break;
1819 }
1820 }
1821 label_close.is_some_and(|i| bytes.get(i + 1) == Some(&b':'))
1822 }
1823 b'<' => crate::utils::html_block::parse_html_block_start(text).is_some(),
1826 _ => false,
1827 }
1828}
1829
1830fn merge_block_construct_continuations(lines: Vec<String>) -> Vec<String> {
1839 let mut merged: Vec<String> = Vec::with_capacity(lines.len());
1840 for line in lines {
1841 match merged.last_mut() {
1842 Some(prev) if starts_block_construct(&line) => {
1843 prev.push(' ');
1844 prev.push_str(line.trim_start());
1845 }
1846 _ => merged.push(line),
1847 }
1848 }
1849 merged
1850}
1851
1852fn reflow_elements_sentence_per_line(
1854 elements: &[Element],
1855 custom_abbreviations: &Option<Vec<String>>,
1856 require_sentence_capital: bool,
1857) -> Vec<String> {
1858 let abbreviations = get_abbreviations(custom_abbreviations);
1859 let mut lines = Vec::new();
1860 let mut current_line = String::new();
1861
1862 for (idx, element) in elements.iter().enumerate() {
1863 if let Element::Text(text) = element {
1865 let combined = format!("{current_line}{text}");
1867 let sentences = split_into_sentences_with_set(&combined, &abbreviations, require_sentence_capital);
1869
1870 if sentences.len() > 1 {
1871 for (i, sentence) in sentences.iter().enumerate() {
1873 if i == 0 {
1874 let trimmed = sentence.trim();
1877
1878 if text_ends_with_abbreviation(trimmed, &abbreviations) {
1879 current_line.clone_from(sentence);
1881 } else {
1882 lines.push(sentence.clone());
1884 current_line.clear();
1885 }
1886 } else if i == sentences.len() - 1 {
1887 let trimmed = sentence.trim();
1889 let ends_with_sentence_punct = ends_with_sentence_punct(trimmed);
1890
1891 if ends_with_sentence_punct && !text_ends_with_abbreviation(trimmed, &abbreviations) {
1892 lines.push(sentence.clone());
1894 current_line.clear();
1895 } else {
1896 current_line.clone_from(sentence);
1898 }
1899 } else {
1900 lines.push(sentence.clone());
1902 }
1903 }
1904 } else {
1905 let trimmed = combined.trim();
1907
1908 if trimmed.is_empty() {
1912 continue;
1913 }
1914
1915 let ends_with_sentence_punct = ends_with_sentence_punct(trimmed);
1916
1917 if ends_with_sentence_punct && !text_ends_with_abbreviation(trimmed, &abbreviations) {
1918 lines.push(combined.trim_matches(is_breakable_whitespace).to_string());
1921 current_line.clear();
1922 } else {
1923 current_line = combined;
1925 }
1926 }
1927 } else if let Element::Italic { content, underscore } = element {
1928 let marker = if *underscore { "_" } else { "*" };
1930 handle_emphasis_sentence_split(
1931 content,
1932 marker,
1933 &abbreviations,
1934 require_sentence_capital,
1935 &mut current_line,
1936 &mut lines,
1937 );
1938 } else if let Element::Bold { content, underscore } = element {
1939 let marker = if *underscore { "__" } else { "**" };
1941 handle_emphasis_sentence_split(
1942 content,
1943 marker,
1944 &abbreviations,
1945 require_sentence_capital,
1946 &mut current_line,
1947 &mut lines,
1948 );
1949 } else if let Element::Strikethrough { content, double } = element {
1950 handle_emphasis_sentence_split(
1952 content,
1953 if *double { "~~" } else { "~" },
1954 &abbreviations,
1955 require_sentence_capital,
1956 &mut current_line,
1957 &mut lines,
1958 );
1959 } else {
1960 let element_str = format!("{element}");
1962 let is_adjacent = if idx > 0 {
1966 match &elements[idx - 1] {
1967 Element::Text(t) => !t.is_empty() && !t.ends_with(is_breakable_whitespace),
1968 _ => true,
1969 }
1970 } else {
1971 false
1972 };
1973
1974 if !is_adjacent && should_insert_space_before_join(¤t_line) {
1976 current_line.push(' ');
1977 }
1978 current_line.push_str(&element_str);
1979 }
1980 }
1981
1982 if !current_line.is_empty() {
1984 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
1985 }
1986 lines
1987}
1988
1989fn handle_emphasis_sentence_split(
1991 content: &str,
1992 marker: &str,
1993 abbreviations: &HashSet<String>,
1994 require_sentence_capital: bool,
1995 current_line: &mut String,
1996 lines: &mut Vec<String>,
1997) {
1998 let sentences = split_into_sentences_with_set(content, abbreviations, require_sentence_capital);
2000
2001 if sentences.len() <= 1 {
2002 if should_insert_space_before_join(current_line) {
2004 current_line.push(' ');
2005 }
2006 current_line.push_str(marker);
2007 current_line.push_str(content);
2008 current_line.push_str(marker);
2009
2010 let trimmed = content.trim();
2012 let ends_with_punct = ends_with_sentence_punct(trimmed);
2013 if ends_with_punct && !text_ends_with_abbreviation(trimmed, abbreviations) {
2014 lines.push(current_line.clone());
2015 current_line.clear();
2016 }
2017 } else {
2018 for (i, sentence) in sentences.iter().enumerate() {
2020 let trimmed = sentence.trim();
2021 if trimmed.is_empty() {
2022 continue;
2023 }
2024
2025 if i == 0 {
2026 if should_insert_space_before_join(current_line) {
2028 current_line.push(' ');
2029 }
2030 current_line.push_str(marker);
2031 current_line.push_str(trimmed);
2032 current_line.push_str(marker);
2033
2034 let ends_with_punct = ends_with_sentence_punct(trimmed);
2036 if ends_with_punct && !text_ends_with_abbreviation(trimmed, abbreviations) {
2037 lines.push(current_line.clone());
2038 current_line.clear();
2039 }
2040 } else if i == sentences.len() - 1 {
2041 let ends_with_punct = ends_with_sentence_punct(trimmed);
2043
2044 let mut line = String::new();
2045 line.push_str(marker);
2046 line.push_str(trimmed);
2047 line.push_str(marker);
2048
2049 if ends_with_punct && !text_ends_with_abbreviation(trimmed, abbreviations) {
2050 lines.push(line);
2051 } else {
2052 *current_line = line;
2054 }
2055 } else {
2056 let mut line = String::new();
2058 line.push_str(marker);
2059 line.push_str(trimmed);
2060 line.push_str(marker);
2061 lines.push(line);
2062 }
2063 }
2064 }
2065}
2066
2067const BREAK_WORDS: &[&str] = &[
2071 "and",
2072 "or",
2073 "but",
2074 "nor",
2075 "yet",
2076 "so",
2077 "for",
2078 "which",
2079 "that",
2080 "because",
2081 "when",
2082 "if",
2083 "while",
2084 "where",
2085 "although",
2086 "though",
2087 "unless",
2088 "since",
2089 "after",
2090 "before",
2091 "until",
2092 "as",
2093 "once",
2094 "whether",
2095 "however",
2096 "therefore",
2097 "moreover",
2098 "furthermore",
2099 "nevertheless",
2100 "whereas",
2101];
2102
2103fn is_clause_punctuation(c: char) -> bool {
2105 matches!(c, ',' | ';' | ':' | '\u{2014}') }
2107
2108fn clause_break_allowed_after(chars: &[char], i: usize) -> bool {
2116 if chars[i] == '\u{2014}' {
2117 return true;
2118 }
2119 match chars.get(i + 1) {
2120 None => true,
2121 Some(next) => next.is_whitespace(),
2122 }
2123}
2124
2125fn paren_group_end<'a>(slice: &'a str, element_spans: &[(usize, usize)], offset: usize) -> Option<(usize, &'a str)> {
2139 debug_assert!(slice.starts_with('('));
2140 let mut depth: i32 = 0;
2141 for (local_byte, c) in slice.char_indices() {
2142 let global_byte = offset + local_byte;
2143 if depth > 0 && is_inside_element(global_byte, element_spans) {
2148 continue;
2149 }
2150 match c {
2151 '(' => depth += 1,
2152 ')' => {
2153 depth -= 1;
2154 if depth == 0 {
2155 let end = local_byte + 1;
2156 let inner = &slice[1..local_byte];
2157 return Some((end, inner));
2158 }
2159 }
2160 _ => {}
2161 }
2162 }
2163 None
2164}
2165
2166fn split_at_parenthetical(
2183 text: &str,
2184 line_length: usize,
2185 element_spans: &[(usize, usize)],
2186 length_mode: ReflowLengthMode,
2187) -> Option<(String, String)> {
2188 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
2189
2190 if text.starts_with('(')
2192 && let Some((end_local, inner)) = paren_group_end(text, element_spans, 0)
2193 && inner.contains(' ')
2194 {
2195 let tail = &text[end_local..];
2199 let attached_len = tail
2200 .char_indices()
2201 .take_while(|(_, c)| is_closing_quote(*c) || is_clause_punctuation(*c))
2202 .last()
2203 .map_or(0, |(idx, c)| idx + c.len_utf8());
2204 let first_end = end_local + attached_len;
2205 let rest_start = first_end;
2206 let first = &text[..first_end];
2207 let first_len = display_len(first, length_mode);
2208 if first_len <= line_length {
2211 let rest = text[rest_start..].trim_start();
2212 if !rest.is_empty() {
2213 return Some((first.to_string(), rest.to_string()));
2214 }
2215 }
2216 }
2217
2218 let mut best_open_byte: Option<usize> = None;
2220 let mut pos = 0usize;
2221 while pos < text.len() {
2222 if text.as_bytes()[pos] != b'(' {
2224 let c = text[pos..].chars().next().unwrap();
2225 pos += c.len_utf8();
2226 continue;
2227 }
2228 if is_inside_element(pos, element_spans) {
2230 pos += 1;
2231 continue;
2232 }
2233 if let Some((end_local, inner)) = paren_group_end(&text[pos..], element_spans, pos) {
2234 let first = text[..pos].trim_end();
2235 let first_len = display_len(first, length_mode);
2236 if !first.is_empty()
2237 && first_len >= min_first_len
2238 && first_len <= line_length
2239 && inner.contains(' ')
2240 && best_open_byte.is_none_or(|prev| pos > prev)
2241 {
2242 best_open_byte = Some(pos);
2243 }
2244 pos += end_local;
2245 } else {
2246 pos += 1;
2247 }
2248 }
2249
2250 let open_byte = best_open_byte?;
2251 let first = text[..open_byte].trim_end().to_string();
2252 let rest = text[open_byte..].to_string();
2253 if first.is_empty() || rest.trim().is_empty() {
2254 return None;
2255 }
2256 Some((first, rest))
2257}
2258
2259fn compute_element_spans(elements: &[Element]) -> Vec<(usize, usize)> {
2263 let mut spans = Vec::new();
2264 let mut offset = 0;
2265 for element in elements {
2266 let len = element.display_len(ReflowLengthMode::Bytes);
2267 if !matches!(element, Element::Text(_)) {
2268 spans.push((offset, offset + len));
2269 }
2270 offset += len;
2271 }
2272 spans
2273}
2274
2275fn is_inside_element(pos: usize, spans: &[(usize, usize)]) -> bool {
2277 spans.iter().any(|(start, end)| pos > *start && pos < *end)
2278}
2279
2280const MIN_SPLIT_RATIO: f64 = 0.3;
2283
2284fn split_at_clause_punctuation(
2288 text: &str,
2289 line_length: usize,
2290 element_spans: &[(usize, usize)],
2291 length_mode: ReflowLengthMode,
2292) -> Option<(String, String)> {
2293 let chars: Vec<char> = text.chars().collect();
2294 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
2295
2296 let mut width_acc = 0;
2298 let mut search_end_char = 0;
2299 for (idx, &c) in chars.iter().enumerate() {
2300 let c_width = display_len(&c.to_string(), length_mode);
2301 if width_acc + c_width > line_length {
2302 break;
2303 }
2304 width_acc += c_width;
2305 search_end_char = idx + 1;
2306 }
2307
2308 let mut paren_depth: i32 = 0;
2315 let mut best_pos = None;
2316 for i in (0..search_end_char).rev() {
2317 let byte_start: usize = chars[..i].iter().map(|c| c.len_utf8()).sum();
2319 let byte_after: usize = byte_start + chars[i].len_utf8();
2321
2322 if !is_inside_element(byte_start, element_spans) {
2323 match chars[i] {
2324 ')' => paren_depth += 1,
2325 '(' => paren_depth = paren_depth.saturating_sub(1),
2326 _ => {}
2327 }
2328 }
2329
2330 if paren_depth == 0
2331 && is_clause_punctuation(chars[i])
2332 && clause_break_allowed_after(&chars, i)
2333 && !is_inside_element(byte_after, element_spans)
2334 {
2335 best_pos = Some(i);
2336 break;
2337 }
2338 }
2339
2340 let pos = best_pos?;
2341
2342 let first: String = chars[..=pos].iter().collect();
2344 let first_display_len = display_len(&first, length_mode);
2345 if first_display_len < min_first_len {
2346 return None;
2347 }
2348
2349 let rest: String = chars[pos + 1..].iter().collect();
2351 let rest = rest.trim_start().to_string();
2352
2353 if rest.is_empty() {
2354 return None;
2355 }
2356
2357 Some((first, rest))
2358}
2359
2360fn paren_depth_map(text: &str, element_spans: &[(usize, usize)]) -> Vec<i32> {
2367 let mut map = vec![0i32; text.len()];
2368 let mut depth = 0i32;
2369 for (byte, c) in text.char_indices() {
2370 if !is_inside_element(byte, element_spans) {
2371 match c {
2372 '(' => depth += 1,
2373 ')' => depth = depth.saturating_sub(1),
2374 _ => {}
2375 }
2376 }
2377 let end = (byte + c.len_utf8()).min(map.len());
2379 for slot in &mut map[byte..end] {
2380 *slot = depth;
2381 }
2382 }
2383 map
2384}
2385
2386fn is_standalone_parenthetical(line: &str) -> bool {
2395 let trimmed = line.trim();
2396 if !trimmed.starts_with('(') {
2397 return false;
2398 }
2399 let core = trimmed.trim_end_matches(|c: char| is_clause_punctuation(c));
2401 if !core.ends_with(')') {
2402 return false;
2403 }
2404 let inner = &core[1..core.len() - 1];
2406 if !inner.contains(' ') {
2407 return false;
2408 }
2409 let mut depth = 0i32;
2411 for c in core.chars() {
2412 match c {
2413 '(' => depth += 1,
2414 ')' => depth -= 1,
2415 _ => {}
2416 }
2417 if depth < 0 {
2418 return false;
2419 }
2420 }
2421 depth == 0
2422}
2423
2424fn split_at_break_word(
2428 text: &str,
2429 line_length: usize,
2430 element_spans: &[(usize, usize)],
2431 length_mode: ReflowLengthMode,
2432) -> Option<(String, String)> {
2433 let lower = text.to_lowercase();
2434 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
2435 let mut best_split: Option<(usize, usize)> = None; let depth_map = paren_depth_map(text, element_spans);
2440
2441 for &word in BREAK_WORDS {
2442 let mut search_start = 0;
2443 while let Some(pos) = lower[search_start..].find(word) {
2444 let abs_pos = search_start + pos;
2445
2446 let preceded_by_space = abs_pos == 0 || text.as_bytes().get(abs_pos - 1) == Some(&b' ');
2448 let followed_by_space = text.as_bytes().get(abs_pos + word.len()) == Some(&b' ');
2449
2450 if preceded_by_space && followed_by_space {
2451 let first_part = text[..abs_pos].trim_end();
2453 let first_part_len = display_len(first_part, length_mode);
2454
2455 let inside_paren = depth_map.get(abs_pos).is_some_and(|&d| d > 0);
2457
2458 if first_part_len >= min_first_len
2459 && first_part_len <= line_length
2460 && !is_inside_element(abs_pos, element_spans)
2461 && !inside_paren
2462 {
2463 if best_split.is_none_or(|(prev_pos, _)| abs_pos > prev_pos) {
2465 best_split = Some((abs_pos, word.len()));
2466 }
2467 }
2468 }
2469
2470 search_start = abs_pos + word.len();
2471 }
2472 }
2473
2474 let (byte_start, _word_len) = best_split?;
2475
2476 let first = text[..byte_start].trim_end().to_string();
2477 let rest = text[byte_start..].to_string();
2478
2479 if first.is_empty() || rest.trim().is_empty() {
2480 return None;
2481 }
2482
2483 Some((first, rest))
2484}
2485
2486fn cascade_split_line(text: &str, options: &ReflowOptions) -> Vec<String> {
2497 let line_length = options.line_length;
2498 let length_mode = options.length_mode;
2499 let attr_lists = options.attr_lists;
2500 let myst_roles = options.myst_roles;
2501 let defined_references = options.defined_references.as_ref();
2502 if line_length == 0 || display_len(text, length_mode) <= line_length {
2503 return vec![text.to_string()];
2504 }
2505
2506 let elements = parse_markdown_elements_inner(text, attr_lists, myst_roles, defined_references);
2507 let element_spans = compute_element_spans(&elements);
2508
2509 let rebased_spans = |start: usize| -> Vec<(usize, usize)> {
2513 if start == 0 {
2514 return element_spans.clone();
2515 }
2516 element_spans
2517 .iter()
2518 .filter(|&&(_, end)| end > start)
2519 .map(|&(s, e)| (s.saturating_sub(start), e.saturating_sub(start)))
2520 .collect()
2521 };
2522
2523 let mut result = Vec::new();
2524 let mut start = 0usize;
2525
2526 loop {
2527 let remaining = &text[start..];
2528 if display_len(remaining, length_mode) <= line_length {
2529 result.push(remaining.to_string());
2530 return result;
2531 }
2532
2533 let spans = rebased_spans(start);
2534
2535 let split = split_at_parenthetical(remaining, line_length, &spans, length_mode)
2539 .or_else(|| split_at_clause_punctuation(remaining, line_length, &spans, length_mode))
2540 .or_else(|| split_at_break_word(remaining, line_length, &spans, length_mode));
2541
2542 if let Some((first, rest)) = split {
2543 let consumed = remaining.len().saturating_sub(rest.len());
2544 if consumed == 0 {
2547 break;
2548 }
2549 result.push(first);
2550 start += consumed;
2551 continue;
2552 }
2553
2554 break;
2556 }
2557
2558 let mut fallback_options = options.clone();
2560 fallback_options.break_on_sentences = false;
2561 fallback_options.preserve_breaks = false;
2562 fallback_options.sentence_per_line = false;
2563 fallback_options.semantic_line_breaks = false;
2564 fallback_options.require_sentence_capital = true;
2565 fallback_options.max_list_continuation_indent = None;
2566 fallback_options.defined_references = None;
2567 let remaining = &text[start..];
2568 let tail_elements = if start == 0 {
2569 elements
2570 } else {
2571 parse_markdown_elements_inner(remaining, attr_lists, myst_roles, defined_references)
2572 };
2573 result.extend(reflow_elements(&tail_elements, &fallback_options));
2574 result
2575}
2576
2577fn reflow_elements_semantic(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
2581 let sentence_lines =
2583 reflow_elements_sentence_per_line(elements, &options.abbreviations, options.require_sentence_capital);
2584
2585 if options.line_length == 0 {
2588 return sentence_lines;
2589 }
2590
2591 let length_mode = options.length_mode;
2592 let mut result = Vec::new();
2593 for line in sentence_lines {
2594 if display_len(&line, length_mode) <= options.line_length {
2595 result.push(line);
2596 } else {
2597 result.extend(cascade_split_line(&line, options));
2598 }
2599 }
2600
2601 let min_line_len = ((options.line_length as f64) * MIN_SPLIT_RATIO) as usize;
2604 let mut merged: Vec<String> = Vec::with_capacity(result.len());
2605 for line in result {
2606 if !merged.is_empty() && display_len(&line, length_mode) < min_line_len && !line.trim().is_empty() {
2607 if is_standalone_parenthetical(&line) {
2610 merged.push(line);
2611 continue;
2612 }
2613
2614 let prev_ends_at_sentence = {
2616 let trimmed = merged.last().unwrap().trim_end();
2617 trimmed
2618 .chars()
2619 .rev()
2620 .find(|c| !matches!(c, '"' | '\'' | '\u{201D}' | '\u{2019}' | ')' | ']'))
2621 .is_some_and(|c| matches!(c, '.' | '!' | '?'))
2622 };
2623
2624 if !prev_ends_at_sentence {
2625 let prev = merged.last_mut().unwrap();
2626 let combined = format!("{prev} {line}");
2627 if display_len(&combined, length_mode) <= options.line_length {
2629 *prev = combined;
2630 continue;
2631 }
2632 }
2633 }
2634 merged.push(line);
2635 }
2636 merged
2637}
2638
2639fn rfind_safe_space(line: &str, element_spans: &[(usize, usize)]) -> Option<usize> {
2649 line.char_indices().rev().map(|(pos, _)| pos).find(|&pos| {
2650 line.as_bytes()[pos] == b' '
2651 && !element_spans.iter().any(|(s, e)| pos > *s && pos < *e)
2652 && !starts_block_construct(&line[pos + 1..])
2653 })
2654}
2655
2656fn break_before_attached(
2663 lines: &mut Vec<String>,
2664 current_line: &mut String,
2665 current_length: &mut usize,
2666 element_spans: &mut Vec<(usize, usize)>,
2667 attach: &str,
2668 separator: &str,
2669 length_mode: ReflowLengthMode,
2670) -> Option<usize> {
2671 let last_space = rfind_safe_space(current_line, element_spans)?;
2672 let before = current_line[..last_space]
2673 .trim_end_matches(is_breakable_whitespace)
2674 .to_string();
2675 let after = current_line[last_space + 1..].to_string();
2676 lines.push(before);
2677 let carried = after.len();
2678 *current_line = format!("{after}{separator}{attach}");
2679 *current_length = display_len(current_line, length_mode);
2680 element_spans.clear();
2681 Some(carried)
2682}
2683
2684fn reflow_elements(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
2686 let mut lines = Vec::new();
2687 let mut current_line = String::new();
2688 let mut current_length = 0;
2689 let mut current_line_element_spans: Vec<(usize, usize)> = Vec::new();
2691 let length_mode = options.length_mode;
2692
2693 for (idx, element) in elements.iter().enumerate() {
2694 let element_len = element.display_len(length_mode);
2695
2696 let is_adjacent_to_prev = if idx > 0 {
2705 match (&elements[idx - 1], element) {
2706 (Element::Text(t), _) => !t.is_empty() && !t.ends_with(is_breakable_whitespace),
2707 (_, Element::Text(t)) => !t.is_empty() && !t.starts_with(is_breakable_whitespace),
2708 _ => true,
2709 }
2710 } else {
2711 false
2712 };
2713
2714 if let Element::Text(text) = element {
2716 let has_leading_space = text.starts_with(is_breakable_whitespace);
2718 let words: Vec<&str> = split_breakable_words(text).collect();
2720
2721 for (i, word) in words.iter().enumerate() {
2722 let word_len = display_len(word, length_mode);
2723 let is_trailing_punct = word.chars().all(|c| {
2729 matches!(c, ',' | '.' | ':' | ';' | '!' | '?' | ')' | ']' | '}') || is_non_breaking_space(c)
2730 });
2731
2732 let is_first_adjacent = i == 0 && is_adjacent_to_prev;
2735
2736 if is_first_adjacent {
2737 if current_length + word_len > options.line_length
2739 && current_length > 0
2740 && break_before_attached(
2741 &mut lines,
2742 &mut current_line,
2743 &mut current_length,
2744 &mut current_line_element_spans,
2745 word,
2746 "",
2747 length_mode,
2748 )
2749 .is_some()
2750 {
2751 } else {
2756 current_line.push_str(word);
2757 current_length += word_len;
2758 }
2759 } else if current_length > 0 && current_length + 1 + word_len > options.line_length {
2760 if is_trailing_punct {
2761 if break_before_attached(
2768 &mut lines,
2769 &mut current_line,
2770 &mut current_length,
2771 &mut current_line_element_spans,
2772 word,
2773 " ",
2774 length_mode,
2775 )
2776 .is_none()
2777 {
2778 current_line.push(' ');
2779 current_line.push_str(word);
2780 current_length += 1 + word_len;
2781 }
2782 } else if !starts_block_construct(word) {
2783 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
2785 current_line = word.to_string();
2786 current_length = word_len;
2787 current_line_element_spans.clear();
2788 } else if break_before_attached(
2789 &mut lines,
2790 &mut current_line,
2791 &mut current_length,
2792 &mut current_line_element_spans,
2793 word,
2794 " ",
2795 length_mode,
2796 )
2797 .is_some()
2798 {
2799 } else {
2804 if i > 0 || has_leading_space {
2807 current_line.push(' ');
2808 current_length += 1;
2809 }
2810 current_line.push_str(word);
2811 current_length += word_len;
2812 }
2813 } else {
2814 let add_space = current_length > 0 && (i > 0 || has_leading_space);
2826 if add_space {
2827 current_line.push(' ');
2828 current_length += 1;
2829 }
2830 current_line.push_str(word);
2831 current_length += word_len;
2832 }
2833 }
2834 } else {
2835 let span_info = match element {
2836 Element::Italic { content, underscore } => {
2837 Some((content.as_str(), if *underscore { "_" } else { "*" }, false))
2838 }
2839 Element::Bold { content, underscore } => {
2840 Some((content.as_str(), if *underscore { "__" } else { "**" }, false))
2841 }
2842 Element::Strikethrough { content, double } => {
2843 Some((content.as_str(), if *double { "~~" } else { "~" }, false))
2844 }
2845 Element::Code { content, marker } => Some((content.as_str(), marker.as_str(), true)),
2846 _ => None,
2847 };
2848
2849 let breakable: Option<Vec<&str>> = match span_info {
2853 Some((content, _, is_code)) => {
2854 if is_code {
2855 (!options.atomic_spans && code_span_wraps_losslessly(content))
2856 .then(|| split_breakable_words(content).collect())
2857 } else {
2858 (!options.atomic_spans || element_len > options.line_length)
2859 .then(|| breakable_units(content))
2860 .flatten()
2861 }
2862 }
2863 None => None,
2864 };
2865
2866 if let Some(words) = breakable {
2867 let (_, marker, is_code) = span_info.expect("breakable implies a span");
2868 let n = words.len();
2869 if n == 0 {
2870 let full = format!("{marker}{marker}");
2872 let full_len = display_len(&full, length_mode);
2873 if !is_adjacent_to_prev && current_length > 0 {
2874 current_line.push(' ');
2875 current_length += 1;
2876 }
2877 current_line.push_str(&full);
2878 current_length += full_len;
2879 } else {
2880 for (i, word) in words.iter().enumerate() {
2881 let is_first = i == 0;
2882 let is_last = i == n - 1;
2883
2884 let space_start = if is_first && is_code && word.starts_with('`') {
2885 " "
2886 } else {
2887 ""
2888 };
2889 let space_end = if is_last && is_code && word.ends_with('`') {
2890 " "
2891 } else {
2892 ""
2893 };
2894
2895 let word_str: String = match (is_first, is_last) {
2896 (true, true) => format!("{marker}{space_start}{word}{space_end}{marker}"),
2897 (true, false) => format!("{marker}{space_start}{word}"),
2898 (false, true) => format!("{word}{space_end}{marker}"),
2899 (false, false) => word.to_string(),
2900 };
2901 let word_len = display_len(&word_str, length_mode);
2902
2903 let needs_space = if is_first {
2904 !is_adjacent_to_prev && current_length > 0
2905 } else {
2906 current_length > 0
2907 };
2908
2909 if needs_space
2910 && current_length + 1 + word_len > options.line_length
2911 && !starts_block_construct(&word_str)
2912 {
2913 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
2914 current_line = word_str;
2915 current_length = word_len;
2916 current_line_element_spans.clear();
2917 } else {
2918 if needs_space {
2919 current_line.push(' ');
2920 current_length += 1;
2921 }
2922 current_line.push_str(&word_str);
2923 current_length += word_len;
2924 }
2925 }
2926 }
2927 } else {
2928 let element_str = format!("{element}");
2931
2932 if is_adjacent_to_prev {
2933 if current_length + element_len > options.line_length
2935 && let Some(carried) = break_before_attached(
2936 &mut lines,
2937 &mut current_line,
2938 &mut current_length,
2939 &mut current_line_element_spans,
2940 &element_str,
2941 "",
2942 length_mode,
2943 )
2944 {
2945 current_line_element_spans.push((carried, carried + element_str.len()));
2949 } else {
2950 let start = current_line.len();
2951 current_line.push_str(&element_str);
2952 current_length += element_len;
2953 current_line_element_spans.push((start, current_line.len()));
2954 }
2955 } else if current_length > 0 && current_length + 1 + element_len > options.line_length {
2956 if !starts_block_construct(&element_str) {
2957 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
2959 current_line.clone_from(&element_str);
2960 current_length = element_len;
2961 current_line_element_spans.clear();
2962 current_line_element_spans.push((0, element_str.len()));
2963 } else if let Some(carried) = break_before_attached(
2964 &mut lines,
2965 &mut current_line,
2966 &mut current_length,
2967 &mut current_line_element_spans,
2968 &element_str,
2969 " ",
2970 length_mode,
2971 ) {
2972 let start = carried + 1;
2976 current_line_element_spans.push((start, start + element_str.len()));
2977 } else {
2978 let ends_with_opener =
2981 current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
2982 if !ends_with_opener {
2983 current_line.push(' ');
2984 current_length += 1;
2985 }
2986 let start = current_line.len();
2987 current_line.push_str(&element_str);
2988 current_length += element_len;
2989 current_line_element_spans.push((start, current_line.len()));
2990 }
2991 } else {
2992 let ends_with_opener =
2994 current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
2995 if current_length > 0 && !ends_with_opener {
2996 current_line.push(' ');
2997 current_length += 1;
2998 }
2999 let start = current_line.len();
3000 current_line.push_str(&element_str);
3001 current_length += element_len;
3002 current_line_element_spans.push((start, current_line.len()));
3003 }
3004 }
3005 }
3006 }
3007
3008 if !current_line.is_empty() {
3010 lines.push(current_line.trim_end_matches(is_breakable_whitespace).to_string());
3011 }
3012
3013 lines
3014}
3015
3016pub fn reflow_markdown(content: &str, options: &ReflowOptions) -> String {
3018 let lines: Vec<&str> = content.lines().collect();
3019 let mut result = Vec::new();
3020 let mut i = 0;
3021
3022 while i < lines.len() {
3023 let line = lines[i];
3024 let trimmed = line.trim();
3025
3026 if trimmed.is_empty() {
3028 result.push(String::new());
3029 i += 1;
3030 continue;
3031 }
3032
3033 if trimmed.starts_with('#') {
3035 result.push(line.to_string());
3036 i += 1;
3037 continue;
3038 }
3039
3040 if trimmed.starts_with(":::") {
3042 result.push(line.to_string());
3043 i += 1;
3044 continue;
3045 }
3046
3047 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
3049 result.push(line.to_string());
3050 i += 1;
3051 while i < lines.len() {
3053 result.push(lines[i].to_string());
3054 if lines[i].trim().starts_with("```") || lines[i].trim().starts_with("~~~") {
3055 i += 1;
3056 break;
3057 }
3058 i += 1;
3059 }
3060 continue;
3061 }
3062
3063 if calculate_indentation_width_default(line) >= 4 {
3065 result.push(line.to_string());
3067 i += 1;
3068 while i < lines.len() {
3069 let next_line = lines[i];
3070 if calculate_indentation_width_default(next_line) >= 4 || next_line.trim().is_empty() {
3072 result.push(next_line.to_string());
3073 i += 1;
3074 } else {
3075 break;
3076 }
3077 }
3078 continue;
3079 }
3080
3081 if trimmed.starts_with('>') {
3083 let gt_pos = line.find('>').expect("'>' must exist since trimmed.starts_with('>')");
3086 let quote_prefix = line[0..=gt_pos].to_string();
3087 let quote_content = &line[quote_prefix.len()..].trim_start();
3088
3089 let reflowed = reflow_line(quote_content, options);
3090 for reflowed_line in &reflowed {
3091 result.push(format!("{quote_prefix} {reflowed_line}"));
3092 }
3093 i += 1;
3094 continue;
3095 }
3096
3097 if is_horizontal_rule(trimmed) {
3099 result.push(line.to_string());
3100 i += 1;
3101 continue;
3102 }
3103
3104 if is_unordered_list_marker(trimmed) || is_numbered_list_item(trimmed) {
3106 let indent = line.len() - line.trim_start().len();
3108 let indent_str = " ".repeat(indent);
3109
3110 let mut marker_end = indent;
3113 let mut content_start = indent;
3114
3115 if trimmed.chars().next().is_some_and(char::is_numeric) {
3116 if let Some(period_pos) = line[indent..].find('.') {
3118 marker_end = indent + period_pos + 1; content_start = marker_end;
3120 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
3124 content_start += 1;
3125 }
3126 }
3127 } else {
3128 marker_end = indent + 1; content_start = marker_end;
3131 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
3135 content_start += 1;
3136 }
3137 }
3138
3139 let min_continuation_indent = content_start;
3141
3142 let rest = &line[content_start..];
3145 if rest.starts_with("[ ] ") || rest.starts_with("[x] ") || rest.starts_with("[X] ") {
3146 marker_end = content_start + 3; content_start += 4; }
3149
3150 let marker = &line[indent..marker_end];
3151
3152 let mut list_content = vec![trim_preserving_hard_break(&line[content_start..])];
3155 i += 1;
3156
3157 while i < lines.len() {
3161 let next_line = lines[i];
3162 let next_trimmed = next_line.trim();
3163
3164 if is_block_boundary(next_trimmed) {
3166 break;
3167 }
3168
3169 let next_indent = next_line.len() - next_line.trim_start().len();
3171 if next_indent >= min_continuation_indent {
3172 let trimmed_start = next_line.trim_start();
3175 list_content.push(trim_preserving_hard_break(trimmed_start));
3176 i += 1;
3177 } else {
3178 break;
3180 }
3181 }
3182
3183 let combined_content = if options.preserve_breaks {
3186 list_content[0].clone()
3187 } else {
3188 let has_hard_breaks = list_content.iter().any(|line| has_hard_break(line));
3190 if has_hard_breaks {
3191 list_content.join("\n")
3193 } else {
3194 list_content.join(" ")
3196 }
3197 };
3198
3199 let trimmed_marker = marker;
3201 let continuation_spaces = if let Some(max_indent) = options.max_list_continuation_indent {
3202 indent + (content_start - indent).min(max_indent)
3205 } else {
3206 content_start
3207 };
3208
3209 let prefix_length = indent + trimmed_marker.len() + 1;
3211
3212 let adjusted_options = ReflowOptions {
3214 line_length: options.line_length.saturating_sub(prefix_length),
3215 ..options.clone()
3216 };
3217
3218 let reflowed = reflow_line(&combined_content, &adjusted_options);
3219 for (j, reflowed_line) in reflowed.iter().enumerate() {
3220 if j == 0 {
3221 result.push(format!("{indent_str}{trimmed_marker} {reflowed_line}"));
3222 } else {
3223 let continuation_indent = " ".repeat(continuation_spaces);
3225 result.push(format!("{continuation_indent}{reflowed_line}"));
3226 }
3227 }
3228 continue;
3229 }
3230
3231 if crate::utils::table_utils::TableUtils::is_potential_table_row(line) {
3233 result.push(line.to_string());
3234 i += 1;
3235 continue;
3236 }
3237
3238 if trimmed.starts_with('[') && line.contains("]:") {
3240 result.push(line.to_string());
3241 i += 1;
3242 continue;
3243 }
3244
3245 if is_definition_list_item(trimmed) {
3247 result.push(line.to_string());
3248 i += 1;
3249 continue;
3250 }
3251
3252 let mut is_single_line_paragraph = true;
3254 if i + 1 < lines.len() {
3255 let next_trimmed = lines[i + 1].trim();
3256 if !is_block_boundary(next_trimmed) {
3258 is_single_line_paragraph = false;
3259 }
3260 }
3261
3262 if is_single_line_paragraph && display_len(line, options.length_mode) <= options.line_length {
3264 result.push(line.to_string());
3265 i += 1;
3266 continue;
3267 }
3268
3269 let mut paragraph_parts = Vec::new();
3271 let mut current_part = vec![line];
3272 i += 1;
3273
3274 if options.preserve_breaks {
3276 let hard_break_type = if line.strip_suffix('\r').unwrap_or(line).ends_with('\\') {
3278 Some("\\")
3279 } else if line.ends_with(" ") {
3280 Some(" ")
3281 } else {
3282 None
3283 };
3284 let reflowed = reflow_line(line, options);
3285
3286 if let Some(break_marker) = hard_break_type {
3288 if !reflowed.is_empty() {
3289 let mut reflowed_with_break = reflowed;
3290 let last_idx = reflowed_with_break.len() - 1;
3291 if !has_hard_break(&reflowed_with_break[last_idx]) {
3292 reflowed_with_break[last_idx].push_str(break_marker);
3293 }
3294 result.extend(reflowed_with_break);
3295 }
3296 } else {
3297 result.extend(reflowed);
3298 }
3299 } else {
3300 while i < lines.len() {
3302 let prev_line = if !current_part.is_empty() {
3303 current_part.last().unwrap()
3304 } else {
3305 ""
3306 };
3307 let next_line = lines[i];
3308 let next_trimmed = next_line.trim();
3309
3310 if is_block_boundary(next_trimmed) {
3312 break;
3313 }
3314
3315 let prev_trimmed = prev_line.trim();
3318 let abbreviations = get_abbreviations(&options.abbreviations);
3319 let ends_with_sentence = (prev_trimmed.ends_with('.')
3320 || prev_trimmed.ends_with('!')
3321 || prev_trimmed.ends_with('?')
3322 || prev_trimmed.ends_with(".*")
3323 || prev_trimmed.ends_with("!*")
3324 || prev_trimmed.ends_with("?*")
3325 || prev_trimmed.ends_with("._")
3326 || prev_trimmed.ends_with("!_")
3327 || prev_trimmed.ends_with("?_")
3328 || prev_trimmed.ends_with(".\"")
3330 || prev_trimmed.ends_with("!\"")
3331 || prev_trimmed.ends_with("?\"")
3332 || prev_trimmed.ends_with(".'")
3333 || prev_trimmed.ends_with("!'")
3334 || prev_trimmed.ends_with("?'")
3335 || prev_trimmed.ends_with(".\u{201D}")
3336 || prev_trimmed.ends_with("!\u{201D}")
3337 || prev_trimmed.ends_with("?\u{201D}")
3338 || prev_trimmed.ends_with(".\u{2019}")
3339 || prev_trimmed.ends_with("!\u{2019}")
3340 || prev_trimmed.ends_with("?\u{2019}"))
3341 && !text_ends_with_abbreviation(
3342 prev_trimmed.trim_end_matches(['*', '_', '"', '\'', '\u{201D}', '\u{2019}']),
3343 &abbreviations,
3344 );
3345
3346 if has_hard_break(prev_line) || (options.sentence_per_line && ends_with_sentence) {
3347 paragraph_parts.push(current_part.join(" "));
3349 current_part = vec![next_line];
3350 } else {
3351 current_part.push(next_line);
3352 }
3353 i += 1;
3354 }
3355
3356 if !current_part.is_empty() {
3358 if current_part.len() == 1 {
3359 paragraph_parts.push(current_part[0].to_string());
3361 } else {
3362 paragraph_parts.push(current_part.join(" "));
3363 }
3364 }
3365
3366 for (j, part) in paragraph_parts.iter().enumerate() {
3368 let reflowed = reflow_line(part, options);
3369 result.extend(reflowed);
3370
3371 if j < paragraph_parts.len() - 1 && !result.is_empty() && !options.sentence_per_line {
3375 let last_idx = result.len() - 1;
3376 if !has_hard_break(&result[last_idx]) {
3377 result[last_idx].push_str(" ");
3378 }
3379 }
3380 }
3381 }
3382 }
3383
3384 let result_text = result.join("\n");
3386 if content.ends_with('\n') && !result_text.ends_with('\n') {
3387 format!("{result_text}\n")
3388 } else {
3389 result_text
3390 }
3391}
3392
3393#[derive(Debug, Clone)]
3395pub struct ParagraphReflow {
3396 pub start_byte: usize,
3398 pub end_byte: usize,
3400 pub reflowed_text: String,
3402}
3403
3404#[derive(Debug, Clone)]
3410pub struct BlockquoteLineData {
3411 pub(crate) content: String,
3413 pub(crate) is_explicit: bool,
3415 pub(crate) prefix: Option<String>,
3417}
3418
3419impl BlockquoteLineData {
3420 pub fn explicit(content: String, prefix: String) -> Self {
3422 Self {
3423 content,
3424 is_explicit: true,
3425 prefix: Some(prefix),
3426 }
3427 }
3428
3429 pub fn lazy(content: String) -> Self {
3431 Self {
3432 content,
3433 is_explicit: false,
3434 prefix: None,
3435 }
3436 }
3437}
3438
3439#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3441pub enum BlockquoteContinuationStyle {
3442 Explicit,
3443 Lazy,
3444}
3445
3446pub fn blockquote_continuation_style(lines: &[BlockquoteLineData]) -> BlockquoteContinuationStyle {
3454 let mut explicit_count = 0usize;
3455 let mut lazy_count = 0usize;
3456
3457 for line in lines.iter().skip(1) {
3458 if line.is_explicit {
3459 explicit_count += 1;
3460 } else {
3461 lazy_count += 1;
3462 }
3463 }
3464
3465 if explicit_count > 0 && lazy_count == 0 {
3466 BlockquoteContinuationStyle::Explicit
3467 } else if lazy_count > 0 && explicit_count == 0 {
3468 BlockquoteContinuationStyle::Lazy
3469 } else if explicit_count >= lazy_count {
3470 BlockquoteContinuationStyle::Explicit
3471 } else {
3472 BlockquoteContinuationStyle::Lazy
3473 }
3474}
3475
3476pub fn dominant_blockquote_prefix(lines: &[BlockquoteLineData], fallback: &str) -> String {
3481 let mut counts: std::collections::HashMap<String, (usize, usize)> = std::collections::HashMap::new();
3482
3483 for (idx, line) in lines.iter().enumerate() {
3484 let Some(prefix) = line.prefix.as_ref() else {
3485 continue;
3486 };
3487 counts
3488 .entry(prefix.clone())
3489 .and_modify(|entry| entry.0 += 1)
3490 .or_insert((1, idx));
3491 }
3492
3493 counts
3494 .into_iter()
3495 .max_by(|(_, (count_a, first_idx_a)), (_, (count_b, first_idx_b))| {
3496 count_a.cmp(count_b).then_with(|| first_idx_b.cmp(first_idx_a))
3497 })
3498 .map_or_else(|| fallback.to_string(), |(prefix, _)| prefix)
3499}
3500
3501pub(crate) fn should_force_explicit_blockquote_line(content_line: &str) -> bool {
3506 let trimmed = content_line.trim_start();
3507 trimmed.starts_with('>')
3508 || trimmed.starts_with('#')
3509 || trimmed.starts_with("```")
3510 || trimmed.starts_with("~~~")
3511 || is_unordered_list_marker(trimmed)
3512 || is_numbered_list_item(trimmed)
3513 || is_horizontal_rule(trimmed)
3514 || is_definition_list_item(trimmed)
3515 || (trimmed.starts_with('[') && trimmed.contains("]:"))
3516 || trimmed.starts_with(":::")
3517 || (trimmed.starts_with('<')
3518 && !trimmed.starts_with("<http")
3519 && !trimmed.starts_with("<https")
3520 && !trimmed.starts_with("<mailto:"))
3521}
3522
3523pub fn reflow_blockquote_content(
3532 lines: &[BlockquoteLineData],
3533 explicit_prefix: &str,
3534 continuation_style: BlockquoteContinuationStyle,
3535 options: &ReflowOptions,
3536) -> Vec<String> {
3537 let content_strs: Vec<&str> = lines.iter().map(|l| l.content.as_str()).collect();
3538 let segments = split_into_segments_strs(&content_strs);
3539 let mut reflowed_content_lines: Vec<String> = Vec::new();
3540
3541 for segment in segments {
3542 let hard_break_type = segment.last().and_then(|&line| {
3543 let line = line.strip_suffix('\r').unwrap_or(line);
3544 if line.ends_with('\\') {
3545 Some("\\")
3546 } else if line.ends_with(" ") {
3547 Some(" ")
3548 } else {
3549 None
3550 }
3551 });
3552
3553 let pieces: Vec<&str> = segment
3554 .iter()
3555 .map(|&line| {
3556 if let Some(l) = line.strip_suffix('\\') {
3557 l.trim_end()
3558 } else if let Some(l) = line.strip_suffix(" ") {
3559 l.trim_end()
3560 } else {
3561 line.trim_end()
3562 }
3563 })
3564 .collect();
3565
3566 let segment_text = pieces.join(" ");
3567 let segment_text = segment_text.trim();
3568 if segment_text.is_empty() {
3569 continue;
3570 }
3571
3572 let mut reflowed = reflow_line(segment_text, options);
3573 if let Some(break_marker) = hard_break_type
3574 && !reflowed.is_empty()
3575 {
3576 let last_idx = reflowed.len() - 1;
3577 if !has_hard_break(&reflowed[last_idx]) {
3578 reflowed[last_idx].push_str(break_marker);
3579 }
3580 }
3581 reflowed_content_lines.extend(reflowed);
3582 }
3583
3584 let mut styled_lines: Vec<String> = Vec::new();
3585 for (idx, line) in reflowed_content_lines.iter().enumerate() {
3586 let force_explicit = idx == 0
3587 || continuation_style == BlockquoteContinuationStyle::Explicit
3588 || should_force_explicit_blockquote_line(line);
3589 if force_explicit {
3590 styled_lines.push(format!("{explicit_prefix}{line}"));
3591 } else {
3592 styled_lines.push(line.clone());
3593 }
3594 }
3595
3596 styled_lines
3597}
3598
3599fn is_blockquote_content_boundary(content: &str) -> bool {
3600 let trimmed = content.trim();
3601 trimmed.is_empty()
3602 || is_block_boundary(trimmed)
3603 || crate::utils::table_utils::TableUtils::is_potential_table_row(content)
3604 || trimmed.starts_with(":::")
3605 || crate::utils::is_template_directive_only(content)
3606 || is_standalone_attr_list(content)
3607 || is_snippet_block_delimiter(content)
3608}
3609
3610fn split_into_segments_strs<'a>(lines: &[&'a str]) -> Vec<Vec<&'a str>> {
3611 let mut segments = Vec::new();
3612 let mut current = Vec::new();
3613
3614 for &line in lines {
3615 current.push(line);
3616 if has_hard_break(line) {
3617 segments.push(current);
3618 current = Vec::new();
3619 }
3620 }
3621
3622 if !current.is_empty() {
3623 segments.push(current);
3624 }
3625
3626 segments
3627}
3628
3629fn reflow_blockquote_paragraph_at_line(
3630 content: &str,
3631 lines: &[&str],
3632 target_idx: usize,
3633 options: &ReflowOptions,
3634) -> Option<ParagraphReflow> {
3635 let mut anchor_idx = target_idx;
3636 let mut target_level = if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[target_idx]) {
3637 parsed.nesting_level
3638 } else {
3639 let mut found = None;
3640 let mut idx = target_idx;
3641 loop {
3642 if lines[idx].trim().is_empty() {
3643 break;
3644 }
3645 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[idx]) {
3646 found = Some((idx, parsed.nesting_level));
3647 break;
3648 }
3649 if idx == 0 {
3650 break;
3651 }
3652 idx -= 1;
3653 }
3654 let (idx, level) = found?;
3655 anchor_idx = idx;
3656 level
3657 };
3658
3659 let mut para_start = anchor_idx;
3661 while para_start > 0 {
3662 let prev_idx = para_start - 1;
3663 let prev_line = lines[prev_idx];
3664
3665 if prev_line.trim().is_empty() {
3666 break;
3667 }
3668
3669 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(prev_line) {
3670 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
3671 break;
3672 }
3673 para_start = prev_idx;
3674 continue;
3675 }
3676
3677 let prev_lazy = prev_line.trim_start();
3678 if is_blockquote_content_boundary(prev_lazy) {
3679 break;
3680 }
3681 para_start = prev_idx;
3682 }
3683
3684 while para_start < lines.len() {
3686 let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[para_start]) else {
3687 para_start += 1;
3688 continue;
3689 };
3690 target_level = parsed.nesting_level;
3691 break;
3692 }
3693
3694 if para_start >= lines.len() || para_start > target_idx {
3695 return None;
3696 }
3697
3698 let mut collected: Vec<(usize, BlockquoteLineData)> = Vec::new();
3701 let mut idx = para_start;
3702 while idx < lines.len() {
3703 if !collected.is_empty() && has_hard_break(&collected[collected.len() - 1].1.content) {
3704 break;
3705 }
3706
3707 let line = lines[idx];
3708 if line.trim().is_empty() {
3709 break;
3710 }
3711
3712 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(line) {
3713 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
3714 break;
3715 }
3716 collected.push((
3717 idx,
3718 BlockquoteLineData::explicit(trim_preserving_hard_break(parsed.content), parsed.prefix.to_string()),
3719 ));
3720 idx += 1;
3721 continue;
3722 }
3723
3724 let lazy_content = line.trim_start();
3725 if is_blockquote_content_boundary(lazy_content) {
3726 break;
3727 }
3728
3729 collected.push((idx, BlockquoteLineData::lazy(trim_preserving_hard_break(lazy_content))));
3730 idx += 1;
3731 }
3732
3733 if collected.is_empty() {
3734 return None;
3735 }
3736
3737 let para_end = collected[collected.len() - 1].0;
3738 if target_idx < para_start || target_idx > para_end {
3739 return None;
3740 }
3741
3742 let line_data: Vec<BlockquoteLineData> = collected.iter().map(|(_, d)| d.clone()).collect();
3743
3744 let fallback_prefix = line_data
3745 .iter()
3746 .find_map(|d| d.prefix.clone())
3747 .unwrap_or_else(|| "> ".to_string());
3748 let explicit_prefix = dominant_blockquote_prefix(&line_data, &fallback_prefix);
3749 let continuation_style = blockquote_continuation_style(&line_data);
3750
3751 let adjusted_line_length = options
3752 .line_length
3753 .saturating_sub(display_len(&explicit_prefix, options.length_mode))
3754 .max(1);
3755
3756 let adjusted_options = ReflowOptions {
3757 line_length: adjusted_line_length,
3758 ..options.clone()
3759 };
3760
3761 let styled_lines = reflow_blockquote_content(&line_data, &explicit_prefix, continuation_style, &adjusted_options);
3762
3763 if styled_lines.is_empty() {
3764 return None;
3765 }
3766
3767 let mut start_byte = 0;
3769 for line in lines.iter().take(para_start) {
3770 start_byte += line.len() + 1;
3771 }
3772
3773 let mut end_byte = start_byte;
3774 for line in lines.iter().take(para_end + 1).skip(para_start) {
3775 end_byte += line.len() + 1;
3776 }
3777
3778 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
3779 if !includes_trailing_newline {
3780 end_byte -= 1;
3781 }
3782
3783 let reflowed_joined = styled_lines.join("\n");
3784 let reflowed_text = if includes_trailing_newline {
3785 if reflowed_joined.ends_with('\n') {
3786 reflowed_joined
3787 } else {
3788 format!("{reflowed_joined}\n")
3789 }
3790 } else if reflowed_joined.ends_with('\n') {
3791 reflowed_joined.trim_end_matches('\n').to_string()
3792 } else {
3793 reflowed_joined
3794 };
3795
3796 Some(ParagraphReflow {
3797 start_byte,
3798 end_byte,
3799 reflowed_text,
3800 })
3801}
3802
3803pub fn reflow_paragraph_at_line(content: &str, line_number: usize, line_length: usize) -> Option<ParagraphReflow> {
3821 reflow_paragraph_at_line_with_mode(content, line_number, line_length, ReflowLengthMode::default())
3822}
3823
3824pub fn reflow_paragraph_at_line_with_mode(
3826 content: &str,
3827 line_number: usize,
3828 line_length: usize,
3829 length_mode: ReflowLengthMode,
3830) -> Option<ParagraphReflow> {
3831 let options = ReflowOptions {
3832 line_length,
3833 length_mode,
3834 ..Default::default()
3835 };
3836 reflow_paragraph_at_line_with_options(content, line_number, &options)
3837}
3838
3839pub fn reflow_paragraph_at_line_with_options(
3850 content: &str,
3851 line_number: usize,
3852 options: &ReflowOptions,
3853) -> Option<ParagraphReflow> {
3854 if line_number == 0 {
3855 return None;
3856 }
3857
3858 let lines: Vec<&str> = content.lines().collect();
3859
3860 if line_number > lines.len() {
3862 return None;
3863 }
3864
3865 let target_idx = line_number - 1; let target_line = lines[target_idx];
3867 let trimmed = target_line.trim();
3868
3869 if let Some(blockquote_reflow) = reflow_blockquote_paragraph_at_line(content, &lines, target_idx, options) {
3872 return Some(blockquote_reflow);
3873 }
3874
3875 if is_paragraph_boundary(trimmed, target_line) {
3877 return None;
3878 }
3879
3880 let mut para_start = target_idx;
3882 while para_start > 0 {
3883 let prev_idx = para_start - 1;
3884 let prev_line = lines[prev_idx];
3885 let prev_trimmed = prev_line.trim();
3886
3887 if is_paragraph_boundary(prev_trimmed, prev_line) {
3889 break;
3890 }
3891
3892 para_start = prev_idx;
3893 }
3894
3895 let mut para_end = target_idx;
3897 while para_end + 1 < lines.len() {
3898 let next_idx = para_end + 1;
3899 let next_line = lines[next_idx];
3900 let next_trimmed = next_line.trim();
3901
3902 if is_paragraph_boundary(next_trimmed, next_line) {
3904 break;
3905 }
3906
3907 para_end = next_idx;
3908 }
3909
3910 let paragraph_lines = &lines[para_start..=para_end];
3912
3913 let mut start_byte = 0;
3915 for line in lines.iter().take(para_start) {
3916 start_byte += line.len() + 1; }
3918
3919 let mut end_byte = start_byte;
3920 for line in paragraph_lines {
3921 end_byte += line.len() + 1; }
3923
3924 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
3927
3928 if !includes_trailing_newline {
3930 end_byte -= 1;
3931 }
3932
3933 let paragraph_text = paragraph_lines.join("\n");
3935
3936 let reflowed = reflow_markdown(¶graph_text, options);
3938
3939 let reflowed_text = if includes_trailing_newline {
3943 if reflowed.ends_with('\n') {
3945 reflowed
3946 } else {
3947 format!("{reflowed}\n")
3948 }
3949 } else {
3950 if reflowed.ends_with('\n') {
3952 reflowed.trim_end_matches('\n').to_string()
3953 } else {
3954 reflowed
3955 }
3956 };
3957
3958 Some(ParagraphReflow {
3959 start_byte,
3960 end_byte,
3961 reflowed_text,
3962 })
3963}
3964fn decompose_code_span(raw: &str) -> Option<(&str, &str)> {
3970 let marker_len = raw.bytes().take_while(|&b| b == b'`').count();
3971 if marker_len == 0 {
3972 return None;
3973 }
3974 let marker = &raw[..marker_len];
3975 if raw.len() < marker_len * 2 {
3976 return None;
3977 }
3978 let content = &raw[marker_len..raw.len() - marker_len];
3979 Some((content, marker))
3980}
3981
3982#[cfg(test)]
3983mod tests {
3984 use super::*;
3985
3986 #[test]
3987 fn cascade_split_line_handles_a_very_long_line_without_overflowing() {
3988 let words: Vec<String> = (0..4000).map(|i| format!("word{i}")).collect();
3994 let line = words.join(" ");
3995
3996 let options = ReflowOptions {
3997 line_length: 80,
3998 length_mode: ReflowLengthMode::Chars,
3999 ..Default::default()
4000 };
4001 let out = cascade_split_line(&line, &options);
4002
4003 assert!(out.len() > 1, "a very long line should split into many lines");
4004 for segment in &out {
4005 assert!(
4006 display_len(segment, ReflowLengthMode::Chars) <= 80 || !segment.contains(' '),
4007 "each wrapped line should fit the width (or be a single unbreakable token)"
4008 );
4009 }
4010 let rejoined = out.join(" ");
4012 let original_words: Vec<&str> = line.split(' ').collect();
4013 let result_words: Vec<&str> = rejoined.split_whitespace().collect();
4014 assert_eq!(original_words, result_words, "reflow must preserve all words in order");
4015 }
4016
4017 #[test]
4022 fn test_helper_function_text_ends_with_abbreviation() {
4023 let abbreviations = get_abbreviations(&None);
4025
4026 assert!(text_ends_with_abbreviation("Dr.", &abbreviations));
4028 assert!(text_ends_with_abbreviation("word Dr.", &abbreviations));
4029 assert!(text_ends_with_abbreviation("e.g.", &abbreviations));
4030 assert!(text_ends_with_abbreviation("i.e.", &abbreviations));
4031 assert!(text_ends_with_abbreviation("Mr.", &abbreviations));
4032 assert!(text_ends_with_abbreviation("Mrs.", &abbreviations));
4033 assert!(text_ends_with_abbreviation("Ms.", &abbreviations));
4034 assert!(text_ends_with_abbreviation("Prof.", &abbreviations));
4035
4036 assert!(!text_ends_with_abbreviation("etc.", &abbreviations));
4038 assert!(!text_ends_with_abbreviation("paradigms.", &abbreviations));
4039 assert!(!text_ends_with_abbreviation("programs.", &abbreviations));
4040 assert!(!text_ends_with_abbreviation("items.", &abbreviations));
4041 assert!(!text_ends_with_abbreviation("systems.", &abbreviations));
4042 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)); }
4048
4049 #[test]
4050 fn test_footnote_after_period_splits_sentence() {
4051 let text = "First sentence.[^1] Second sentence.";
4055 let sentences = split_into_sentences(text);
4056 assert_eq!(
4057 sentences,
4058 vec!["First sentence.[^1]".to_string(), "Second sentence.".to_string()],
4059 "footnote glued to the period should keep the boundary and stay attached to the first sentence"
4060 );
4061 }
4062
4063 #[test]
4064 fn test_multiple_consecutive_footnotes_after_period_splits_sentence() {
4065 let text = "Notes here.[^1][^2] Second sentence.";
4067 let sentences = split_into_sentences(text);
4068 assert_eq!(
4069 sentences,
4070 vec!["Notes here.[^1][^2]".to_string(), "Second sentence.".to_string()]
4071 );
4072 }
4073
4074 #[test]
4075 fn test_footnote_before_period_still_splits_sentence() {
4076 let text = "Annotation here[^1]. Second sentence.";
4080 let sentences = split_into_sentences(text);
4081 assert_eq!(
4082 sentences,
4083 vec!["Annotation here[^1].".to_string(), "Second sentence.".to_string()]
4084 );
4085 }
4086
4087 #[test]
4088 fn test_mid_sentence_footnote_does_not_split() {
4089 let text = "The system word[^1] more words. Next sentence.";
4092 let sentences = split_into_sentences(text);
4093 assert_eq!(
4094 sentences,
4095 vec![
4096 "The system word[^1] more words.".to_string(),
4097 "Next sentence.".to_string()
4098 ]
4099 );
4100 }
4101
4102 #[test]
4103 fn test_bare_numeric_bracket_after_period_does_not_split() {
4104 let text = "Citation here.[1] Second sentence.";
4107 let sentences = split_into_sentences(text);
4108 assert_eq!(
4109 sentences,
4110 vec![text.to_string()],
4111 "a bare numeric bracket must not be treated as a sentence boundary"
4112 );
4113 }
4114
4115 #[test]
4116 fn test_footnote_glued_to_following_word_does_not_split() {
4117 let text = "First sentence.[^1]Continued glued text.";
4120 let sentences = split_into_sentences(text);
4121 assert_eq!(sentences, vec![text.to_string()]);
4122 }
4123
4124 #[test]
4125 fn test_footnote_at_end_of_text_is_preserved() {
4126 let text = "Sentence.[^1]";
4129 let sentences = split_into_sentences(text);
4130 assert_eq!(sentences, vec![text.to_string()]);
4131 }
4132
4133 #[test]
4134 fn test_abbreviation_before_footnote_does_not_split() {
4135 let text = "See the notes, e.g.[^1] this one.";
4138 let sentences = split_into_sentences(text);
4139 assert_eq!(
4140 sentences,
4141 vec![text.to_string()],
4142 "e.g. is an abbreviation, not a sentence boundary"
4143 );
4144 }
4145
4146 #[test]
4147 fn test_is_unordered_list_marker() {
4148 assert!(is_unordered_list_marker("- item"));
4150 assert!(is_unordered_list_marker("* item"));
4151 assert!(is_unordered_list_marker("+ item"));
4152 assert!(is_unordered_list_marker("-")); assert!(is_unordered_list_marker("*"));
4154 assert!(is_unordered_list_marker("+"));
4155
4156 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")); }
4167
4168 #[test]
4169 fn test_is_block_boundary() {
4170 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"));
4192 assert!(!is_block_boundary("*emphasis*")); assert!(!is_block_boundary("[link](url)")); assert!(!is_block_boundary("some words here"));
4195 }
4196
4197 #[test]
4198 fn test_definition_list_boundary_in_single_line_paragraph() {
4199 let options = ReflowOptions {
4202 line_length: 80,
4203 ..Default::default()
4204 };
4205 let input = "Term\n: Definition of the term";
4206 let result = reflow_markdown(input, &options);
4207 assert!(
4209 result.contains(": Definition"),
4210 "Definition list item should not be merged into previous line. Got: {result:?}"
4211 );
4212 let lines: Vec<&str> = result.lines().collect();
4213 assert_eq!(lines.len(), 2, "Should remain two separate lines. Got: {lines:?}");
4214 assert_eq!(lines[0], "Term");
4215 assert_eq!(lines[1], ": Definition of the term");
4216 }
4217
4218 #[test]
4219 fn test_is_paragraph_boundary() {
4220 assert!(is_paragraph_boundary("# Heading", "# Heading"));
4222 assert!(is_paragraph_boundary("- item", "- item"));
4223 assert!(is_paragraph_boundary(":::", ":::"));
4224 assert!(is_paragraph_boundary(": definition", ": definition"));
4225
4226 assert!(is_paragraph_boundary("code", " code"));
4228 assert!(is_paragraph_boundary("code", "\tcode"));
4229
4230 assert!(is_paragraph_boundary("| a | b |", "| a | b |"));
4232 assert!(is_paragraph_boundary("a | b", "a | b")); assert!(!is_paragraph_boundary("regular text", "regular text"));
4236 assert!(!is_paragraph_boundary("text", " text")); }
4238
4239 #[test]
4240 fn test_div_marker_boundary_in_reflow_paragraph_at_line() {
4241 let content = "Some paragraph text here.\n\n::: {.callout-note}\nThis is a callout.\n:::\n";
4244 let result = reflow_paragraph_at_line(content, 3, 80);
4246 assert!(result.is_none(), "Div marker line should not be reflowed");
4247 }
4248
4249 #[test]
4250 fn starts_block_construct_detects_block_openers() {
4251 for case in ["- item", "-", "* item", "*", "+ item", "+", "-\titem"] {
4253 assert!(starts_block_construct(case), "bullet: {case:?}");
4254 }
4255 for case in ["1. item", "1) item", "9. x", "123456789. x", "1.", "42) x"] {
4257 assert!(starts_block_construct(case), "ordered: {case:?}");
4258 }
4259 for case in ["> quote", ">quote", ">"] {
4261 assert!(starts_block_construct(case), "blockquote: {case:?}");
4262 }
4263 for case in ["# heading", "###### h6", "#", "##"] {
4265 assert!(starts_block_construct(case), "heading: {case:?}");
4266 }
4267 for case in ["```", "```rust", "````", "~~~", "~~~text"] {
4269 assert!(starts_block_construct(case), "fence: {case:?}");
4270 }
4271 for case in ["---", "--", "===", "=", "***", "___", "_ _ _", "- - -"] {
4273 assert!(starts_block_construct(case), "setext/thematic: {case:?}");
4274 }
4275 for case in [
4278 "[^1]: text",
4279 "[^note]:",
4280 "[ref]: http://example.com",
4281 "[wat]: url follows",
4282 ] {
4283 assert!(starts_block_construct(case), "definition: {case:?}");
4284 }
4285 for case in ["<div>content", "</div>", "<p>text", "<table>", "<pre>code", "<h1>x"] {
4287 assert!(starts_block_construct(case), "html block: {case:?}");
4288 }
4289 }
4290
4291 #[test]
4292 fn starts_block_construct_allows_ordinary_prose() {
4293 for case in [
4294 "",
4295 "word",
4296 "-5 degrees",
4297 "--flag",
4298 "-item",
4299 "#hashtag",
4300 "####### seven hashes is not a heading",
4301 "1.5 million",
4302 "1234567890. ten digits is not a list marker",
4303 "1:30 pm",
4304 "*emphasis*",
4305 "**bold** text",
4306 "__bold__ text",
4307 "_emphasis_ text",
4308 "`code` span",
4309 "`` double backtick span ``",
4310 "~~strikethrough~~",
4311 "=x",
4312 "== ==",
4313 "(parenthetical)",
4314 "[link](url)",
4315 "[text][ref] more",
4316 "[bracketed] aside",
4317 "[a](b) [ref]: first bracket is a link, not a label",
4318 "[esc\\]: not a close] text",
4319 "<span>inline</span>",
4320 "<b>bold</b>",
4321 "<https://example.com> autolink",
4322 "<mailto:a@b.com>",
4323 "<notarealtag>",
4324 ] {
4325 assert!(!starts_block_construct(case), "prose: {case:?}");
4326 }
4327 }
4328
4329 #[test]
4330 fn merge_block_construct_continuations_merges_marker_led_lines() {
4331 let lines = vec![
4332 "First sentence?".to_string(),
4333 "- looks like a list item".to_string(),
4334 "Second sentence.".to_string(),
4335 ];
4336 assert_eq!(
4337 merge_block_construct_continuations(lines),
4338 vec![
4339 "First sentence? - looks like a list item".to_string(),
4340 "Second sentence.".to_string(),
4341 ]
4342 );
4343
4344 let lines = vec!["- real list content".to_string(), "continuation".to_string()];
4347 assert_eq!(
4348 merge_block_construct_continuations(lines.clone()),
4349 lines,
4350 "first line must never be merged"
4351 );
4352 }
4353
4354 #[test]
4355 fn wrap_never_starts_a_line_with_a_block_marker() {
4356 let options = ReflowOptions {
4357 line_length: 25,
4358 ..Default::default()
4359 };
4360 let lines = reflow_line(
4363 "Some words here and then - a dash clause that wraps around the limit.",
4364 &options,
4365 );
4366 assert_eq!(
4367 lines,
4368 vec![
4369 "Some words here and",
4370 "then - a dash clause that",
4371 "wraps around the limit."
4372 ]
4373 );
4374
4375 for input in [
4377 "Alpha beta gamma delta epsilon - dash clause here to wrap",
4378 "Alpha beta gamma delta epsilon > quote lookalike here to wrap",
4379 "Alpha beta gamma delta epsilon # heading lookalike here to wrap",
4380 "Alpha beta gamma delta epsilon 1. ordered lookalike here to wrap",
4381 "Alpha beta gamma delta epsilon * star clause here to wrap",
4382 "Alpha beta gamma delta epsilon + plus clause here to wrap",
4383 ] {
4384 for width in 10..40 {
4385 let options = ReflowOptions {
4386 line_length: width,
4387 ..Default::default()
4388 };
4389 for line in reflow_line(input, &options) {
4390 assert!(
4391 !starts_block_construct(&line),
4392 "width {width}: wrapped line opens a block construct: {line:?} (input {input:?})"
4393 );
4394 }
4395 }
4396 }
4397 }
4398
4399 #[test]
4400 fn sentence_per_line_keeps_block_markers_mid_line() {
4401 let options = ReflowOptions {
4402 line_length: 80,
4403 sentence_per_line: true,
4404 ..Default::default()
4405 };
4406 let lines = reflow_line(
4409 "Google Calendar (Can't we get rid of this dependency? - I don't really see the need)",
4410 &options,
4411 );
4412 assert_eq!(
4413 lines,
4414 vec!["Google Calendar (Can't we get rid of this dependency? - I don't really see the need)".to_string()]
4415 );
4416
4417 let lines = reflow_line("See section 4? # is the marker we use. Fine.", &options);
4419 assert_eq!(lines, vec!["See section 4? # is the marker we use.", "Fine."]);
4420
4421 let lines = reflow_line("Is this a problem? > I quote someone here.", &options);
4422 assert_eq!(lines, vec!["Is this a problem? > I quote someone here."]);
4423
4424 let lines = reflow_line("Another case! 1. Not a list. More text follows here.", &options);
4425 for line in &lines {
4426 assert!(
4427 !starts_block_construct(line),
4428 "sentence-per-line output opens a block construct: {line:?}"
4429 );
4430 }
4431 }
4432
4433 #[test]
4434 fn inline_math_directly_after_display_math_stays_atomic() {
4435 let options = ReflowOptions {
4443 line_length: 8,
4444 ..Default::default()
4445 };
4446 let lines = reflow_line("$$a$$$bb cc dd$ x", &options);
4447 assert_eq!(lines, vec!["$$a$$$bb cc dd$".to_string(), "x".to_string()]);
4448 }
4449
4450 #[test]
4451 fn test_code_span_parsing() {
4452 let elements = parse_markdown_elements_inner("`code`", false, false, None);
4454 assert_eq!(elements.len(), 1);
4455 assert!(matches!(&elements[0], Element::Code { content, marker } if content == "code" && marker == "`"));
4456
4457 let elements = parse_markdown_elements_inner("``code``", false, false, None);
4459 assert_eq!(elements.len(), 1);
4460 assert!(matches!(&elements[0], Element::Code { content, marker } if content == "code" && marker == "``"));
4461
4462 let elements = parse_markdown_elements_inner("``code`inside``", false, false, None);
4464 assert_eq!(elements.len(), 1);
4465 assert!(
4466 matches!(&elements[0], Element::Code { content, marker } if content == "code`inside" && marker == "``")
4467 );
4468
4469 let elements = parse_markdown_elements_inner("`` code ``", false, false, None);
4471 assert_eq!(elements.len(), 1);
4472 assert!(matches!(&elements[0], Element::Code { content, marker } if content == " code " && marker == "``"));
4473
4474 let elements = parse_markdown_elements_inner("`unclosed", false, false, None);
4476 assert_eq!(elements.len(), 1);
4477 assert!(matches!(&elements[0], Element::Text(s) if s == "`unclosed"));
4478
4479 let elements = parse_markdown_elements_inner("`unclosed [link](url)", false, false, None);
4481 assert_eq!(elements.len(), 2);
4483 assert!(matches!(&elements[0], Element::Text(s) if s == "`unclosed "));
4484 assert!(matches!(&elements[1], Element::Link(s) if s == "[link](url)"));
4485 }
4486
4487 #[test]
4488 fn test_reflow_performance_long_input() {
4489 let mut text = String::new();
4492 for i in 1..400 {
4493 let backticks = "`".repeat(i);
4494 text.push_str(&backticks);
4495 text.push(' ');
4496 }
4497
4498 let start = std::time::Instant::now();
4499 let elements = parse_markdown_elements_inner(&text, false, false, None);
4500 let duration = start.elapsed();
4501
4502 assert!(duration.as_millis() < 100, "Parsing took too long: {duration:?}");
4504 assert!(!elements.is_empty());
4505 }
4506
4507 #[test]
4508 fn test_reflow_performance_display_math_heavy() {
4509 let text = "$$a$$".repeat(4000);
4514
4515 let start = std::time::Instant::now();
4516 let elements = parse_markdown_elements_inner(&text, false, false, None);
4517 let duration = start.elapsed();
4518
4519 assert!(duration.as_millis() < 100, "Parsing took too long: {duration:?}");
4520 assert_eq!(elements.len(), 4000);
4521 }
4522
4523 #[test]
4524 fn inline_math_len_at_start_matches_regex_at_slice_start() {
4525 let alphabet = ['$', 'a', ' '];
4530 let mut inputs: Vec<String> = vec![String::new()];
4531 let mut frontier: Vec<String> = vec![String::new()];
4532 for _ in 0..6 {
4533 let mut longer = Vec::new();
4534 for prefix in &frontier {
4535 for ch in alphabet {
4536 let mut s = prefix.clone();
4537 s.push(ch);
4538 longer.push(s);
4539 }
4540 }
4541 inputs.extend(longer.iter().cloned());
4542 frontier = longer;
4543 }
4544 inputs.push("$αβ$x".to_string());
4546 inputs.push("$α$$".to_string());
4547
4548 for s in &inputs {
4549 let expected = INLINE_MATH_REGEX
4550 .find(s)
4551 .ok()
4552 .flatten()
4553 .filter(|m| m.start() == 0)
4554 .map(|m| m.end());
4555 assert_eq!(inline_math_len_at_start(s), expected, "input: {s:?}");
4556 }
4557 }
4558
4559 #[test]
4560 fn inline_math_probe_after_dollar_matches_uncached_parse() {
4561 let cases = [
4567 ("$$a$$$b c$ x", r#"[DisplayMath("a"), InlineMath("b c"), Text(" x")]"#),
4568 (
4569 "$$a$$$b$ $$a$$$b$",
4570 r#"[DisplayMath("a"), InlineMath("b"), Text(" "), DisplayMath("a"), InlineMath("b")]"#,
4571 ),
4572 (
4574 "$$a$$$ x $y z$",
4575 r#"[DisplayMath("a"), InlineMath(" x "), Text("y z$")]"#,
4576 ),
4577 ("$$a$$$$ x", r#"[DisplayMath("a"), Text("$$ x")]"#),
4579 ("$$a$$$$b$$ x", r#"[DisplayMath("a"), DisplayMath("b"), Text(" x")]"#),
4580 (
4582 "$a$$b$$c$$d$ tail",
4583 r#"[Text("$a"), DisplayMath("b"), Text("c$$d$ tail")]"#,
4584 ),
4585 ];
4586 for (input, expected) in cases {
4587 let elements = parse_markdown_elements_inner(input, false, false, None);
4588 assert_eq!(format!("{elements:?}"), expected, "input: {input:?}");
4589 }
4590 }
4591
4592 #[test]
4593 fn test_atomic_spans() {
4594 let text_emphasis = "hello **word1 word2**";
4596
4597 let options_disabled = ReflowOptions {
4598 line_length: 18,
4599 atomic_spans: true,
4600 ..Default::default()
4601 };
4602 let lines_disabled = reflow_line(text_emphasis, &options_disabled);
4603 assert_eq!(lines_disabled, vec!["hello", "**word1 word2**"]);
4604
4605 let options_enabled = ReflowOptions {
4606 line_length: 18,
4607 atomic_spans: false,
4608 ..Default::default()
4609 };
4610 let lines_enabled = reflow_line(text_emphasis, &options_enabled);
4611 assert_eq!(lines_enabled, vec!["hello **word1", "word2**"]);
4612
4613 let text_code = "hello `word1 word2`";
4615
4616 let lines_code_disabled = reflow_line(text_code, &options_disabled);
4617 assert_eq!(lines_code_disabled, vec!["hello", "`word1 word2`"]);
4618
4619 let lines_code_enabled = reflow_line(text_code, &options_enabled);
4620 assert_eq!(lines_code_enabled, vec!["hello `word1", "word2`"]);
4621
4622 let text_code_padding = "hello `` `word1` `word2` ``";
4624 let lines_padding_enabled = reflow_line(text_code_padding, &options_enabled);
4625 assert_eq!(lines_padding_enabled, vec![r#"hello `` `word1`"#, r#"`word2` ``"#]);
4626 }
4627
4628 #[test]
4629 fn test_emphasis_containing_markers_is_not_split() {
4630 let options = ReflowOptions {
4631 line_length: 5,
4632 atomic_spans: false,
4633 ..Default::default()
4634 };
4635 let lines = reflow_line(r#"*foo \*bar*"#, &options);
4637 assert_eq!(lines, vec![r#"*foo \*bar*"#.to_string()]);
4638 }
4639
4640 fn semantic_shape(markdown: &str) -> String {
4645 let mut options = Options::empty();
4646 options.insert(Options::ENABLE_STRIKETHROUGH);
4647 let mut out = String::new();
4648 let push_prose = |out: &mut String, text: &str| {
4649 for c in text.chars() {
4650 if c.is_whitespace() {
4651 if !out.ends_with(char::is_whitespace) {
4652 out.push(' ');
4653 }
4654 } else {
4655 out.push(c);
4656 }
4657 }
4658 };
4659 for event in Parser::new_ext(markdown, options) {
4660 match event {
4661 Event::Text(text) => push_prose(&mut out, &text),
4662 Event::SoftBreak | Event::HardBreak => push_prose(&mut out, " "),
4663 Event::Code(code) => out.push_str(&format!("<code>{code}</code>")),
4665 Event::Start(tag) => out.push_str(&format!("<{tag:?}>")),
4666 Event::End(tag) => out.push_str(&format!("</{tag:?}>")),
4667 other => out.push_str(&format!("{other:?}")),
4668 }
4669 }
4670 out.trim().to_string()
4671 }
4672
4673 #[test]
4674 fn test_wrapping_a_span_never_changes_what_it_parses_to() {
4675 let corpus = [
4679 "_This is a very, very, very, very, very long line with some `code` inside._",
4680 "_alpha beta gamma delta epsilon `a b` zeta eta theta iota kappa lambda_",
4681 "**strong text with `code` and more words than fit on one single line**",
4682 "~~struck text with `code` and more words than fit on one single line~~",
4683 "_emphasis with **nested strong that is quite long** and trailing words_",
4684 "_foo `a` bar `b` baz qux quux corge grault garply waldo fred plugh xyzzy_",
4685 "text before _a long emphasis with `code` inside of it here_ and after",
4686 "(_a parenthesized long emphasis with `code` inside of it right here_)",
4687 r#"*foo \*bar baz qux quux corge grault garply waldo fred plugh xyzzy*"#,
4688 "_tab\tseparated `a\tb` words spread out over quite a long emphasis span_",
4689 "_This has [text](<a b c d e f g h i j k>) and trailing words to wrap._",
4692 "_This has [`code`](<a b c d e f g h i j k>) and trailing words to wrap._",
4693 r#"_See [x](https://example.com "a long link title here") and `code` too._"#,
4694 "_A [link with a long label](https://example.com/path) and `code` here._",
4695 "_An image  plus `code` and more text_",
4696 ];
4697 for text in corpus {
4698 let expected = semantic_shape(text);
4699 for line_length in [20, 30, 40, 80] {
4700 for atomic_spans in [true, false] {
4701 let options = ReflowOptions {
4702 line_length,
4703 atomic_spans,
4704 ..Default::default()
4705 };
4706 let wrapped = reflow_line(text, &options).join("\n");
4707 assert_eq!(
4708 semantic_shape(&wrapped),
4709 expected,
4710 "reflow changed the parse of {text:?} at line_length={line_length} \
4711 atomic_spans={atomic_spans}\n wrapped: {wrapped:?}"
4712 );
4713 }
4714 }
4715 }
4716 }
4717
4718 #[test]
4719 fn test_overlong_emphasis_with_nested_code_span_wraps() {
4720 let options = ReflowOptions {
4724 line_length: 80,
4725 atomic_spans: true,
4726 ..Default::default()
4727 };
4728 let text = "_This is a very, very, very, very, very, very, very long line that exceeds 80 characters with some `code` inside._";
4729 let lines = reflow_line(text, &options);
4730 assert_eq!(
4731 lines,
4732 vec![
4733 "_This is a very, very, very, very, very, very, very long line that exceeds 80",
4734 "characters with some `code` inside._",
4735 ]
4736 );
4737 }
4738
4739 #[test]
4740 fn test_overlong_emphasis_with_nested_strong_wraps() {
4741 let options = ReflowOptions {
4743 line_length: 80,
4744 atomic_spans: true,
4745 ..Default::default()
4746 };
4747 let text = "_This is a very, very, very, very, very, very, very long line that exceeds 80 characters with some **bold** inside._";
4748 let lines = reflow_line(text, &options);
4749 assert_eq!(
4750 lines,
4751 vec![
4752 "_This is a very, very, very, very, very, very, very long line that exceeds 80",
4753 "characters with some **bold** inside._",
4754 ]
4755 );
4756 }
4757
4758 #[test]
4759 fn test_overlong_emphasis_never_breaks_inside_nested_code_span() {
4760 let options = ReflowOptions {
4764 line_length: 30,
4765 atomic_spans: true,
4766 ..Default::default()
4767 };
4768 let text = "_alpha beta gamma delta epsilon `a b` zeta eta theta iota kappa_";
4769 let lines = reflow_line(text, &options);
4770 assert!(lines.len() > 1, "over-long emphasis should wrap: {lines:?}");
4771 assert!(
4772 lines.iter().any(|line| line.contains("`a b`")),
4773 "nested code span must stay whole with its interior spaces: {lines:?}"
4774 );
4775 for line in &lines {
4776 assert_eq!(
4777 line.matches('`').count() % 2,
4778 0,
4779 "no line may contain half a code span: {line:?}"
4780 );
4781 }
4782 }
4783
4784 #[test]
4785 fn test_definition_list_marker_does_not_start_line() {
4786 let options = ReflowOptions {
4787 line_length: 20,
4788 ..Default::default()
4789 };
4790 let lines = reflow_line("This is a term and : definition here.", &options);
4792 for line in &lines {
4793 assert!(
4794 !line.trim_start().starts_with(": "),
4795 "Wrapped line should not start with definition marker: {line}"
4796 );
4797 }
4798 }
4799
4800 #[test]
4801 fn test_div_marker_does_not_start_line() {
4802 let options = ReflowOptions {
4803 line_length: 20,
4804 ..Default::default()
4805 };
4806 let lines = reflow_line("This is some text with ::: class marker.", &options);
4808 for line in &lines {
4809 assert!(
4810 !line.trim_start().starts_with(":::"),
4811 "Wrapped line should not start with div marker: {line}"
4812 );
4813 }
4814 }
4815}