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
83struct NestedStructure {
86 atomic: Vec<(usize, usize)>,
93 markers: Vec<(usize, usize)>,
98}
99
100struct OpenSpan {
102 span: (usize, usize),
104 content: Option<(usize, usize)>,
107}
108
109fn note_span_content(open: &mut [OpenSpan], start: usize, end: usize) {
112 for open_span in open.iter_mut() {
113 if start >= open_span.span.0 && end <= open_span.span.1 {
114 open_span.content = Some(match open_span.content {
115 Some((known_start, known_end)) => (known_start.min(start), known_end.max(end)),
116 None => (start, end),
117 });
118 }
119 }
120}
121
122fn merge_ranges(mut ranges: Vec<(usize, usize)>) -> Vec<(usize, usize)> {
123 ranges.sort_unstable();
126 let mut merged: Vec<(usize, usize)> = Vec::with_capacity(ranges.len());
127 for (start, end) in ranges {
128 match merged.last_mut() {
129 Some(last) if start <= last.1 => last.1 = last.1.max(end),
130 _ => merged.push((start, end)),
131 }
132 }
133 merged
134}
135
136fn nested_structure(content: &str, defined_references: Option<&HashSet<String>>, attr_lists: bool) -> NestedStructure {
138 let mut options = Options::empty();
139 options.insert(Options::ENABLE_STRIKETHROUGH);
140
141 let mut atomic: Vec<(usize, usize)> = Vec::new();
142 let mut markers: Vec<(usize, usize)> = Vec::new();
143 let mut open: Vec<OpenSpan> = Vec::new();
146
147 for (event, range) in Parser::new_ext(content, options).into_offset_iter() {
148 let (start, end) = (range.start, range.end);
149 if !matches!(event, Event::End(_)) {
153 note_span_content(&mut open, start, end);
154 }
155 match event {
156 Event::Code(_) | Event::InlineHtml(_) | Event::Start(Tag::Link { .. } | Tag::Image { .. }) => {
157 atomic.push((start, end));
158 }
159 Event::Start(Tag::Emphasis | Tag::Strong | Tag::Strikethrough) => {
160 open.push(OpenSpan {
161 span: (start, end),
162 content: None,
163 });
164 }
165 Event::End(TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough) => {
166 if let Some(OpenSpan {
167 span: (span_start, span_end),
168 content,
169 }) = open.pop()
170 {
171 match content {
172 Some((content_start, content_end)) => {
176 markers.push((span_start, content_start));
177 markers.push((content_end, span_end));
178 }
179 None => atomic.push((span_start, span_end)),
182 }
183 }
184 }
185 _ => {}
186 }
187 }
188
189 for span in extract_link_spans(content, defined_references) {
194 atomic.push((span.start, span.end));
195 }
196
197 for found in WIKI_LINK_REGEX
201 .find_iter(content)
202 .chain(HUGO_SHORTCODE_REGEX.find_iter(content))
203 .chain(DISPLAY_MATH_REGEX.find_iter(content))
204 {
205 atomic.push((found.start(), found.end()));
206 }
207 let mut from = 0;
208 while let Ok(Some(found)) = INLINE_MATH_REGEX.find_from_pos(content, from) {
209 atomic.push((found.start(), found.end()));
210 from = found.end();
211 }
212
213 if attr_lists {
219 for found in ATTR_LIST_PATTERN.find_iter(content) {
220 atomic.push((found.start(), found.end()));
221 }
222 }
223
224 NestedStructure {
225 atomic: merge_ranges(atomic),
226 markers: merge_ranges(markers),
227 }
228}
229
230fn breakable_units<'a>(
253 content: &'a str,
254 defined_references: Option<&HashSet<String>>,
255 attr_lists: bool,
256) -> Option<Vec<&'a str>> {
257 if !content.contains(['`', '*', '_', '~', '[', '<', '$', '{']) {
260 return Some(split_breakable_words(content).collect());
261 }
262
263 let NestedStructure { atomic, markers } = nested_structure(content, defined_references, attr_lists);
264
265 let mut units = Vec::new();
266 let mut unit_start = None;
267 let mut next_atomic = 0;
268 let mut next_marker = 0;
269 for (offset, ch) in content.char_indices() {
270 while atomic.get(next_atomic).is_some_and(|&(_, end)| end <= offset) {
271 next_atomic += 1;
272 }
273 if atomic.get(next_atomic).is_some_and(|&(start, _)| offset >= start) {
274 if unit_start.is_none() {
277 unit_start = Some(offset);
278 }
279 continue;
280 }
281 while markers.get(next_marker).is_some_and(|&(_, end)| end <= offset) {
282 next_marker += 1;
283 }
284 if matches!(ch, '`' | '*' | '_' | '~') && markers.get(next_marker).is_none_or(|&(start, _)| offset < start) {
285 return None;
286 }
287 if is_breakable_whitespace(ch) {
288 if let Some(start) = unit_start.take() {
289 units.push(&content[start..offset]);
290 }
291 } else if unit_start.is_none() {
292 unit_start = Some(offset);
293 }
294 }
295 if let Some(start) = unit_start {
296 units.push(&content[start..]);
297 }
298 Some(units)
299}
300
301#[derive(Clone)]
303pub struct ReflowOptions {
304 pub line_length: usize,
306 pub break_on_sentences: bool,
308 pub preserve_breaks: bool,
310 pub sentence_per_line: bool,
312 pub semantic_line_breaks: bool,
314 pub abbreviations: Option<Vec<String>>,
318 pub length_mode: ReflowLengthMode,
320 pub attr_lists: bool,
323 pub myst_roles: bool,
327 pub require_sentence_capital: bool,
332 pub max_list_continuation_indent: Option<usize>,
336 pub defined_references: Option<HashSet<String>>,
350 pub atomic_spans: bool,
354}
355
356impl Default for ReflowOptions {
357 fn default() -> Self {
358 Self {
359 line_length: 80,
360 break_on_sentences: true,
361 preserve_breaks: false,
362 sentence_per_line: false,
363 semantic_line_breaks: false,
364 abbreviations: None,
365 length_mode: ReflowLengthMode::default(),
366 attr_lists: false,
367 myst_roles: false,
368 require_sentence_capital: true,
369 max_list_continuation_indent: None,
370 defined_references: None,
371 atomic_spans: true,
372 }
373 }
374}
375
376pub fn normalize_reference_label(label: &str) -> String {
383 label.split_whitespace().collect::<Vec<_>>().join(" ").to_lowercase()
384}
385
386fn footnote_refs_end(chars: &[char], start: usize) -> Option<usize> {
392 let mut pos = start;
393 let mut found = false;
394
395 loop {
396 if chars.get(pos) != Some(&'[') || chars.get(pos + 1) != Some(&'^') {
397 break;
398 }
399 let label_start = pos + 2;
400 let mut label_end = label_start;
401 while matches!(chars.get(label_end), Some(c) if c.is_ascii_alphanumeric() || *c == '_' || *c == '-') {
402 label_end += 1;
403 }
404 if label_end == label_start || chars.get(label_end) != Some(&']') {
405 break;
406 }
407 pos = label_end + 1;
408 found = true;
409 }
410
411 found.then_some(pos)
412}
413
414fn is_sentence_boundary(
418 text: &str,
419 chars: &[char],
420 pos: usize,
421 byte_offset_after_punct: usize,
422 abbreviations: &HashSet<String>,
423 require_sentence_capital: bool,
424) -> bool {
425 if pos + 1 >= chars.len() {
426 return false;
427 }
428
429 let c = chars[pos];
430 let next_char = chars[pos + 1];
431
432 if is_cjk_sentence_ending(c) {
435 let mut after_punct_pos = pos + 1;
437 while after_punct_pos < chars.len()
438 && (chars[after_punct_pos] == '*' || chars[after_punct_pos] == '_' || chars[after_punct_pos] == '~')
439 {
440 after_punct_pos += 1;
441 }
442
443 while after_punct_pos < chars.len() && chars[after_punct_pos].is_whitespace() {
445 after_punct_pos += 1;
446 }
447
448 if after_punct_pos >= chars.len() {
450 return false;
451 }
452
453 while after_punct_pos < chars.len()
455 && (chars[after_punct_pos] == '*' || chars[after_punct_pos] == '_' || chars[after_punct_pos] == '~')
456 {
457 after_punct_pos += 1;
458 }
459
460 if after_punct_pos >= chars.len() {
461 return false;
462 }
463
464 return true;
467 }
468
469 if c != '.' && c != '!' && c != '?' {
471 return false;
472 }
473
474 let (_space_pos, after_space_pos) = if next_char == ' ' {
476 (pos + 1, pos + 2)
478 } else if is_closing_quote(next_char) && pos + 2 < chars.len() {
479 if chars[pos + 2] == ' ' {
481 (pos + 2, pos + 3)
483 } else if (chars[pos + 2] == '*' || chars[pos + 2] == '_') && pos + 3 < chars.len() && chars[pos + 3] == ' ' {
484 (pos + 3, pos + 4)
486 } else if (chars[pos + 2] == '*' || chars[pos + 2] == '_')
487 && pos + 4 < chars.len()
488 && chars[pos + 3] == chars[pos + 2]
489 && chars[pos + 4] == ' '
490 {
491 (pos + 4, pos + 5)
493 } else {
494 return false;
495 }
496 } else if (next_char == '*' || next_char == '_') && pos + 2 < chars.len() && chars[pos + 2] == ' ' {
497 (pos + 2, pos + 3)
499 } else if (next_char == '*' || next_char == '_')
500 && pos + 3 < chars.len()
501 && chars[pos + 2] == next_char
502 && chars[pos + 3] == ' '
503 {
504 (pos + 3, pos + 4)
506 } else if next_char == '~' && pos + 3 < chars.len() && chars[pos + 2] == '~' && chars[pos + 3] == ' ' {
507 (pos + 3, pos + 4)
509 } else if next_char == '[' {
510 match footnote_refs_end(chars, pos + 1) {
516 Some(end_pos) if chars.get(end_pos) == Some(&' ') => (end_pos, end_pos + 1),
517 _ => return false,
518 }
519 } else {
520 return false;
521 };
522
523 let mut next_char_pos = after_space_pos;
525 while next_char_pos < chars.len() && chars[next_char_pos].is_whitespace() {
526 next_char_pos += 1;
527 }
528
529 if next_char_pos >= chars.len() {
531 return false;
532 }
533
534 let mut first_letter_pos = next_char_pos;
536 while first_letter_pos < chars.len()
537 && (chars[first_letter_pos] == '*'
538 || chars[first_letter_pos] == '_'
539 || chars[first_letter_pos] == '~'
540 || is_opening_quote(chars[first_letter_pos]))
541 {
542 first_letter_pos += 1;
543 }
544
545 if first_letter_pos >= chars.len() {
547 return false;
548 }
549
550 let first_char = chars[first_letter_pos];
551
552 if c == '!' || c == '?' {
554 return true;
555 }
556
557 if pos > 0 {
561 if text_ends_with_abbreviation(&text[..byte_offset_after_punct], abbreviations) {
563 return false;
564 }
565
566 if chars[pos - 1].is_numeric() && first_char.is_ascii_digit() {
568 return false;
569 }
570
571 if chars[pos - 1].is_ascii_uppercase() && (pos == 1 || (pos >= 2 && chars[pos - 2].is_whitespace())) {
575 return false;
576 }
577 }
578
579 if require_sentence_capital && !first_char.is_uppercase() && !is_cjk_char(first_char) {
582 return false;
583 }
584
585 true
586}
587
588pub fn split_into_sentences(text: &str) -> Vec<String> {
590 split_into_sentences_custom(text, &None)
591}
592
593pub fn split_into_sentences_custom(text: &str, custom_abbreviations: &Option<Vec<String>>) -> Vec<String> {
595 let abbreviations = get_abbreviations(custom_abbreviations);
596 split_into_sentences_with_set(text, &abbreviations, true)
597}
598
599fn split_into_sentences_with_set(
602 text: &str,
603 abbreviations: &HashSet<String>,
604 require_sentence_capital: bool,
605) -> Vec<String> {
606 let char_vec: Vec<char> = text.chars().collect();
607
608 let mut char_offsets = Vec::with_capacity(char_vec.len() + 1);
612 let mut offset = 0;
613 for c in &char_vec {
614 char_offsets.push(offset);
615 offset += c.len_utf8();
616 }
617 char_offsets.push(offset);
618
619 let code_spans = extract_code_spans(text);
621 let mut span_it = code_spans.iter().peekable();
622
623 let mut sentences = Vec::new();
624 let mut current_sentence = String::new();
625 let mut pos = 0;
626
627 while pos < char_vec.len() {
628 let c = char_vec[pos];
629 current_sentence.push(c);
630
631 let byte_idx = char_offsets[pos];
632
633 while let Some(span) = span_it.peek() {
635 if span.end <= byte_idx {
636 span_it.next();
637 } else {
638 break;
639 }
640 }
641
642 let in_code = if let Some(span) = span_it.peek() {
644 byte_idx >= span.start && byte_idx < span.end
645 } else {
646 false
647 };
648
649 if !in_code
650 && is_sentence_boundary(
651 text,
652 &char_vec,
653 pos,
654 char_offsets[pos + 1],
655 abbreviations,
656 require_sentence_capital,
657 )
658 {
659 if let Some(end_pos) = footnote_refs_end(&char_vec, pos + 1) {
661 while pos + 1 < end_pos {
662 pos += 1;
663 current_sentence.push(char_vec[pos]);
664 }
665 }
666
667 while pos + 1 < char_vec.len() {
669 let next = char_vec[pos + 1];
670 if next == '*' || next == '_' || next == '~' || is_closing_quote(next) {
671 pos += 1;
672 current_sentence.push(char_vec[pos]);
673 } else {
674 break;
675 }
676 }
677
678 if pos + 1 < char_vec.len() && char_vec[pos + 1] == ' ' {
680 pos += 1; }
682
683 sentences.push(current_sentence.trim().to_string());
684 current_sentence.clear();
685 }
686
687 pos += 1;
688 }
689
690 if !current_sentence.trim().is_empty() {
692 sentences.push(current_sentence.trim().to_string());
693 }
694 sentences
695}
696
697fn is_horizontal_rule(line: &str) -> bool {
699 if line.len() < 3 {
700 return false;
701 }
702
703 let mut chars = line.chars();
706 let Some(first_char) = chars.next() else {
707 return false;
708 };
709 if first_char != '-' && first_char != '_' && first_char != '*' {
710 return false;
711 }
712
713 let mut non_space_count = 1usize; for c in chars {
715 if c == ' ' {
716 continue;
717 }
718 if c != first_char {
719 return false;
720 }
721 non_space_count += 1;
722 }
723 non_space_count >= 3
724}
725
726fn is_numbered_list_item(line: &str) -> bool {
728 let mut chars = line.chars();
729
730 if !chars.next().is_some_and(char::is_numeric) {
732 return false;
733 }
734
735 while let Some(c) = chars.next() {
737 if c == '.' {
738 return chars.next() == Some(' ');
741 }
742 if !c.is_numeric() {
743 return false;
744 }
745 }
746
747 false
748}
749
750fn is_unordered_list_marker(s: &str) -> bool {
752 matches!(s.as_bytes().first(), Some(b'-' | b'*' | b'+'))
753 && !is_horizontal_rule(s)
754 && (s.len() == 1 || s.as_bytes().get(1) == Some(&b' '))
755}
756
757fn is_block_boundary_core(trimmed: &str) -> bool {
760 trimmed.is_empty()
761 || trimmed.starts_with('#')
762 || trimmed.starts_with("```")
763 || trimmed.starts_with("~~~")
764 || trimmed.starts_with('>')
765 || (trimmed.starts_with('[') && trimmed.contains("]:"))
766 || is_horizontal_rule(trimmed)
767 || is_unordered_list_marker(trimmed)
768 || is_numbered_list_item(trimmed)
769 || is_definition_list_item(trimmed)
770 || trimmed.starts_with(":::")
771}
772
773fn is_block_boundary(trimmed: &str) -> bool {
776 is_block_boundary_core(trimmed) || trimmed.starts_with('|')
777}
778
779fn is_paragraph_boundary(trimmed: &str, line: &str) -> bool {
783 is_block_boundary_core(trimmed)
784 || calculate_indentation_width_default(line) >= 4
785 || crate::utils::table_utils::TableUtils::is_potential_table_row(line)
786}
787
788fn has_hard_break(line: &str) -> bool {
794 let line = line.strip_suffix('\r').unwrap_or(line);
795 line.ends_with(" ") || line.ends_with('\\')
796}
797
798fn ends_with_sentence_punct(text: &str) -> bool {
800 text.ends_with('.') || text.ends_with('!') || text.ends_with('?')
801}
802
803fn trim_preserving_hard_break(s: &str) -> String {
809 let s = s.strip_suffix('\r').unwrap_or(s);
811
812 if s.ends_with('\\') {
814 return s.to_string();
816 }
817
818 if s.ends_with(" ") {
820 let content_end = s.trim_end().len();
822 if content_end == 0 {
823 return String::new();
825 }
826 format!("{} ", &s[..content_end])
828 } else {
829 s.trim_end().to_string()
831 }
832}
833
834fn parse_elements(text: &str, options: &ReflowOptions) -> Vec<Element> {
836 parse_markdown_elements_inner(
837 text,
838 options.attr_lists,
839 options.myst_roles,
840 options.defined_references.as_ref(),
841 )
842}
843
844pub fn reflow_line(line: &str, options: &ReflowOptions) -> Vec<String> {
854 let reflowed = reflow_line_unchecked(line, options);
855 if preserves_content(line, &reflowed) {
856 reflowed
857 } else {
858 vec![line.to_string()]
859 }
860}
861
862fn preserves_content(original: &str, reflowed: &[String]) -> bool {
869 let (original_text, original_breaks) = visible_text_and_breaks(original.chars());
870 let (reflowed_text, reflowed_breaks) =
871 visible_text_and_breaks(reflowed.iter().flat_map(|line| line.chars().chain(['\n'])));
872
873 original_text == reflowed_text && contains_all(&reflowed_breaks, &original_breaks)
874}
875
876fn visible_text_and_breaks(text: impl Iterator<Item = char>) -> (String, Vec<usize>) {
879 let mut visible = String::new();
880 let mut breaks = Vec::new();
881 let mut count = 0usize;
882 let mut pending_break = false;
883
884 for c in text {
885 if c.is_whitespace() {
886 pending_break = count > 0;
887 } else {
888 if pending_break {
889 breaks.push(count);
890 pending_break = false;
891 }
892 visible.push(c);
893 count += 1;
894 }
895 }
896
897 (visible, breaks)
898}
899
900fn contains_all(superset: &[usize], subset: &[usize]) -> bool {
902 let mut candidates = superset.iter();
903 subset
904 .iter()
905 .all(|wanted| candidates.by_ref().any(|found| found == wanted))
906}
907
908fn reflow_line_unchecked(line: &str, options: &ReflowOptions) -> Vec<String> {
909 if options.sentence_per_line {
911 let elements = parse_elements(line, options);
912 return merge_block_construct_continuations(reflow_elements_sentence_per_line(
913 &elements,
914 &options.abbreviations,
915 options.require_sentence_capital,
916 ));
917 }
918
919 if options.semantic_line_breaks {
921 let elements = parse_elements(line, options);
922 return merge_block_construct_continuations(reflow_elements_semantic(&elements, options));
923 }
924
925 if options.line_length == 0 || display_len(line, options.length_mode) <= options.line_length {
928 return vec![line.to_string()];
929 }
930
931 let elements = parse_elements(line, options);
933
934 merge_block_construct_continuations(reflow_elements(&elements, options))
936}
937
938#[derive(Debug, Clone)]
940enum Element {
941 Text(String),
943 Link(String),
945 ReferenceLink(String),
947 EmptyReferenceLink(String),
949 ShortcutReference(String),
951 InlineImage(String),
953 ReferenceImage(String),
955 EmptyReferenceImage(String),
957 LinkedImage(String),
959 FootnoteReference(String),
961 Strikethrough {
963 content: String,
964 double: bool,
966 },
967 WikiLink(String),
969 InlineMath(String),
971 DisplayMath(String),
973 EmojiShortcode(String),
975 Autolink(String),
977 HtmlTag(String),
979 HtmlEntity(String),
981 HugoShortcode(String),
983 AttrList(String),
985 MystRole(String),
989 Code { content: String, marker: String },
991 Bold {
993 content: String,
994 underscore: bool,
996 },
997 Italic {
999 content: String,
1000 underscore: bool,
1002 },
1003}
1004
1005impl std::fmt::Display for Element {
1006 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1007 match self {
1008 Element::Text(s) => write!(f, "{s}"),
1009 Element::Link(s) => write!(f, "{s}"),
1010 Element::ReferenceLink(s) => write!(f, "{s}"),
1011 Element::EmptyReferenceLink(s) => write!(f, "{s}"),
1012 Element::ShortcutReference(s) => write!(f, "{s}"),
1013 Element::InlineImage(s) => write!(f, "{s}"),
1014 Element::ReferenceImage(s) => write!(f, "{s}"),
1015 Element::EmptyReferenceImage(s) => write!(f, "{s}"),
1016 Element::LinkedImage(s) => write!(f, "{s}"),
1017 Element::FootnoteReference(s) => write!(f, "{s}"),
1018 Element::Strikethrough { content, double } => {
1019 let marker = if *double { "~~" } else { "~" };
1020 write!(f, "{marker}{content}{marker}")
1021 }
1022 Element::WikiLink(s) => write!(f, "[[{s}]]"),
1023 Element::InlineMath(s) => write!(f, "${s}$"),
1024 Element::DisplayMath(s) => write!(f, "$${s}$$"),
1025 Element::EmojiShortcode(s) => write!(f, ":{s}:"),
1026 Element::Autolink(s) => write!(f, "{s}"),
1027 Element::HtmlTag(s) => write!(f, "{s}"),
1028 Element::HtmlEntity(s) => write!(f, "{s}"),
1029 Element::HugoShortcode(s) => write!(f, "{s}"),
1030 Element::AttrList(s) => write!(f, "{s}"),
1031 Element::MystRole(s) => write!(f, "{s}"),
1032 Element::Code { content, marker } => write!(f, "{marker}{content}{marker}"),
1033 Element::Bold { content, underscore } => {
1034 if *underscore {
1035 write!(f, "__{content}__")
1036 } else {
1037 write!(f, "**{content}**")
1038 }
1039 }
1040 Element::Italic { content, underscore } => {
1041 if *underscore {
1042 write!(f, "_{content}_")
1043 } else {
1044 write!(f, "*{content}*")
1045 }
1046 }
1047 }
1048 }
1049}
1050
1051impl Element {
1052 fn display_len(&self, mode: ReflowLengthMode) -> usize {
1053 match self {
1054 Element::Text(s)
1055 | Element::Link(s)
1056 | Element::ReferenceLink(s)
1057 | Element::EmptyReferenceLink(s)
1058 | Element::ShortcutReference(s)
1059 | Element::InlineImage(s)
1060 | Element::ReferenceImage(s)
1061 | Element::EmptyReferenceImage(s)
1062 | Element::LinkedImage(s)
1063 | Element::FootnoteReference(s)
1064 | Element::Autolink(s)
1065 | Element::HtmlTag(s)
1066 | Element::HtmlEntity(s)
1067 | Element::HugoShortcode(s)
1068 | Element::AttrList(s)
1069 | Element::MystRole(s) => display_len(s, mode),
1070 Element::WikiLink(s) => display_len(s, mode) + 4,
1071 Element::InlineMath(s) => display_len(s, mode) + 2,
1072 Element::DisplayMath(s) => display_len(s, mode) + 4,
1073 Element::EmojiShortcode(s) => display_len(s, mode) + 2,
1074 Element::Strikethrough { content, double } => display_len(content, mode) + if *double { 4 } else { 2 },
1075 Element::Code { content, marker } => display_len(content, mode) + display_len(marker, mode) * 2,
1076 Element::Bold { content, .. } => display_len(content, mode) + 4,
1077 Element::Italic { content, .. } => display_len(content, mode) + 2,
1078 }
1079 }
1080}
1081
1082#[derive(Debug, Clone)]
1084struct EmphasisSpan {
1085 start: usize,
1087 end: usize,
1089 content: String,
1091 is_strong: bool,
1093 is_strikethrough: bool,
1095 uses_underscore: bool,
1097 strikethrough_double: bool,
1100}
1101
1102fn extract_emphasis_and_code_spans(text: &str) -> (Vec<EmphasisSpan>, Vec<CodeSpan>) {
1112 let has_emphasis = text.contains(['*', '_', '~']);
1114 let has_code = text.contains('`');
1115 if !has_emphasis && !has_code {
1116 return (Vec::new(), Vec::new());
1117 }
1118
1119 let mut emphasis_spans = Vec::new();
1120 let mut code_spans = Vec::new();
1121
1122 let mut options = Options::empty();
1123 if has_emphasis {
1124 options.insert(Options::ENABLE_STRIKETHROUGH);
1125 }
1126
1127 let mut emphasis_stack: Vec<(usize, bool)> = Vec::new(); let mut strong_stack: Vec<(usize, bool)> = Vec::new();
1130 let mut strikethrough_stack: Vec<usize> = Vec::new();
1131
1132 let parser = Parser::new_ext(text, options).into_offset_iter();
1133
1134 for (event, range) in parser {
1135 match event {
1136 Event::Code(_) => {
1137 code_spans.push(CodeSpan {
1138 start: range.start,
1139 end: range.end,
1140 });
1141 }
1142 Event::Start(Tag::Emphasis) => {
1143 let uses_underscore = text.get(range.start..range.start + 1) == Some("_");
1145 emphasis_stack.push((range.start, uses_underscore));
1146 }
1147 Event::End(TagEnd::Emphasis) => {
1148 if let Some((start_byte, uses_underscore)) = emphasis_stack.pop() {
1149 let content_start = start_byte + 1;
1150 let content_end = range.end - 1;
1151 if content_end > content_start
1152 && let Some(content) = text.get(content_start..content_end)
1153 {
1154 emphasis_spans.push(EmphasisSpan {
1155 start: start_byte,
1156 end: range.end,
1157 content: content.to_string(),
1158 is_strong: false,
1159 is_strikethrough: false,
1160 uses_underscore,
1161 strikethrough_double: false,
1162 });
1163 }
1164 }
1165 }
1166 Event::Start(Tag::Strong) => {
1167 let uses_underscore = text.get(range.start..range.start + 2) == Some("__");
1168 strong_stack.push((range.start, uses_underscore));
1169 }
1170 Event::End(TagEnd::Strong) => {
1171 if let Some((start_byte, uses_underscore)) = strong_stack.pop() {
1172 let content_start = start_byte + 2;
1173 let content_end = range.end - 2;
1174 if content_end > content_start
1175 && let Some(content) = text.get(content_start..content_end)
1176 {
1177 emphasis_spans.push(EmphasisSpan {
1178 start: start_byte,
1179 end: range.end,
1180 content: content.to_string(),
1181 is_strong: true,
1182 is_strikethrough: false,
1183 uses_underscore,
1184 strikethrough_double: false,
1185 });
1186 }
1187 }
1188 }
1189 Event::Start(Tag::Strikethrough) => {
1190 strikethrough_stack.push(range.start);
1191 }
1192 Event::End(TagEnd::Strikethrough) => {
1193 if let Some(start_byte) = strikethrough_stack.pop() {
1194 let double = text.get(start_byte..start_byte + 2) == Some("~~");
1195 let marker_len = if double { 2 } else { 1 };
1196 let content_start = start_byte + marker_len;
1197 let content_end = range.end - marker_len;
1198 if content_end > content_start
1199 && let Some(content) = text.get(content_start..content_end)
1200 {
1201 emphasis_spans.push(EmphasisSpan {
1202 start: start_byte,
1203 end: range.end,
1204 content: content.to_string(),
1205 is_strong: false,
1206 is_strikethrough: true,
1207 uses_underscore: false,
1208 strikethrough_double: double,
1209 });
1210 }
1211 }
1212 }
1213 _ => {}
1214 }
1215 }
1216
1217 emphasis_spans.sort_by_key(|s| s.start);
1218 (emphasis_spans, code_spans)
1219}
1220
1221#[derive(Debug, Clone)]
1222struct CodeSpan {
1223 start: usize,
1224 end: usize,
1225}
1226
1227fn extract_code_spans(text: &str) -> Vec<CodeSpan> {
1228 if !text.contains('`') {
1230 return Vec::new();
1231 }
1232
1233 let mut spans = Vec::new();
1234 let parser = Parser::new(text).into_offset_iter();
1235 for (event, range) in parser {
1236 if let Event::Code(_) = event {
1237 spans.push(CodeSpan {
1238 start: range.start,
1239 end: range.end,
1240 });
1241 }
1242 }
1243 spans
1244}
1245
1246#[derive(Debug, Clone)]
1247struct LinkSpan {
1248 start: usize,
1249 end: usize,
1250 link_type: Option<LinkType>,
1251 is_image: bool,
1252 is_footnote: bool,
1253}
1254
1255fn extract_link_spans(text: &str, defined_references: Option<&HashSet<String>>) -> Vec<LinkSpan> {
1256 if !text.contains('[') {
1259 return Vec::new();
1260 }
1261
1262 let mut spans = Vec::new();
1263 let mut options = Options::empty();
1264 options.insert(Options::ENABLE_FOOTNOTES);
1265
1266 let resolve = move |link: BrokenLink<'_>| -> Option<(CowStr<'_>, CowStr<'_>)> {
1283 let atomic = match link.link_type {
1288 LinkType::Shortcut | LinkType::ShortcutUnknown => match defined_references {
1289 Some(defs) => defs.contains(&normalize_reference_label(link.reference.as_ref())),
1290 None => true,
1291 },
1292 _ => true,
1293 };
1294 atomic.then_some((CowStr::Borrowed(""), CowStr::Borrowed("")))
1295 };
1296 let parser = Parser::new_with_broken_link_callback(text, options, Some(resolve)).into_offset_iter();
1297 let mut stack = Vec::new();
1298
1299 for (event, range) in parser {
1300 match event {
1301 Event::Start(Tag::Link { link_type, .. }) => {
1302 stack.push((range.start, Some(link_type), false));
1303 }
1304 Event::Start(Tag::Image { link_type, .. }) => {
1305 stack.push((range.start, Some(link_type), true));
1306 }
1307 Event::End(TagEnd::Link) => {
1308 if let Some((start_byte, link_type, is_image)) = stack.pop()
1309 && stack.is_empty()
1310 {
1311 spans.push(LinkSpan {
1312 start: start_byte,
1313 end: range.end,
1314 link_type,
1315 is_image,
1316 is_footnote: false,
1317 });
1318 }
1319 }
1320 Event::End(TagEnd::Image) => {
1321 if let Some((start_byte, link_type, is_image)) = stack.pop()
1322 && stack.is_empty()
1323 {
1324 spans.push(LinkSpan {
1325 start: start_byte,
1326 end: range.end,
1327 link_type,
1328 is_image,
1329 is_footnote: false,
1330 });
1331 }
1332 }
1333 Event::FootnoteReference(_) if stack.is_empty() => {
1334 spans.push(LinkSpan {
1335 start: range.start,
1336 end: range.end,
1337 link_type: None,
1338 is_image: false,
1339 is_footnote: true,
1340 });
1341 }
1342 _ => {}
1343 }
1344 }
1345
1346 spans.sort_by_key(|s| s.start);
1347 spans
1348}
1349
1350fn myst_role_len_at(text: &str, absolute_pos: usize, code_spans: &[CodeSpan]) -> Option<usize> {
1358 let bytes = text.as_bytes();
1359 if bytes.first() != Some(&b'{') {
1360 return None;
1361 }
1362
1363 let mut j = 1;
1365 match bytes.get(j) {
1366 Some(&b) if b.is_ascii_alphabetic() || b == b'_' => {}
1367 _ => return None,
1368 }
1369 while let Some(&b) = bytes.get(j) {
1370 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b':' | b'.') {
1371 j += 1;
1372 } else {
1373 break;
1374 }
1375 }
1376 if bytes.get(j) != Some(&b'}') {
1377 return None;
1378 }
1379 j += 1; let code_span_start = absolute_pos + j;
1383 if let Ok(idx) = code_spans.binary_search_by_key(&code_span_start, |span| span.start) {
1384 let span = &code_spans[idx];
1385 let code_span_len = span.end - span.start;
1386 return Some(j + code_span_len);
1387 }
1388
1389 None
1390}
1391
1392fn inline_math_len_at_start(s: &str) -> Option<usize> {
1399 let bytes = s.as_bytes();
1400 if bytes.first() != Some(&b'$') || bytes.get(1) == Some(&b'$') {
1402 return None;
1403 }
1404 let close = 1 + s[1..].find('$')?;
1407 if bytes.get(close + 1) == Some(&b'$') {
1409 return None;
1410 }
1411 Some(close + 1)
1412}
1413
1414#[derive(Clone, Copy, Debug)]
1416struct PatternMatch {
1417 start: usize,
1418 end: usize,
1419}
1420
1421#[derive(Clone, Copy)]
1435enum PatternCache {
1436 Unsearched,
1437 NotFound,
1438 Found(PatternMatch),
1439}
1440
1441impl PatternCache {
1442 fn earliest_in(
1446 &mut self,
1447 remaining: &str,
1448 cursor: usize,
1449 find: impl FnOnce(&str) -> Option<(usize, usize)>,
1450 ) -> Option<(usize, usize)> {
1451 let stale = match self {
1452 PatternCache::Found(pm) => pm.start < cursor,
1453 PatternCache::NotFound => false,
1454 PatternCache::Unsearched => true,
1455 };
1456 if stale {
1457 *self = match find(remaining) {
1458 Some((start, end)) => PatternCache::Found(PatternMatch {
1459 start: cursor + start,
1460 end: cursor + end,
1461 }),
1462 None => PatternCache::NotFound,
1463 };
1464 }
1465 match self {
1466 PatternCache::Found(pm) => Some((pm.start - cursor, pm.end - cursor)),
1467 _ => None,
1468 }
1469 }
1470}
1471
1472fn parse_markdown_elements_inner(
1483 text: &str,
1484 attr_lists: bool,
1485 myst_roles: bool,
1486 defined_references: Option<&HashSet<String>>,
1487) -> Vec<Element> {
1488 let mut elements = Vec::new();
1489 let mut remaining = text;
1490
1491 let (emphasis_spans, code_spans) = extract_emphasis_and_code_spans(text);
1496 let link_spans = extract_link_spans(text, defined_references);
1497
1498 let mut cached_wiki_link = PatternCache::Unsearched;
1501 let mut cached_display_math = PatternCache::Unsearched;
1502 let mut cached_inline_math = PatternCache::Unsearched;
1503 let mut cached_emoji = PatternCache::Unsearched;
1504 let mut cached_html_entity = PatternCache::Unsearched;
1505 let mut cached_hugo_shortcode = PatternCache::Unsearched;
1506 let mut cached_html_tag = PatternCache::Unsearched;
1507 let mut cached_next_curly = PatternCache::Unsearched;
1508
1509 let mut link_span_idx = 0usize;
1513 let mut emphasis_span_idx = 0usize;
1514 let mut code_span_idx = 0usize;
1515
1516 while !remaining.is_empty() {
1517 let current_offset = text.len() - remaining.len();
1519 let mut earliest_match: Option<(usize, usize, &str)> = None;
1522
1523 while link_span_idx < link_spans.len() && link_spans[link_span_idx].start < current_offset {
1525 link_span_idx += 1;
1526 }
1527 let next_link: Option<&LinkSpan> = link_spans.get(link_span_idx);
1528
1529 if let Some(span) = next_link {
1530 let pos_in_remaining = span.start - current_offset;
1531 if earliest_match
1532 .as_ref()
1533 .is_none_or(|(start, _, _)| pos_in_remaining < *start)
1534 {
1535 let match_end = span.end - current_offset;
1536 earliest_match = Some((pos_in_remaining, match_end, "link_span"));
1537 }
1538 }
1539
1540 if let Some((start, end)) = cached_wiki_link.earliest_in(remaining, current_offset, |suffix| {
1542 WIKI_LINK_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1543 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1544 {
1545 earliest_match = Some((start, end, "wiki_link"));
1546 }
1547
1548 if let Some((start, end)) = cached_display_math.earliest_in(remaining, current_offset, |suffix| {
1550 DISPLAY_MATH_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1551 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1552 {
1553 earliest_match = Some((start, end, "display_math"));
1554 }
1555
1556 let inline_math_probe = if current_offset > 0 && text.as_bytes()[current_offset - 1] == b'$' {
1570 inline_math_len_at_start(remaining).map(|len| (0, len))
1571 } else {
1572 None
1573 };
1574 if let Some((start, end)) = inline_math_probe.or_else(|| {
1575 cached_inline_math.earliest_in(remaining, current_offset, |suffix| {
1576 INLINE_MATH_REGEX
1577 .find(suffix)
1578 .ok()
1579 .flatten()
1580 .map(|m| (m.start(), m.end()))
1581 })
1582 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1583 {
1584 earliest_match = Some((start, end, "inline_math"));
1585 }
1586
1587 if let Some((start, end)) = cached_emoji.earliest_in(remaining, current_offset, |suffix| {
1589 EMOJI_SHORTCODE_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1590 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1591 {
1592 earliest_match = Some((start, end, "emoji"));
1593 }
1594
1595 if let Some((start, end)) = cached_html_entity.earliest_in(remaining, current_offset, |suffix| {
1597 HTML_ENTITY_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1598 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1599 {
1600 earliest_match = Some((start, end, "html_entity"));
1601 }
1602
1603 if let Some((start, end)) = cached_hugo_shortcode.earliest_in(remaining, current_offset, |suffix| {
1606 HUGO_SHORTCODE_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1607 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1608 {
1609 earliest_match = Some((start, end, "hugo_shortcode"));
1610 }
1611
1612 if let Some((start, end)) = cached_html_tag.earliest_in(remaining, current_offset, |suffix| {
1619 let mut from = 0;
1620 while let Some(m) = HTML_TAG_PATTERN.find(&suffix[from..]) {
1621 let (tag_start, tag_end) = (from + m.start(), from + m.end());
1622 let tag = &suffix[tag_start..tag_end];
1623 let is_url_autolink = tag.starts_with("<http://")
1625 || tag.starts_with("<https://")
1626 || tag.starts_with("<mailto:")
1627 || tag.starts_with("<ftp://")
1628 || tag.starts_with("<ftps://");
1629 let is_email_autolink = {
1632 let content = tag.trim_start_matches('<').trim_end_matches('>');
1633 EMAIL_PATTERN.is_match(content)
1634 };
1635 if is_url_autolink || is_email_autolink {
1636 from = tag_end;
1637 } else {
1638 return Some((tag_start, tag_end));
1639 }
1640 }
1641 None
1642 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1643 {
1644 earliest_match = Some((start, end, "html_tag"));
1645 }
1646
1647 let mut next_special = remaining.len();
1649 let mut special_type = "";
1650 let mut pulldown_emphasis: Option<&EmphasisSpan> = None;
1651 let mut attr_list_len: usize = 0;
1652 let mut myst_role_len: usize = 0;
1653
1654 while code_span_idx < code_spans.len() && code_spans[code_span_idx].start < current_offset {
1656 code_span_idx += 1;
1657 }
1658 let next_code_span: Option<&CodeSpan> = code_spans.get(code_span_idx);
1659 if let Some(span) = next_code_span {
1660 let pos_in_remaining = span.start - current_offset;
1661 if pos_in_remaining < next_special {
1662 next_special = pos_in_remaining;
1663 special_type = "pulldown_code";
1664 }
1665 }
1666
1667 let next_curly_pos = cached_next_curly
1670 .earliest_in(remaining, current_offset, |suffix| {
1671 suffix.find('{').map(|pos| (pos, pos + 1))
1672 })
1673 .map(|(start, _)| start);
1674
1675 if myst_roles
1680 && let Some(pos) = next_curly_pos
1681 && pos < next_special
1682 && let Some(role_len) = myst_role_len_at(&remaining[pos..], current_offset + pos, &code_spans)
1683 {
1684 next_special = pos;
1685 special_type = "myst_role";
1686 myst_role_len = role_len;
1687 }
1688
1689 if attr_lists
1691 && let Some(pos) = next_curly_pos
1692 && pos < next_special
1693 && let Some(m) = ATTR_LIST_PATTERN.find(&remaining[pos..])
1694 && m.start() == 0
1695 {
1696 next_special = pos;
1697 special_type = "attr_list";
1698 attr_list_len = m.end();
1699 }
1700
1701 while emphasis_span_idx < emphasis_spans.len() && emphasis_spans[emphasis_span_idx].start < current_offset {
1703 emphasis_span_idx += 1;
1704 }
1705 if let Some(span) = emphasis_spans.get(emphasis_span_idx) {
1706 let pos_in_remaining = span.start - current_offset;
1707 if pos_in_remaining < next_special {
1708 next_special = pos_in_remaining;
1709 special_type = "pulldown_emphasis";
1710 pulldown_emphasis = Some(span);
1711 }
1712 }
1713
1714 let should_process_markdown_link = if let Some((pos, _, _)) = earliest_match {
1716 pos < next_special
1717 } else {
1718 false
1719 };
1720
1721 if should_process_markdown_link {
1722 let (pos, match_end, pattern_type) = earliest_match.unwrap();
1723
1724 if pos > 0 {
1726 elements.push(Element::Text(remaining[..pos].to_string()));
1727 }
1728
1729 match pattern_type {
1731 "link_span" => {
1732 let span = next_link.unwrap();
1733 let raw_text = remaining[pos..match_end].to_string();
1734 if span.is_footnote {
1735 elements.push(Element::FootnoteReference(raw_text));
1736 } else if span.is_image {
1737 match span.link_type {
1738 Some(LinkType::Inline) => elements.push(Element::InlineImage(raw_text)),
1739 Some(LinkType::Reference)
1742 | Some(LinkType::ReferenceUnknown)
1743 | Some(LinkType::Shortcut)
1744 | Some(LinkType::ShortcutUnknown) => elements.push(Element::ReferenceImage(raw_text)),
1745 Some(LinkType::Collapsed) | Some(LinkType::CollapsedUnknown) => {
1746 elements.push(Element::EmptyReferenceImage(raw_text))
1747 }
1748 _ => elements.push(Element::InlineImage(raw_text)),
1749 }
1750 } else {
1751 match span.link_type {
1752 Some(LinkType::Inline) => {
1753 if raw_text.starts_with('[') && raw_text.contains("![") {
1754 elements.push(Element::LinkedImage(raw_text));
1755 } else {
1756 elements.push(Element::Link(raw_text));
1757 }
1758 }
1759 Some(LinkType::Reference) | Some(LinkType::ReferenceUnknown) => {
1762 elements.push(Element::ReferenceLink(raw_text))
1763 }
1764 Some(LinkType::Collapsed) | Some(LinkType::CollapsedUnknown) => {
1765 elements.push(Element::EmptyReferenceLink(raw_text))
1766 }
1767 Some(LinkType::Shortcut) | Some(LinkType::ShortcutUnknown) => {
1768 elements.push(Element::ShortcutReference(raw_text))
1769 }
1770 Some(LinkType::Autolink) | Some(LinkType::Email) => {
1771 elements.push(Element::Autolink(raw_text))
1772 }
1773 _ => elements.push(Element::Link(raw_text)),
1774 }
1775 }
1776 remaining = &remaining[match_end..];
1777 }
1778 "wiki_link" => {
1779 if let Some(caps) = WIKI_LINK_REGEX.captures(remaining) {
1780 let content = caps.get(1).map_or("", |m| m.as_str());
1781 elements.push(Element::WikiLink(content.to_string()));
1782 remaining = &remaining[match_end..];
1783 } else {
1784 elements.push(Element::Text("[[".to_string()));
1785 remaining = &remaining[2..];
1786 }
1787 }
1788 "display_math" => {
1789 if let Some(caps) = DISPLAY_MATH_REGEX.captures(remaining) {
1790 let math = caps.get(1).map_or("", |m| m.as_str());
1791 elements.push(Element::DisplayMath(math.to_string()));
1792 remaining = &remaining[match_end..];
1793 } else {
1794 elements.push(Element::Text("$$".to_string()));
1795 remaining = &remaining[2..];
1796 }
1797 }
1798 "inline_math" => {
1799 if let Ok(Some(caps)) = INLINE_MATH_REGEX.captures(remaining) {
1800 let math = caps.get(1).map_or("", |m| m.as_str());
1801 elements.push(Element::InlineMath(math.to_string()));
1802 remaining = &remaining[match_end..];
1803 } else {
1804 elements.push(Element::Text("$".to_string()));
1805 remaining = &remaining[1..];
1806 }
1807 }
1808 "emoji" => {
1809 if let Some(caps) = EMOJI_SHORTCODE_REGEX.captures(remaining) {
1810 let emoji = caps.get(1).map_or("", |m| m.as_str());
1811 elements.push(Element::EmojiShortcode(emoji.to_string()));
1812 remaining = &remaining[match_end..];
1813 } else {
1814 elements.push(Element::Text(":".to_string()));
1815 remaining = &remaining[1..];
1816 }
1817 }
1818 "html_entity" => {
1819 elements.push(Element::HtmlEntity(remaining[pos..match_end].to_string()));
1821 remaining = &remaining[match_end..];
1822 }
1823 "hugo_shortcode" => {
1824 elements.push(Element::HugoShortcode(remaining[pos..match_end].to_string()));
1826 remaining = &remaining[match_end..];
1827 }
1828 "html_tag" => {
1829 elements.push(Element::HtmlTag(remaining[pos..match_end].to_string()));
1831 remaining = &remaining[match_end..];
1832 }
1833 _ => unreachable!("unknown pattern type: {}", pattern_type),
1834 }
1835 } else {
1836 if next_special > 0 && next_special < remaining.len() {
1840 elements.push(Element::Text(remaining[..next_special].to_string()));
1841 remaining = &remaining[next_special..];
1842 }
1843
1844 match special_type {
1846 "pulldown_code" => {
1847 let span = next_code_span.unwrap();
1848 let span_len = span.end - span.start;
1849 let code_raw = &remaining[..span_len];
1850 if let Some((content, marker)) = decompose_code_span(code_raw) {
1851 elements.push(Element::Code {
1852 content: content.to_string(),
1853 marker: marker.to_string(),
1854 });
1855 } else {
1856 elements.push(Element::Text(code_raw.to_string()));
1857 }
1858 remaining = &remaining[span_len..];
1859 }
1860 "attr_list" => {
1861 elements.push(Element::AttrList(remaining[..attr_list_len].to_string()));
1862 remaining = &remaining[attr_list_len..];
1863 }
1864 "myst_role" => {
1865 elements.push(Element::MystRole(remaining[..myst_role_len].to_string()));
1866 remaining = &remaining[myst_role_len..];
1867 }
1868 "pulldown_emphasis" => {
1869 let span = pulldown_emphasis.expect("pulldown_emphasis must be set");
1871 let span_len = span.end - span.start;
1872 if span.is_strikethrough {
1873 elements.push(Element::Strikethrough {
1874 content: span.content.clone(),
1875 double: span.strikethrough_double,
1876 });
1877 } else if span.is_strong {
1878 elements.push(Element::Bold {
1879 content: span.content.clone(),
1880 underscore: span.uses_underscore,
1881 });
1882 } else {
1883 elements.push(Element::Italic {
1884 content: span.content.clone(),
1885 underscore: span.uses_underscore,
1886 });
1887 }
1888 remaining = &remaining[span_len..];
1889 }
1890 _ => {
1891 elements.push(Element::Text(remaining.to_string()));
1893 break;
1894 }
1895 }
1896 }
1897 }
1898
1899 let mut merged_elements = Vec::new();
1901 for el in elements {
1902 match el {
1903 Element::Text(s) => {
1904 if let Some(Element::Text(last_s)) = merged_elements.last_mut() {
1905 last_s.push_str(&s);
1906 } else {
1907 merged_elements.push(Element::Text(s));
1908 }
1909 }
1910 other => merged_elements.push(other),
1911 }
1912 }
1913 merged_elements
1914}
1915
1916fn should_insert_space_before_join(current: &str) -> bool {
1917 !current.is_empty()
1918 && !current.ends_with(' ')
1919 && !current.ends_with('(')
1920 && !current.ends_with('[')
1921 && !current.ends_with('-')
1922}
1923
1924fn is_setext_or_thematic(text: &str) -> bool {
1930 let mut marker = 0u8;
1931 let mut count = 0usize;
1932 let mut has_space = false;
1933 for &b in text.as_bytes() {
1934 match b {
1935 b' ' | b'\t' => has_space = true,
1936 b'-' | b'=' | b'*' | b'_' => {
1937 if marker == 0 {
1938 marker = b;
1939 } else if b != marker {
1940 return false;
1941 }
1942 count += 1;
1943 }
1944 _ => return false,
1945 }
1946 }
1947 match marker {
1948 b'=' => !has_space,
1949 b'-' => !has_space || count >= 3,
1950 b'*' | b'_' => count >= 3,
1951 _ => false,
1952 }
1953}
1954
1955fn starts_block_construct(text: &str) -> bool {
1967 let text = text.trim_start();
1968 let bytes = text.as_bytes();
1969 let Some(&first) = bytes.first() else {
1970 return false;
1971 };
1972 let marker_then_boundary = |len: usize| bytes.len() == len || bytes[len] == b' ' || bytes[len] == b'\t';
1973 match first {
1974 b'>' => true,
1976 b'-' | b'*' | b'+' => marker_then_boundary(1) || is_setext_or_thematic(text),
1977 b'_' | b'=' => is_setext_or_thematic(text),
1978 b':' => is_definition_list_item(text) || text.starts_with(":::"),
1979 b'|' => true,
1980 b'#' => {
1981 let hashes = bytes.iter().take_while(|&&b| b == b'#').count();
1982 hashes <= 6 && marker_then_boundary(hashes)
1983 }
1984 b'`' => bytes.iter().take_while(|&&b| b == b'`').count() >= 3,
1985 b'~' => bytes.iter().take_while(|&&b| b == b'~').count() >= 3,
1986 b'0'..=b'9' => {
1993 let digits = bytes.iter().take_while(|b| b.is_ascii_digit()).count();
1994 digits <= 9
1995 && text[..digits].trim_start_matches('0') == "1"
1996 && bytes.len() > digits + 1
1997 && (bytes[digits] == b'.' || bytes[digits] == b')')
1998 && (bytes[digits + 1] == b' ' || bytes[digits + 1] == b'\t')
1999 }
2000 b'[' => {
2008 let mut escaped = false;
2009 let mut label_close = None;
2010 for (i, &b) in bytes.iter().enumerate().skip(1) {
2011 if escaped {
2012 escaped = false;
2013 } else if b == b'\\' {
2014 escaped = true;
2015 } else if b == b']' {
2016 label_close = Some(i);
2017 break;
2018 }
2019 }
2020 label_close.is_some_and(|i| bytes.get(i + 1) == Some(&b':'))
2021 }
2022 b'<' => crate::utils::html_block::parse_html_block_start(text).is_some(),
2025 _ => false,
2026 }
2027}
2028
2029fn merge_block_construct_continuations(lines: Vec<String>) -> Vec<String> {
2038 let mut merged: Vec<String> = Vec::with_capacity(lines.len());
2039 for line in lines {
2040 merged.push(line);
2041 while merged.len() > 1 && starts_block_construct(merged.last().expect("non-empty")) {
2045 let last = merged.pop().expect("non-empty");
2046 let prev = merged.last_mut().expect("len > 1");
2047 prev.push(' ');
2048 prev.push_str(last.trim_start());
2049 }
2050 }
2051 merged
2052}
2053
2054fn reflow_elements_sentence_per_line(
2056 elements: &[Element],
2057 custom_abbreviations: &Option<Vec<String>>,
2058 require_sentence_capital: bool,
2059) -> Vec<String> {
2060 let abbreviations = get_abbreviations(custom_abbreviations);
2061 let mut lines = Vec::new();
2062 let mut current_line = String::new();
2063
2064 for (idx, element) in elements.iter().enumerate() {
2065 let piece = match element {
2071 Element::Text(text) => Some(text.clone()),
2073 Element::Italic { content, underscore } => Some(wrap_emphasis(
2074 content,
2075 if *underscore { "_" } else { "*" },
2076 &mut current_line,
2077 )),
2078 Element::Bold { content, underscore } => Some(wrap_emphasis(
2079 content,
2080 if *underscore { "__" } else { "**" },
2081 &mut current_line,
2082 )),
2083 Element::Strikethrough { content, double } => Some(wrap_emphasis(
2084 content,
2085 if *double { "~~" } else { "~" },
2086 &mut current_line,
2087 )),
2088 _ => None,
2089 };
2090
2091 if let Some(piece) = piece {
2092 let combined = format!("{current_line}{piece}");
2093 let sentences = split_into_sentences_with_set(&combined, &abbreviations, require_sentence_capital);
2095
2096 if sentences.len() > 1 {
2097 let mut pending = String::new();
2101 let last = sentences.len() - 1;
2102 for (i, sentence) in sentences.iter().enumerate() {
2103 if !pending.is_empty() {
2104 pending.push(' ');
2105 }
2106 pending.push_str(sentence);
2107
2108 let closed = i < last || ends_with_sentence_punct(&pending);
2113 if closed && !text_ends_with_abbreviation(&pending, &abbreviations) {
2114 lines.push(std::mem::take(&mut pending));
2115 }
2116 }
2117 current_line = pending;
2118 } else {
2119 let trimmed = combined.trim();
2121
2122 if trimmed.is_empty() {
2126 continue;
2127 }
2128
2129 let ends_with_sentence_punct = ends_with_sentence_punct(trimmed);
2130
2131 if ends_with_sentence_punct && !text_ends_with_abbreviation(trimmed, &abbreviations) {
2132 lines.push(combined.trim_matches(is_breakable_whitespace).to_string());
2135 current_line.clear();
2136 } else {
2137 current_line = combined;
2139 }
2140 }
2141 } else {
2142 let element_str = format!("{element}");
2144 let is_adjacent = if idx > 0 {
2148 match &elements[idx - 1] {
2149 Element::Text(t) => !t.is_empty() && !t.ends_with(is_breakable_whitespace),
2150 _ => true,
2151 }
2152 } else {
2153 false
2154 };
2155
2156 if !is_adjacent && should_insert_space_before_join(¤t_line) {
2158 current_line.push(' ');
2159 }
2160 current_line.push_str(&element_str);
2161 }
2162 }
2163
2164 if !current_line.is_empty() {
2166 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
2167 }
2168 lines
2169}
2170
2171fn wrap_emphasis(content: &str, marker: &str, current_line: &mut String) -> String {
2175 if should_insert_space_before_join(current_line) {
2176 current_line.push(' ');
2177 }
2178 format!("{marker}{content}{marker}")
2179}
2180
2181const BREAK_WORDS: &[&str] = &[
2185 "and",
2186 "or",
2187 "but",
2188 "nor",
2189 "yet",
2190 "so",
2191 "for",
2192 "which",
2193 "that",
2194 "because",
2195 "when",
2196 "if",
2197 "while",
2198 "where",
2199 "although",
2200 "though",
2201 "unless",
2202 "since",
2203 "after",
2204 "before",
2205 "until",
2206 "as",
2207 "once",
2208 "whether",
2209 "however",
2210 "therefore",
2211 "moreover",
2212 "furthermore",
2213 "nevertheless",
2214 "whereas",
2215];
2216
2217fn is_clause_punctuation(c: char) -> bool {
2219 matches!(c, ',' | ';' | ':' | '\u{2014}') }
2221
2222fn clause_break_allowed_after(chars: &[char], i: usize) -> bool {
2230 if chars[i] == '\u{2014}' {
2231 return true;
2232 }
2233 match chars.get(i + 1) {
2234 None => true,
2235 Some(next) => next.is_whitespace(),
2236 }
2237}
2238
2239fn paren_group_end<'a>(slice: &'a str, element_spans: &[(usize, usize)], offset: usize) -> Option<(usize, &'a str)> {
2253 debug_assert!(slice.starts_with('('));
2254 let mut depth: i32 = 0;
2255 for (local_byte, c) in slice.char_indices() {
2256 let global_byte = offset + local_byte;
2257 if depth > 0 && is_inside_element(global_byte, element_spans) {
2262 continue;
2263 }
2264 match c {
2265 '(' => depth += 1,
2266 ')' => {
2267 depth -= 1;
2268 if depth == 0 {
2269 let end = local_byte + 1;
2270 let inner = &slice[1..local_byte];
2271 return Some((end, inner));
2272 }
2273 }
2274 _ => {}
2275 }
2276 }
2277 None
2278}
2279
2280fn split_at_parenthetical(
2297 text: &str,
2298 line_length: usize,
2299 element_spans: &[(usize, usize)],
2300 length_mode: ReflowLengthMode,
2301) -> Option<(String, String)> {
2302 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
2303
2304 if text.starts_with('(')
2306 && let Some((end_local, inner)) = paren_group_end(text, element_spans, 0)
2307 && inner.contains(' ')
2308 {
2309 let tail = &text[end_local..];
2313 let attached_len = tail
2314 .char_indices()
2315 .take_while(|(_, c)| is_closing_quote(*c) || is_clause_punctuation(*c))
2316 .last()
2317 .map_or(0, |(idx, c)| idx + c.len_utf8());
2318 let first_end = end_local + attached_len;
2319 let rest_start = first_end;
2320 let first = &text[..first_end];
2321 let first_len = display_len(first, length_mode);
2322 if first_len <= line_length {
2325 let rest = text[rest_start..].trim_start();
2326 if !rest.is_empty() {
2327 return Some((first.to_string(), rest.to_string()));
2328 }
2329 }
2330 }
2331
2332 let mut best_open_byte: Option<usize> = None;
2334 let mut pos = 0usize;
2335 while pos < text.len() {
2336 if text.as_bytes()[pos] != b'(' {
2338 let c = text[pos..].chars().next().unwrap();
2339 pos += c.len_utf8();
2340 continue;
2341 }
2342 if is_inside_element(pos, element_spans) {
2344 pos += 1;
2345 continue;
2346 }
2347 if let Some((end_local, inner)) = paren_group_end(&text[pos..], element_spans, pos) {
2348 let first = text[..pos].trim_end();
2349 let first_len = display_len(first, length_mode);
2350 if !first.is_empty()
2351 && first_len >= min_first_len
2352 && first_len <= line_length
2353 && inner.contains(' ')
2354 && best_open_byte.is_none_or(|prev| pos > prev)
2355 {
2356 best_open_byte = Some(pos);
2357 }
2358 pos += end_local;
2359 } else {
2360 pos += 1;
2361 }
2362 }
2363
2364 let open_byte = best_open_byte?;
2365 let first = text[..open_byte].trim_end().to_string();
2366 let rest = text[open_byte..].to_string();
2367 if first.is_empty() || rest.trim().is_empty() {
2368 return None;
2369 }
2370 Some((first, rest))
2371}
2372
2373fn compute_element_spans(elements: &[Element]) -> Vec<(usize, usize)> {
2377 let mut spans = Vec::new();
2378 let mut offset = 0;
2379 for element in elements {
2380 let len = element.display_len(ReflowLengthMode::Bytes);
2381 if !matches!(element, Element::Text(_)) {
2382 spans.push((offset, offset + len));
2383 }
2384 offset += len;
2385 }
2386 spans
2387}
2388
2389fn is_inside_element(pos: usize, spans: &[(usize, usize)]) -> bool {
2391 spans.iter().any(|(start, end)| pos > *start && pos < *end)
2392}
2393
2394const MIN_SPLIT_RATIO: f64 = 0.3;
2397
2398fn split_at_clause_punctuation(
2402 text: &str,
2403 line_length: usize,
2404 element_spans: &[(usize, usize)],
2405 length_mode: ReflowLengthMode,
2406) -> Option<(String, String)> {
2407 let chars: Vec<char> = text.chars().collect();
2408 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
2409
2410 let mut width_acc = 0;
2412 let mut search_end_char = 0;
2413 for (idx, &c) in chars.iter().enumerate() {
2414 let c_width = display_len(&c.to_string(), length_mode);
2415 if width_acc + c_width > line_length {
2416 break;
2417 }
2418 width_acc += c_width;
2419 search_end_char = idx + 1;
2420 }
2421
2422 let mut paren_depth: i32 = 0;
2429 let mut best_pos = None;
2430 for i in (0..search_end_char).rev() {
2431 let byte_start: usize = chars[..i].iter().map(|c| c.len_utf8()).sum();
2433 let byte_after: usize = byte_start + chars[i].len_utf8();
2435
2436 if !is_inside_element(byte_start, element_spans) {
2437 match chars[i] {
2438 ')' => paren_depth += 1,
2439 '(' => paren_depth = paren_depth.saturating_sub(1),
2440 _ => {}
2441 }
2442 }
2443
2444 if paren_depth == 0
2445 && is_clause_punctuation(chars[i])
2446 && clause_break_allowed_after(&chars, i)
2447 && !is_inside_element(byte_after, element_spans)
2448 {
2449 best_pos = Some(i);
2450 break;
2451 }
2452 }
2453
2454 let pos = best_pos?;
2455
2456 let first: String = chars[..=pos].iter().collect();
2458 let first_display_len = display_len(&first, length_mode);
2459 if first_display_len < min_first_len {
2460 return None;
2461 }
2462
2463 let rest: String = chars[pos + 1..].iter().collect();
2465 let rest = rest.trim_start().to_string();
2466
2467 if rest.is_empty() {
2468 return None;
2469 }
2470
2471 Some((first, rest))
2472}
2473
2474fn paren_depth_map(text: &str, element_spans: &[(usize, usize)]) -> Vec<i32> {
2481 let mut map = vec![0i32; text.len()];
2482 let mut depth = 0i32;
2483 for (byte, c) in text.char_indices() {
2484 if !is_inside_element(byte, element_spans) {
2485 match c {
2486 '(' => depth += 1,
2487 ')' => depth = depth.saturating_sub(1),
2488 _ => {}
2489 }
2490 }
2491 let end = (byte + c.len_utf8()).min(map.len());
2493 for slot in &mut map[byte..end] {
2494 *slot = depth;
2495 }
2496 }
2497 map
2498}
2499
2500fn is_standalone_parenthetical(line: &str) -> bool {
2509 let trimmed = line.trim();
2510 if !trimmed.starts_with('(') {
2511 return false;
2512 }
2513 let core = trimmed.trim_end_matches(|c: char| is_clause_punctuation(c));
2515 if !core.ends_with(')') {
2516 return false;
2517 }
2518 let inner = &core[1..core.len() - 1];
2520 if !inner.contains(' ') {
2521 return false;
2522 }
2523 let mut depth = 0i32;
2525 for c in core.chars() {
2526 match c {
2527 '(' => depth += 1,
2528 ')' => depth -= 1,
2529 _ => {}
2530 }
2531 if depth < 0 {
2532 return false;
2533 }
2534 }
2535 depth == 0
2536}
2537
2538fn split_at_break_word(
2542 text: &str,
2543 line_length: usize,
2544 element_spans: &[(usize, usize)],
2545 length_mode: ReflowLengthMode,
2546) -> Option<(String, String)> {
2547 let lower = text.to_lowercase();
2548 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
2549 let mut best_split: Option<(usize, usize)> = None; let depth_map = paren_depth_map(text, element_spans);
2554
2555 for &word in BREAK_WORDS {
2556 let mut search_start = 0;
2557 while let Some(pos) = lower[search_start..].find(word) {
2558 let abs_pos = search_start + pos;
2559
2560 let preceded_by_space = abs_pos == 0 || text.as_bytes().get(abs_pos - 1) == Some(&b' ');
2562 let followed_by_space = text.as_bytes().get(abs_pos + word.len()) == Some(&b' ');
2563
2564 if preceded_by_space && followed_by_space {
2565 let first_part = text[..abs_pos].trim_end();
2567 let first_part_len = display_len(first_part, length_mode);
2568
2569 let inside_paren = depth_map.get(abs_pos).is_some_and(|&d| d > 0);
2571
2572 if first_part_len >= min_first_len
2573 && first_part_len <= line_length
2574 && !is_inside_element(abs_pos, element_spans)
2575 && !inside_paren
2576 {
2577 if best_split.is_none_or(|(prev_pos, _)| abs_pos > prev_pos) {
2579 best_split = Some((abs_pos, word.len()));
2580 }
2581 }
2582 }
2583
2584 search_start = abs_pos + word.len();
2585 }
2586 }
2587
2588 let (byte_start, _word_len) = best_split?;
2589
2590 let first = text[..byte_start].trim_end().to_string();
2591 let rest = text[byte_start..].to_string();
2592
2593 if first.is_empty() || rest.trim().is_empty() {
2594 return None;
2595 }
2596
2597 Some((first, rest))
2598}
2599
2600fn cascade_split_line(text: &str, options: &ReflowOptions) -> Vec<String> {
2611 let line_length = options.line_length;
2612 let length_mode = options.length_mode;
2613 let attr_lists = options.attr_lists;
2614 let myst_roles = options.myst_roles;
2615 let defined_references = options.defined_references.as_ref();
2616 if line_length == 0 || display_len(text, length_mode) <= line_length {
2617 return vec![text.to_string()];
2618 }
2619
2620 let elements = parse_markdown_elements_inner(text, attr_lists, myst_roles, defined_references);
2621 let element_spans = compute_element_spans(&elements);
2622
2623 let rebased_spans = |start: usize| -> Vec<(usize, usize)> {
2627 if start == 0 {
2628 return element_spans.clone();
2629 }
2630 element_spans
2631 .iter()
2632 .filter(|&&(_, end)| end > start)
2633 .map(|&(s, e)| (s.saturating_sub(start), e.saturating_sub(start)))
2634 .collect()
2635 };
2636
2637 let mut result = Vec::new();
2638 let mut start = 0usize;
2639
2640 loop {
2641 let remaining = &text[start..];
2642 if display_len(remaining, length_mode) <= line_length {
2643 result.push(remaining.to_string());
2644 return result;
2645 }
2646
2647 let spans = rebased_spans(start);
2648
2649 let split = split_at_parenthetical(remaining, line_length, &spans, length_mode)
2653 .or_else(|| split_at_clause_punctuation(remaining, line_length, &spans, length_mode))
2654 .or_else(|| split_at_break_word(remaining, line_length, &spans, length_mode));
2655
2656 if let Some((first, rest)) = split {
2657 let consumed = remaining.len().saturating_sub(rest.len());
2658 if consumed == 0 {
2661 break;
2662 }
2663 result.push(first);
2664 start += consumed;
2665 continue;
2666 }
2667
2668 break;
2670 }
2671
2672 let mut fallback_options = options.clone();
2674 fallback_options.break_on_sentences = false;
2675 fallback_options.preserve_breaks = false;
2676 fallback_options.sentence_per_line = false;
2677 fallback_options.semantic_line_breaks = false;
2678 fallback_options.require_sentence_capital = true;
2679 fallback_options.max_list_continuation_indent = None;
2680 fallback_options.defined_references = None;
2681 let remaining = &text[start..];
2682 let tail_elements = if start == 0 {
2683 elements
2684 } else {
2685 parse_markdown_elements_inner(remaining, attr_lists, myst_roles, defined_references)
2686 };
2687 result.extend(reflow_elements(&tail_elements, &fallback_options));
2688 result
2689}
2690
2691fn reflow_elements_semantic(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
2695 let sentence_lines =
2697 reflow_elements_sentence_per_line(elements, &options.abbreviations, options.require_sentence_capital);
2698
2699 if options.line_length == 0 {
2702 return sentence_lines;
2703 }
2704
2705 let length_mode = options.length_mode;
2706 let mut result = Vec::new();
2707 for line in sentence_lines {
2708 if display_len(&line, length_mode) <= options.line_length {
2709 result.push(line);
2710 } else {
2711 result.extend(cascade_split_line(&line, options));
2712 }
2713 }
2714
2715 let min_line_len = ((options.line_length as f64) * MIN_SPLIT_RATIO) as usize;
2718 let mut merged: Vec<String> = Vec::with_capacity(result.len());
2719 for line in result {
2720 if !merged.is_empty() && display_len(&line, length_mode) < min_line_len && !line.trim().is_empty() {
2721 if is_standalone_parenthetical(&line) {
2724 merged.push(line);
2725 continue;
2726 }
2727
2728 let prev_ends_at_sentence = {
2730 let trimmed = merged.last().unwrap().trim_end();
2731 trimmed
2732 .chars()
2733 .rev()
2734 .find(|c| !matches!(c, '"' | '\'' | '\u{201D}' | '\u{2019}' | ')' | ']'))
2735 .is_some_and(|c| matches!(c, '.' | '!' | '?'))
2736 };
2737
2738 if !prev_ends_at_sentence {
2739 let prev = merged.last_mut().unwrap();
2740 let combined = format!("{prev} {line}");
2741 if display_len(&combined, length_mode) <= options.line_length {
2743 *prev = combined;
2744 continue;
2745 }
2746 }
2747 }
2748 merged.push(line);
2749 }
2750 merged
2751}
2752
2753fn rfind_safe_space(line: &str, element_spans: &[(usize, usize)]) -> Option<usize> {
2763 line.char_indices().rev().map(|(pos, _)| pos).find(|&pos| {
2764 line.as_bytes()[pos] == b' '
2765 && !element_spans.iter().any(|(s, e)| pos > *s && pos < *e)
2766 && !starts_block_construct(&line[pos + 1..])
2767 })
2768}
2769
2770fn break_before_attached(
2777 lines: &mut Vec<String>,
2778 current_line: &mut String,
2779 current_length: &mut usize,
2780 element_spans: &mut Vec<(usize, usize)>,
2781 attach: &str,
2782 separator: &str,
2783 length_mode: ReflowLengthMode,
2784) -> Option<usize> {
2785 let last_space = rfind_safe_space(current_line, element_spans)?;
2786 let before = current_line[..last_space]
2787 .trim_end_matches(is_breakable_whitespace)
2788 .to_string();
2789 let after = current_line[last_space + 1..].to_string();
2790 lines.push(before);
2791 let carried = after.len();
2792 *current_line = format!("{after}{separator}{attach}");
2793 *current_length = display_len(current_line, length_mode);
2794 element_spans.clear();
2795 Some(carried)
2796}
2797
2798fn reflow_elements(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
2800 let mut lines = Vec::new();
2801 let mut current_line = String::new();
2802 let mut current_length = 0;
2803 let mut current_line_element_spans: Vec<(usize, usize)> = Vec::new();
2805 let length_mode = options.length_mode;
2806
2807 for (idx, element) in elements.iter().enumerate() {
2808 let element_len = element.display_len(length_mode);
2809
2810 let is_adjacent_to_prev = if idx > 0 {
2819 match (&elements[idx - 1], element) {
2820 (Element::Text(t), _) => !t.is_empty() && !t.ends_with(is_breakable_whitespace),
2821 (_, Element::Text(t)) => !t.is_empty() && !t.starts_with(is_breakable_whitespace),
2822 _ => true,
2823 }
2824 } else {
2825 false
2826 };
2827
2828 if let Element::Text(text) = element {
2830 let has_leading_space = text.starts_with(is_breakable_whitespace);
2832 let words: Vec<&str> = split_breakable_words(text).collect();
2834
2835 for (i, word) in words.iter().enumerate() {
2836 let word_len = display_len(word, length_mode);
2837 let is_trailing_punct = word.chars().all(|c| {
2843 matches!(c, ',' | '.' | ':' | ';' | '!' | '?' | ')' | ']' | '}') || is_non_breaking_space(c)
2844 });
2845
2846 let is_first_adjacent = i == 0 && is_adjacent_to_prev;
2849
2850 if is_first_adjacent {
2851 if current_length + word_len > options.line_length
2853 && current_length > 0
2854 && break_before_attached(
2855 &mut lines,
2856 &mut current_line,
2857 &mut current_length,
2858 &mut current_line_element_spans,
2859 word,
2860 "",
2861 length_mode,
2862 )
2863 .is_some()
2864 {
2865 } else {
2870 current_line.push_str(word);
2871 current_length += word_len;
2872 }
2873 } else if current_length > 0 && current_length + 1 + word_len > options.line_length {
2874 if is_trailing_punct {
2875 if break_before_attached(
2882 &mut lines,
2883 &mut current_line,
2884 &mut current_length,
2885 &mut current_line_element_spans,
2886 word,
2887 " ",
2888 length_mode,
2889 )
2890 .is_none()
2891 {
2892 current_line.push(' ');
2893 current_line.push_str(word);
2894 current_length += 1 + word_len;
2895 }
2896 } else if !starts_block_construct(word) {
2897 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
2899 current_line = word.to_string();
2900 current_length = word_len;
2901 current_line_element_spans.clear();
2902 } else if break_before_attached(
2903 &mut lines,
2904 &mut current_line,
2905 &mut current_length,
2906 &mut current_line_element_spans,
2907 word,
2908 " ",
2909 length_mode,
2910 )
2911 .is_some()
2912 {
2913 } else {
2918 if i > 0 || has_leading_space {
2921 current_line.push(' ');
2922 current_length += 1;
2923 }
2924 current_line.push_str(word);
2925 current_length += word_len;
2926 }
2927 } else {
2928 let add_space = current_length > 0 && (i > 0 || has_leading_space);
2940 if add_space {
2941 current_line.push(' ');
2942 current_length += 1;
2943 }
2944 current_line.push_str(word);
2945 current_length += word_len;
2946 }
2947 }
2948 } else {
2949 let span_info = match element {
2950 Element::Italic { content, underscore } => {
2951 Some((content.as_str(), if *underscore { "_" } else { "*" }, false))
2952 }
2953 Element::Bold { content, underscore } => {
2954 Some((content.as_str(), if *underscore { "__" } else { "**" }, false))
2955 }
2956 Element::Strikethrough { content, double } => {
2957 Some((content.as_str(), if *double { "~~" } else { "~" }, false))
2958 }
2959 Element::Code { content, marker } => Some((content.as_str(), marker.as_str(), true)),
2960 _ => None,
2961 };
2962
2963 let breakable: Option<Vec<&str>> = match span_info {
2967 Some((content, _, is_code)) => {
2968 if is_code {
2969 (!options.atomic_spans && code_span_wraps_losslessly(content))
2970 .then(|| split_breakable_words(content).collect())
2971 } else {
2972 (!options.atomic_spans || element_len > options.line_length)
2973 .then(|| breakable_units(content, options.defined_references.as_ref(), options.attr_lists))
2974 .flatten()
2975 }
2976 }
2977 None => None,
2978 };
2979
2980 if let Some(words) = breakable {
2981 let (_, marker, is_code) = span_info.expect("breakable implies a span");
2982 let n = words.len();
2983 if n == 0 {
2984 let full = format!("{marker}{marker}");
2986 let full_len = display_len(&full, length_mode);
2987 if !is_adjacent_to_prev && current_length > 0 {
2988 current_line.push(' ');
2989 current_length += 1;
2990 }
2991 current_line.push_str(&full);
2992 current_length += full_len;
2993 } else {
2994 for (i, word) in words.iter().enumerate() {
2995 let is_first = i == 0;
2996 let is_last = i == n - 1;
2997
2998 let space_start = if is_first && is_code && word.starts_with('`') {
2999 " "
3000 } else {
3001 ""
3002 };
3003 let space_end = if is_last && is_code && word.ends_with('`') {
3004 " "
3005 } else {
3006 ""
3007 };
3008
3009 let word_str: String = match (is_first, is_last) {
3010 (true, true) => format!("{marker}{space_start}{word}{space_end}{marker}"),
3011 (true, false) => format!("{marker}{space_start}{word}"),
3012 (false, true) => format!("{word}{space_end}{marker}"),
3013 (false, false) => word.to_string(),
3014 };
3015 let word_len = display_len(&word_str, length_mode);
3016
3017 let needs_space = if is_first {
3018 !is_adjacent_to_prev && current_length > 0
3019 } else {
3020 current_length > 0
3021 };
3022
3023 if needs_space
3024 && current_length + 1 + word_len > options.line_length
3025 && !starts_block_construct(&word_str)
3026 {
3027 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
3028 current_line = word_str;
3029 current_length = word_len;
3030 current_line_element_spans.clear();
3031 } else {
3032 if needs_space {
3033 current_line.push(' ');
3034 current_length += 1;
3035 }
3036 current_line.push_str(&word_str);
3037 current_length += word_len;
3038 }
3039 }
3040 }
3041 } else {
3042 let element_str = format!("{element}");
3045
3046 if is_adjacent_to_prev {
3047 if current_length + element_len > options.line_length
3049 && let Some(carried) = break_before_attached(
3050 &mut lines,
3051 &mut current_line,
3052 &mut current_length,
3053 &mut current_line_element_spans,
3054 &element_str,
3055 "",
3056 length_mode,
3057 )
3058 {
3059 current_line_element_spans.push((carried, carried + element_str.len()));
3063 } else {
3064 let start = current_line.len();
3065 current_line.push_str(&element_str);
3066 current_length += element_len;
3067 current_line_element_spans.push((start, current_line.len()));
3068 }
3069 } else if current_length > 0 && current_length + 1 + element_len > options.line_length {
3070 if !starts_block_construct(&element_str) {
3071 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
3073 current_line.clone_from(&element_str);
3074 current_length = element_len;
3075 current_line_element_spans.clear();
3076 current_line_element_spans.push((0, element_str.len()));
3077 } else if let Some(carried) = break_before_attached(
3078 &mut lines,
3079 &mut current_line,
3080 &mut current_length,
3081 &mut current_line_element_spans,
3082 &element_str,
3083 " ",
3084 length_mode,
3085 ) {
3086 let start = carried + 1;
3090 current_line_element_spans.push((start, start + element_str.len()));
3091 } else {
3092 let ends_with_opener =
3095 current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
3096 if !ends_with_opener {
3097 current_line.push(' ');
3098 current_length += 1;
3099 }
3100 let start = current_line.len();
3101 current_line.push_str(&element_str);
3102 current_length += element_len;
3103 current_line_element_spans.push((start, current_line.len()));
3104 }
3105 } else {
3106 let ends_with_opener =
3108 current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
3109 if current_length > 0 && !ends_with_opener {
3110 current_line.push(' ');
3111 current_length += 1;
3112 }
3113 let start = current_line.len();
3114 current_line.push_str(&element_str);
3115 current_length += element_len;
3116 current_line_element_spans.push((start, current_line.len()));
3117 }
3118 }
3119 }
3120 }
3121
3122 if !current_line.is_empty() {
3124 lines.push(current_line.trim_end_matches(is_breakable_whitespace).to_string());
3125 }
3126
3127 lines
3128}
3129
3130pub fn reflow_markdown(content: &str, options: &ReflowOptions) -> String {
3132 let lines: Vec<&str> = content.lines().collect();
3133 let mut result = Vec::new();
3134 let mut i = 0;
3135
3136 while i < lines.len() {
3137 let line = lines[i];
3138 let trimmed = line.trim();
3139
3140 if trimmed.is_empty() {
3142 result.push(String::new());
3143 i += 1;
3144 continue;
3145 }
3146
3147 if trimmed.starts_with('#') {
3149 result.push(line.to_string());
3150 i += 1;
3151 continue;
3152 }
3153
3154 if trimmed.starts_with(":::") {
3156 result.push(line.to_string());
3157 i += 1;
3158 continue;
3159 }
3160
3161 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
3163 result.push(line.to_string());
3164 i += 1;
3165 while i < lines.len() {
3167 result.push(lines[i].to_string());
3168 if lines[i].trim().starts_with("```") || lines[i].trim().starts_with("~~~") {
3169 i += 1;
3170 break;
3171 }
3172 i += 1;
3173 }
3174 continue;
3175 }
3176
3177 if calculate_indentation_width_default(line) >= 4 {
3179 result.push(line.to_string());
3181 i += 1;
3182 while i < lines.len() {
3183 let next_line = lines[i];
3184 if calculate_indentation_width_default(next_line) >= 4 || next_line.trim().is_empty() {
3186 result.push(next_line.to_string());
3187 i += 1;
3188 } else {
3189 break;
3190 }
3191 }
3192 continue;
3193 }
3194
3195 if trimmed.starts_with('>') {
3197 let gt_pos = line.find('>').expect("'>' must exist since trimmed.starts_with('>')");
3200 let quote_prefix = line[0..=gt_pos].to_string();
3201 let quote_content = &line[quote_prefix.len()..].trim_start();
3202
3203 let reflowed = reflow_line(quote_content, options);
3204 for reflowed_line in &reflowed {
3205 result.push(format!("{quote_prefix} {reflowed_line}"));
3206 }
3207 i += 1;
3208 continue;
3209 }
3210
3211 if is_horizontal_rule(trimmed) {
3213 result.push(line.to_string());
3214 i += 1;
3215 continue;
3216 }
3217
3218 if is_unordered_list_marker(trimmed) || is_numbered_list_item(trimmed) {
3220 let indent = line.len() - line.trim_start().len();
3222 let indent_str = " ".repeat(indent);
3223
3224 let mut marker_end = indent;
3227 let mut content_start = indent;
3228
3229 if trimmed.chars().next().is_some_and(char::is_numeric) {
3230 if let Some(period_pos) = line[indent..].find('.') {
3232 marker_end = indent + period_pos + 1; content_start = marker_end;
3234 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
3238 content_start += 1;
3239 }
3240 }
3241 } else {
3242 marker_end = indent + 1; content_start = marker_end;
3245 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
3249 content_start += 1;
3250 }
3251 }
3252
3253 let min_continuation_indent = content_start;
3255
3256 let rest = &line[content_start..];
3259 if rest.starts_with("[ ] ") || rest.starts_with("[x] ") || rest.starts_with("[X] ") {
3260 marker_end = content_start + 3; content_start += 4; }
3263
3264 let marker = &line[indent..marker_end];
3265
3266 let mut list_content = vec![trim_preserving_hard_break(&line[content_start..])];
3269 i += 1;
3270
3271 while i < lines.len() {
3275 let next_line = lines[i];
3276 let next_trimmed = next_line.trim();
3277
3278 if is_block_boundary(next_trimmed) {
3280 break;
3281 }
3282
3283 let next_indent = next_line.len() - next_line.trim_start().len();
3285 if next_indent >= min_continuation_indent {
3286 let trimmed_start = next_line.trim_start();
3289 list_content.push(trim_preserving_hard_break(trimmed_start));
3290 i += 1;
3291 } else {
3292 break;
3294 }
3295 }
3296
3297 let combined_content = if options.preserve_breaks {
3300 list_content[0].clone()
3301 } else {
3302 let has_hard_breaks = list_content.iter().any(|line| has_hard_break(line));
3304 if has_hard_breaks {
3305 list_content.join("\n")
3307 } else {
3308 list_content.join(" ")
3310 }
3311 };
3312
3313 let trimmed_marker = marker;
3315 let continuation_spaces = if let Some(max_indent) = options.max_list_continuation_indent {
3316 indent + (content_start - indent).min(max_indent)
3319 } else {
3320 content_start
3321 };
3322
3323 let prefix_length = indent + trimmed_marker.len() + 1;
3325
3326 let adjusted_options = ReflowOptions {
3328 line_length: options.line_length.saturating_sub(prefix_length),
3329 ..options.clone()
3330 };
3331
3332 let reflowed = reflow_line(&combined_content, &adjusted_options);
3333 for (j, reflowed_line) in reflowed.iter().enumerate() {
3334 if j == 0 {
3335 result.push(format!("{indent_str}{trimmed_marker} {reflowed_line}"));
3336 } else {
3337 let continuation_indent = " ".repeat(continuation_spaces);
3339 result.push(format!("{continuation_indent}{reflowed_line}"));
3340 }
3341 }
3342 continue;
3343 }
3344
3345 if crate::utils::table_utils::TableUtils::is_potential_table_row(line) {
3347 result.push(line.to_string());
3348 i += 1;
3349 continue;
3350 }
3351
3352 if trimmed.starts_with('[') && line.contains("]:") {
3354 result.push(line.to_string());
3355 i += 1;
3356 continue;
3357 }
3358
3359 if is_definition_list_item(trimmed) {
3361 result.push(line.to_string());
3362 i += 1;
3363 continue;
3364 }
3365
3366 let mut is_single_line_paragraph = true;
3368 if i + 1 < lines.len() {
3369 let next_trimmed = lines[i + 1].trim();
3370 if !is_block_boundary(next_trimmed) {
3372 is_single_line_paragraph = false;
3373 }
3374 }
3375
3376 if is_single_line_paragraph && display_len(line, options.length_mode) <= options.line_length {
3378 result.push(line.to_string());
3379 i += 1;
3380 continue;
3381 }
3382
3383 let mut paragraph_parts = Vec::new();
3385 let mut current_part = vec![line];
3386 i += 1;
3387
3388 if options.preserve_breaks {
3390 let hard_break_type = if line.strip_suffix('\r').unwrap_or(line).ends_with('\\') {
3392 Some("\\")
3393 } else if line.ends_with(" ") {
3394 Some(" ")
3395 } else {
3396 None
3397 };
3398 let reflowed = reflow_line(line, options);
3399
3400 if let Some(break_marker) = hard_break_type {
3402 if !reflowed.is_empty() {
3403 let mut reflowed_with_break = reflowed;
3404 let last_idx = reflowed_with_break.len() - 1;
3405 if !has_hard_break(&reflowed_with_break[last_idx]) {
3406 reflowed_with_break[last_idx].push_str(break_marker);
3407 }
3408 result.extend(reflowed_with_break);
3409 }
3410 } else {
3411 result.extend(reflowed);
3412 }
3413 } else {
3414 while i < lines.len() {
3416 let prev_line = if !current_part.is_empty() {
3417 current_part.last().unwrap()
3418 } else {
3419 ""
3420 };
3421 let next_line = lines[i];
3422 let next_trimmed = next_line.trim();
3423
3424 if is_block_boundary(next_trimmed) {
3426 break;
3427 }
3428
3429 let prev_trimmed = prev_line.trim();
3432 let abbreviations = get_abbreviations(&options.abbreviations);
3433 let ends_with_sentence = (prev_trimmed.ends_with('.')
3434 || prev_trimmed.ends_with('!')
3435 || prev_trimmed.ends_with('?')
3436 || prev_trimmed.ends_with(".*")
3437 || prev_trimmed.ends_with("!*")
3438 || prev_trimmed.ends_with("?*")
3439 || prev_trimmed.ends_with("._")
3440 || prev_trimmed.ends_with("!_")
3441 || prev_trimmed.ends_with("?_")
3442 || prev_trimmed.ends_with(".\"")
3444 || prev_trimmed.ends_with("!\"")
3445 || prev_trimmed.ends_with("?\"")
3446 || prev_trimmed.ends_with(".'")
3447 || prev_trimmed.ends_with("!'")
3448 || prev_trimmed.ends_with("?'")
3449 || prev_trimmed.ends_with(".\u{201D}")
3450 || prev_trimmed.ends_with("!\u{201D}")
3451 || prev_trimmed.ends_with("?\u{201D}")
3452 || prev_trimmed.ends_with(".\u{2019}")
3453 || prev_trimmed.ends_with("!\u{2019}")
3454 || prev_trimmed.ends_with("?\u{2019}"))
3455 && !text_ends_with_abbreviation(
3456 prev_trimmed.trim_end_matches(['*', '_', '"', '\'', '\u{201D}', '\u{2019}']),
3457 &abbreviations,
3458 );
3459
3460 if has_hard_break(prev_line) || (options.sentence_per_line && ends_with_sentence) {
3461 paragraph_parts.push(current_part.join(" "));
3463 current_part = vec![next_line];
3464 } else {
3465 current_part.push(next_line);
3466 }
3467 i += 1;
3468 }
3469
3470 if !current_part.is_empty() {
3472 if current_part.len() == 1 {
3473 paragraph_parts.push(current_part[0].to_string());
3475 } else {
3476 paragraph_parts.push(current_part.join(" "));
3477 }
3478 }
3479
3480 for (j, part) in paragraph_parts.iter().enumerate() {
3482 let reflowed = reflow_line(part, options);
3483 result.extend(reflowed);
3484
3485 if j < paragraph_parts.len() - 1 && !result.is_empty() && !options.sentence_per_line {
3489 let last_idx = result.len() - 1;
3490 if !has_hard_break(&result[last_idx]) {
3491 result[last_idx].push_str(" ");
3492 }
3493 }
3494 }
3495 }
3496 }
3497
3498 let result_text = result.join("\n");
3500 if content.ends_with('\n') && !result_text.ends_with('\n') {
3501 format!("{result_text}\n")
3502 } else {
3503 result_text
3504 }
3505}
3506
3507#[derive(Debug, Clone)]
3509pub struct ParagraphReflow {
3510 pub start_byte: usize,
3512 pub end_byte: usize,
3514 pub reflowed_text: String,
3516}
3517
3518#[derive(Debug, Clone)]
3524pub struct BlockquoteLineData {
3525 pub(crate) content: String,
3527 pub(crate) is_explicit: bool,
3529 pub(crate) prefix: Option<String>,
3531}
3532
3533impl BlockquoteLineData {
3534 pub fn explicit(content: String, prefix: String) -> Self {
3536 Self {
3537 content,
3538 is_explicit: true,
3539 prefix: Some(prefix),
3540 }
3541 }
3542
3543 pub fn lazy(content: String) -> Self {
3545 Self {
3546 content,
3547 is_explicit: false,
3548 prefix: None,
3549 }
3550 }
3551}
3552
3553#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3555pub enum BlockquoteContinuationStyle {
3556 Explicit,
3557 Lazy,
3558}
3559
3560pub fn blockquote_continuation_style(lines: &[BlockquoteLineData]) -> BlockquoteContinuationStyle {
3568 let mut explicit_count = 0usize;
3569 let mut lazy_count = 0usize;
3570
3571 for line in lines.iter().skip(1) {
3572 if line.is_explicit {
3573 explicit_count += 1;
3574 } else {
3575 lazy_count += 1;
3576 }
3577 }
3578
3579 if explicit_count > 0 && lazy_count == 0 {
3580 BlockquoteContinuationStyle::Explicit
3581 } else if lazy_count > 0 && explicit_count == 0 {
3582 BlockquoteContinuationStyle::Lazy
3583 } else if explicit_count >= lazy_count {
3584 BlockquoteContinuationStyle::Explicit
3585 } else {
3586 BlockquoteContinuationStyle::Lazy
3587 }
3588}
3589
3590pub fn dominant_blockquote_prefix(lines: &[BlockquoteLineData], fallback: &str) -> String {
3595 let mut counts: std::collections::HashMap<String, (usize, usize)> = std::collections::HashMap::new();
3596
3597 for (idx, line) in lines.iter().enumerate() {
3598 let Some(prefix) = line.prefix.as_ref() else {
3599 continue;
3600 };
3601 counts
3602 .entry(prefix.clone())
3603 .and_modify(|entry| entry.0 += 1)
3604 .or_insert((1, idx));
3605 }
3606
3607 counts
3608 .into_iter()
3609 .max_by(|(_, (count_a, first_idx_a)), (_, (count_b, first_idx_b))| {
3610 count_a.cmp(count_b).then_with(|| first_idx_b.cmp(first_idx_a))
3611 })
3612 .map_or_else(|| fallback.to_string(), |(prefix, _)| prefix)
3613}
3614
3615pub(crate) fn should_force_explicit_blockquote_line(content_line: &str) -> bool {
3620 let trimmed = content_line.trim_start();
3621 trimmed.starts_with('>')
3622 || trimmed.starts_with('#')
3623 || trimmed.starts_with("```")
3624 || trimmed.starts_with("~~~")
3625 || is_unordered_list_marker(trimmed)
3626 || is_numbered_list_item(trimmed)
3627 || is_horizontal_rule(trimmed)
3628 || is_definition_list_item(trimmed)
3629 || (trimmed.starts_with('[') && trimmed.contains("]:"))
3630 || trimmed.starts_with(":::")
3631 || (trimmed.starts_with('<')
3632 && !trimmed.starts_with("<http")
3633 && !trimmed.starts_with("<https")
3634 && !trimmed.starts_with("<mailto:"))
3635}
3636
3637pub fn reflow_blockquote_content(
3646 lines: &[BlockquoteLineData],
3647 explicit_prefix: &str,
3648 continuation_style: BlockquoteContinuationStyle,
3649 options: &ReflowOptions,
3650) -> Vec<String> {
3651 let content_strs: Vec<&str> = lines.iter().map(|l| l.content.as_str()).collect();
3652 let segments = split_into_segments_strs(&content_strs);
3653 let mut reflowed_content_lines: Vec<String> = Vec::new();
3654
3655 for segment in segments {
3656 let hard_break_type = segment.last().and_then(|&line| {
3657 let line = line.strip_suffix('\r').unwrap_or(line);
3658 if line.ends_with('\\') {
3659 Some("\\")
3660 } else if line.ends_with(" ") {
3661 Some(" ")
3662 } else {
3663 None
3664 }
3665 });
3666
3667 let pieces: Vec<&str> = segment
3668 .iter()
3669 .map(|&line| {
3670 if let Some(l) = line.strip_suffix('\\') {
3671 l.trim_end()
3672 } else if let Some(l) = line.strip_suffix(" ") {
3673 l.trim_end()
3674 } else {
3675 line.trim_end()
3676 }
3677 })
3678 .collect();
3679
3680 let segment_text = pieces.join(" ");
3681 let segment_text = segment_text.trim();
3682 if segment_text.is_empty() {
3683 continue;
3684 }
3685
3686 let mut reflowed = reflow_line(segment_text, options);
3687 if let Some(break_marker) = hard_break_type
3688 && !reflowed.is_empty()
3689 {
3690 let last_idx = reflowed.len() - 1;
3691 if !has_hard_break(&reflowed[last_idx]) {
3692 reflowed[last_idx].push_str(break_marker);
3693 }
3694 }
3695 reflowed_content_lines.extend(reflowed);
3696 }
3697
3698 let mut styled_lines: Vec<String> = Vec::new();
3699 for (idx, line) in reflowed_content_lines.iter().enumerate() {
3700 let force_explicit = idx == 0
3701 || continuation_style == BlockquoteContinuationStyle::Explicit
3702 || should_force_explicit_blockquote_line(line);
3703 if force_explicit {
3704 styled_lines.push(format!("{explicit_prefix}{line}"));
3705 } else {
3706 styled_lines.push(line.clone());
3707 }
3708 }
3709
3710 styled_lines
3711}
3712
3713fn is_blockquote_content_boundary(content: &str) -> bool {
3714 let trimmed = content.trim();
3715 trimmed.is_empty()
3716 || is_block_boundary(trimmed)
3717 || crate::utils::table_utils::TableUtils::is_potential_table_row(content)
3718 || trimmed.starts_with(":::")
3719 || crate::utils::is_template_directive_only(content)
3720 || is_standalone_attr_list(content)
3721 || is_snippet_block_delimiter(content)
3722}
3723
3724fn split_into_segments_strs<'a>(lines: &[&'a str]) -> Vec<Vec<&'a str>> {
3725 let mut segments = Vec::new();
3726 let mut current = Vec::new();
3727
3728 for &line in lines {
3729 current.push(line);
3730 if has_hard_break(line) {
3731 segments.push(current);
3732 current = Vec::new();
3733 }
3734 }
3735
3736 if !current.is_empty() {
3737 segments.push(current);
3738 }
3739
3740 segments
3741}
3742
3743fn reflow_blockquote_paragraph_at_line(
3744 content: &str,
3745 lines: &[&str],
3746 target_idx: usize,
3747 options: &ReflowOptions,
3748) -> Option<ParagraphReflow> {
3749 let mut anchor_idx = target_idx;
3750 let mut target_level = if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[target_idx]) {
3751 parsed.nesting_level
3752 } else {
3753 let mut found = None;
3754 let mut idx = target_idx;
3755 loop {
3756 if lines[idx].trim().is_empty() {
3757 break;
3758 }
3759 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[idx]) {
3760 found = Some((idx, parsed.nesting_level));
3761 break;
3762 }
3763 if idx == 0 {
3764 break;
3765 }
3766 idx -= 1;
3767 }
3768 let (idx, level) = found?;
3769 anchor_idx = idx;
3770 level
3771 };
3772
3773 let mut para_start = anchor_idx;
3775 while para_start > 0 {
3776 let prev_idx = para_start - 1;
3777 let prev_line = lines[prev_idx];
3778
3779 if prev_line.trim().is_empty() {
3780 break;
3781 }
3782
3783 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(prev_line) {
3784 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
3785 break;
3786 }
3787 para_start = prev_idx;
3788 continue;
3789 }
3790
3791 let prev_lazy = prev_line.trim_start();
3792 if is_blockquote_content_boundary(prev_lazy) {
3793 break;
3794 }
3795 para_start = prev_idx;
3796 }
3797
3798 while para_start < lines.len() {
3800 let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[para_start]) else {
3801 para_start += 1;
3802 continue;
3803 };
3804 target_level = parsed.nesting_level;
3805 break;
3806 }
3807
3808 if para_start >= lines.len() || para_start > target_idx {
3809 return None;
3810 }
3811
3812 let mut collected: Vec<(usize, BlockquoteLineData)> = Vec::new();
3815 let mut idx = para_start;
3816 while idx < lines.len() {
3817 if !collected.is_empty() && has_hard_break(&collected[collected.len() - 1].1.content) {
3818 break;
3819 }
3820
3821 let line = lines[idx];
3822 if line.trim().is_empty() {
3823 break;
3824 }
3825
3826 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(line) {
3827 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
3828 break;
3829 }
3830 collected.push((
3831 idx,
3832 BlockquoteLineData::explicit(trim_preserving_hard_break(parsed.content), parsed.prefix.to_string()),
3833 ));
3834 idx += 1;
3835 continue;
3836 }
3837
3838 let lazy_content = line.trim_start();
3839 if is_blockquote_content_boundary(lazy_content) {
3840 break;
3841 }
3842
3843 collected.push((idx, BlockquoteLineData::lazy(trim_preserving_hard_break(lazy_content))));
3844 idx += 1;
3845 }
3846
3847 if collected.is_empty() {
3848 return None;
3849 }
3850
3851 let para_end = collected[collected.len() - 1].0;
3852 if target_idx < para_start || target_idx > para_end {
3853 return None;
3854 }
3855
3856 let line_data: Vec<BlockquoteLineData> = collected.iter().map(|(_, d)| d.clone()).collect();
3857
3858 let fallback_prefix = line_data
3859 .iter()
3860 .find_map(|d| d.prefix.clone())
3861 .unwrap_or_else(|| "> ".to_string());
3862 let explicit_prefix = dominant_blockquote_prefix(&line_data, &fallback_prefix);
3863 let continuation_style = blockquote_continuation_style(&line_data);
3864
3865 let adjusted_line_length = options
3866 .line_length
3867 .saturating_sub(display_len(&explicit_prefix, options.length_mode))
3868 .max(1);
3869
3870 let adjusted_options = ReflowOptions {
3871 line_length: adjusted_line_length,
3872 ..options.clone()
3873 };
3874
3875 let styled_lines = reflow_blockquote_content(&line_data, &explicit_prefix, continuation_style, &adjusted_options);
3876
3877 if styled_lines.is_empty() {
3878 return None;
3879 }
3880
3881 let mut start_byte = 0;
3883 for line in lines.iter().take(para_start) {
3884 start_byte += line.len() + 1;
3885 }
3886
3887 let mut end_byte = start_byte;
3888 for line in lines.iter().take(para_end + 1).skip(para_start) {
3889 end_byte += line.len() + 1;
3890 }
3891
3892 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
3893 if !includes_trailing_newline {
3894 end_byte -= 1;
3895 }
3896
3897 let reflowed_joined = styled_lines.join("\n");
3898 let reflowed_text = if includes_trailing_newline {
3899 if reflowed_joined.ends_with('\n') {
3900 reflowed_joined
3901 } else {
3902 format!("{reflowed_joined}\n")
3903 }
3904 } else if reflowed_joined.ends_with('\n') {
3905 reflowed_joined.trim_end_matches('\n').to_string()
3906 } else {
3907 reflowed_joined
3908 };
3909
3910 Some(ParagraphReflow {
3911 start_byte,
3912 end_byte,
3913 reflowed_text,
3914 })
3915}
3916
3917pub fn reflow_paragraph_at_line(content: &str, line_number: usize, line_length: usize) -> Option<ParagraphReflow> {
3935 reflow_paragraph_at_line_with_mode(content, line_number, line_length, ReflowLengthMode::default())
3936}
3937
3938pub fn reflow_paragraph_at_line_with_mode(
3940 content: &str,
3941 line_number: usize,
3942 line_length: usize,
3943 length_mode: ReflowLengthMode,
3944) -> Option<ParagraphReflow> {
3945 let options = ReflowOptions {
3946 line_length,
3947 length_mode,
3948 ..Default::default()
3949 };
3950 reflow_paragraph_at_line_with_options(content, line_number, &options)
3951}
3952
3953pub fn reflow_paragraph_at_line_with_options(
3964 content: &str,
3965 line_number: usize,
3966 options: &ReflowOptions,
3967) -> Option<ParagraphReflow> {
3968 if line_number == 0 {
3969 return None;
3970 }
3971
3972 let lines: Vec<&str> = content.lines().collect();
3973
3974 if line_number > lines.len() {
3976 return None;
3977 }
3978
3979 let target_idx = line_number - 1; let target_line = lines[target_idx];
3981 let trimmed = target_line.trim();
3982
3983 if let Some(blockquote_reflow) = reflow_blockquote_paragraph_at_line(content, &lines, target_idx, options) {
3986 return Some(blockquote_reflow);
3987 }
3988
3989 if is_paragraph_boundary(trimmed, target_line) {
3991 return None;
3992 }
3993
3994 let mut para_start = target_idx;
3996 while para_start > 0 {
3997 let prev_idx = para_start - 1;
3998 let prev_line = lines[prev_idx];
3999 let prev_trimmed = prev_line.trim();
4000
4001 if is_paragraph_boundary(prev_trimmed, prev_line) {
4003 break;
4004 }
4005
4006 para_start = prev_idx;
4007 }
4008
4009 let mut para_end = target_idx;
4011 while para_end + 1 < lines.len() {
4012 let next_idx = para_end + 1;
4013 let next_line = lines[next_idx];
4014 let next_trimmed = next_line.trim();
4015
4016 if is_paragraph_boundary(next_trimmed, next_line) {
4018 break;
4019 }
4020
4021 para_end = next_idx;
4022 }
4023
4024 let paragraph_lines = &lines[para_start..=para_end];
4026
4027 let mut start_byte = 0;
4029 for line in lines.iter().take(para_start) {
4030 start_byte += line.len() + 1; }
4032
4033 let mut end_byte = start_byte;
4034 for line in paragraph_lines {
4035 end_byte += line.len() + 1; }
4037
4038 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
4041
4042 if !includes_trailing_newline {
4044 end_byte -= 1;
4045 }
4046
4047 let paragraph_text = paragraph_lines.join("\n");
4049
4050 let reflowed = reflow_markdown(¶graph_text, options);
4052
4053 let reflowed_text = if includes_trailing_newline {
4057 if reflowed.ends_with('\n') {
4059 reflowed
4060 } else {
4061 format!("{reflowed}\n")
4062 }
4063 } else {
4064 if reflowed.ends_with('\n') {
4066 reflowed.trim_end_matches('\n').to_string()
4067 } else {
4068 reflowed
4069 }
4070 };
4071
4072 Some(ParagraphReflow {
4073 start_byte,
4074 end_byte,
4075 reflowed_text,
4076 })
4077}
4078fn decompose_code_span(raw: &str) -> Option<(&str, &str)> {
4084 let marker_len = raw.bytes().take_while(|&b| b == b'`').count();
4085 if marker_len == 0 {
4086 return None;
4087 }
4088 let marker = &raw[..marker_len];
4089 if raw.len() < marker_len * 2 {
4090 return None;
4091 }
4092 let content = &raw[marker_len..raw.len() - marker_len];
4093 Some((content, marker))
4094}
4095
4096#[cfg(test)]
4097mod tests {
4098 use super::*;
4099
4100 #[test]
4104 fn preserves_content_accepts_whitespace_changes_and_rejects_the_rest() {
4105 let accepted: &[(&str, &[&str])] = &[
4106 ("one two three", &["one two three"]),
4107 ("one two three", &["one two", "three"]),
4108 ("one two three", &["one", "two", "three"]),
4109 ("one two ", &["one two"]),
4111 ("日本語のテキスト", &["日本語の", "テキスト"]),
4113 ("_First. Second._", &["_First.", "Second._"]),
4115 ];
4116 for (original, reflowed) in accepted {
4117 let reflowed: Vec<String> = reflowed.iter().map(ToString::to_string).collect();
4118 assert!(
4119 preserves_content(original, &reflowed),
4120 "{original:?} -> {reflowed:?} only moves whitespace"
4121 );
4122 }
4123
4124 let rejected: &[(&str, &[&str])] = &[
4125 ("one two three", &["one two"]),
4127 ("one two", &["one two three"]),
4129 ("one two", &["two one"]),
4131 ("_First. Second._", &["_First._", "_Second._"]),
4133 ("alpha and beta", &["alpha", "andbeta"]),
4135 ("mot suivant : autre", &["mot suivant: autre"]),
4137 ];
4138 for (original, reflowed) in rejected {
4139 let reflowed: Vec<String> = reflowed.iter().map(ToString::to_string).collect();
4140 assert!(
4141 !preserves_content(original, &reflowed),
4142 "{original:?} -> {reflowed:?} changes the text, not just its line breaks"
4143 );
4144 }
4145 }
4146
4147 #[test]
4149 fn reflow_line_falls_back_to_the_input_when_content_would_change() {
4150 let options = ReflowOptions {
4151 line_length: 40,
4152 ..Default::default()
4153 };
4154 let line = "one two three four five six seven eight nine ten";
4155
4156 assert!(preserves_content(line, &reflow_line(line, &options)));
4157 assert_eq!(reflow_line(line, &options), reflow_line_unchecked(line, &options));
4158 }
4159
4160 #[test]
4161 fn cascade_split_line_handles_a_very_long_line_without_overflowing() {
4162 let words: Vec<String> = (0..4000).map(|i| format!("word{i}")).collect();
4168 let line = words.join(" ");
4169
4170 let options = ReflowOptions {
4171 line_length: 80,
4172 length_mode: ReflowLengthMode::Chars,
4173 ..Default::default()
4174 };
4175 let out = cascade_split_line(&line, &options);
4176
4177 assert!(out.len() > 1, "a very long line should split into many lines");
4178 for segment in &out {
4179 assert!(
4180 display_len(segment, ReflowLengthMode::Chars) <= 80 || !segment.contains(' '),
4181 "each wrapped line should fit the width (or be a single unbreakable token)"
4182 );
4183 }
4184 let rejoined = out.join(" ");
4186 let original_words: Vec<&str> = line.split(' ').collect();
4187 let result_words: Vec<&str> = rejoined.split_whitespace().collect();
4188 assert_eq!(original_words, result_words, "reflow must preserve all words in order");
4189 }
4190
4191 #[test]
4196 fn test_helper_function_text_ends_with_abbreviation() {
4197 let abbreviations = get_abbreviations(&None);
4199
4200 assert!(text_ends_with_abbreviation("Dr.", &abbreviations));
4202 assert!(text_ends_with_abbreviation("word Dr.", &abbreviations));
4203 assert!(text_ends_with_abbreviation("e.g.", &abbreviations));
4204 assert!(text_ends_with_abbreviation("i.e.", &abbreviations));
4205 assert!(text_ends_with_abbreviation("Mr.", &abbreviations));
4206 assert!(text_ends_with_abbreviation("Mrs.", &abbreviations));
4207 assert!(text_ends_with_abbreviation("Ms.", &abbreviations));
4208 assert!(text_ends_with_abbreviation("Prof.", &abbreviations));
4209
4210 assert!(!text_ends_with_abbreviation("etc.", &abbreviations));
4212 assert!(!text_ends_with_abbreviation("paradigms.", &abbreviations));
4213 assert!(!text_ends_with_abbreviation("programs.", &abbreviations));
4214 assert!(!text_ends_with_abbreviation("items.", &abbreviations));
4215 assert!(!text_ends_with_abbreviation("systems.", &abbreviations));
4216 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)); }
4222
4223 #[test]
4224 fn test_footnote_after_period_splits_sentence() {
4225 let text = "First sentence.[^1] Second sentence.";
4229 let sentences = split_into_sentences(text);
4230 assert_eq!(
4231 sentences,
4232 vec!["First sentence.[^1]".to_string(), "Second sentence.".to_string()],
4233 "footnote glued to the period should keep the boundary and stay attached to the first sentence"
4234 );
4235 }
4236
4237 #[test]
4238 fn test_multiple_consecutive_footnotes_after_period_splits_sentence() {
4239 let text = "Notes here.[^1][^2] Second sentence.";
4241 let sentences = split_into_sentences(text);
4242 assert_eq!(
4243 sentences,
4244 vec!["Notes here.[^1][^2]".to_string(), "Second sentence.".to_string()]
4245 );
4246 }
4247
4248 #[test]
4249 fn test_footnote_before_period_still_splits_sentence() {
4250 let text = "Annotation here[^1]. Second sentence.";
4254 let sentences = split_into_sentences(text);
4255 assert_eq!(
4256 sentences,
4257 vec!["Annotation here[^1].".to_string(), "Second sentence.".to_string()]
4258 );
4259 }
4260
4261 #[test]
4262 fn test_mid_sentence_footnote_does_not_split() {
4263 let text = "The system word[^1] more words. Next sentence.";
4266 let sentences = split_into_sentences(text);
4267 assert_eq!(
4268 sentences,
4269 vec![
4270 "The system word[^1] more words.".to_string(),
4271 "Next sentence.".to_string()
4272 ]
4273 );
4274 }
4275
4276 #[test]
4277 fn test_bare_numeric_bracket_after_period_does_not_split() {
4278 let text = "Citation here.[1] Second sentence.";
4281 let sentences = split_into_sentences(text);
4282 assert_eq!(
4283 sentences,
4284 vec![text.to_string()],
4285 "a bare numeric bracket must not be treated as a sentence boundary"
4286 );
4287 }
4288
4289 #[test]
4290 fn test_footnote_glued_to_following_word_does_not_split() {
4291 let text = "First sentence.[^1]Continued glued text.";
4294 let sentences = split_into_sentences(text);
4295 assert_eq!(sentences, vec![text.to_string()]);
4296 }
4297
4298 #[test]
4299 fn test_footnote_at_end_of_text_is_preserved() {
4300 let text = "Sentence.[^1]";
4303 let sentences = split_into_sentences(text);
4304 assert_eq!(sentences, vec![text.to_string()]);
4305 }
4306
4307 #[test]
4308 fn test_abbreviation_before_footnote_does_not_split() {
4309 let text = "See the notes, e.g.[^1] this one.";
4312 let sentences = split_into_sentences(text);
4313 assert_eq!(
4314 sentences,
4315 vec![text.to_string()],
4316 "e.g. is an abbreviation, not a sentence boundary"
4317 );
4318 }
4319
4320 #[test]
4321 fn test_is_unordered_list_marker() {
4322 assert!(is_unordered_list_marker("- item"));
4324 assert!(is_unordered_list_marker("* item"));
4325 assert!(is_unordered_list_marker("+ item"));
4326 assert!(is_unordered_list_marker("-")); assert!(is_unordered_list_marker("*"));
4328 assert!(is_unordered_list_marker("+"));
4329
4330 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")); }
4341
4342 #[test]
4343 fn test_is_block_boundary() {
4344 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"));
4366 assert!(!is_block_boundary("*emphasis*")); assert!(!is_block_boundary("[link](url)")); assert!(!is_block_boundary("some words here"));
4369 }
4370
4371 #[test]
4372 fn test_definition_list_boundary_in_single_line_paragraph() {
4373 let options = ReflowOptions {
4376 line_length: 80,
4377 ..Default::default()
4378 };
4379 let input = "Term\n: Definition of the term";
4380 let result = reflow_markdown(input, &options);
4381 assert!(
4383 result.contains(": Definition"),
4384 "Definition list item should not be merged into previous line. Got: {result:?}"
4385 );
4386 let lines: Vec<&str> = result.lines().collect();
4387 assert_eq!(lines.len(), 2, "Should remain two separate lines. Got: {lines:?}");
4388 assert_eq!(lines[0], "Term");
4389 assert_eq!(lines[1], ": Definition of the term");
4390 }
4391
4392 #[test]
4393 fn test_is_paragraph_boundary() {
4394 assert!(is_paragraph_boundary("# Heading", "# Heading"));
4396 assert!(is_paragraph_boundary("- item", "- item"));
4397 assert!(is_paragraph_boundary(":::", ":::"));
4398 assert!(is_paragraph_boundary(": definition", ": definition"));
4399
4400 assert!(is_paragraph_boundary("code", " code"));
4402 assert!(is_paragraph_boundary("code", "\tcode"));
4403
4404 assert!(is_paragraph_boundary("| a | b |", "| a | b |"));
4406 assert!(is_paragraph_boundary("a | b", "a | b")); assert!(!is_paragraph_boundary("regular text", "regular text"));
4410 assert!(!is_paragraph_boundary("text", " text")); }
4412
4413 #[test]
4414 fn test_div_marker_boundary_in_reflow_paragraph_at_line() {
4415 let content = "Some paragraph text here.\n\n::: {.callout-note}\nThis is a callout.\n:::\n";
4418 let result = reflow_paragraph_at_line(content, 3, 80);
4420 assert!(result.is_none(), "Div marker line should not be reflowed");
4421 }
4422
4423 #[test]
4424 fn starts_block_construct_detects_block_openers() {
4425 for case in ["- item", "-", "* item", "*", "+ item", "+", "-\titem"] {
4427 assert!(starts_block_construct(case), "bullet: {case:?}");
4428 }
4429 for case in ["1. item", "1) item", "01. x", "000000001. x", "1.\titem"] {
4432 assert!(starts_block_construct(case), "ordered: {case:?}");
4433 }
4434 for case in ["> quote", ">quote", ">"] {
4436 assert!(starts_block_construct(case), "blockquote: {case:?}");
4437 }
4438 for case in ["# heading", "###### h6", "#", "##"] {
4440 assert!(starts_block_construct(case), "heading: {case:?}");
4441 }
4442 for case in ["```", "```rust", "````", "~~~", "~~~text"] {
4444 assert!(starts_block_construct(case), "fence: {case:?}");
4445 }
4446 for case in ["---", "--", "===", "=", "***", "___", "_ _ _", "- - -"] {
4448 assert!(starts_block_construct(case), "setext/thematic: {case:?}");
4449 }
4450 for case in [
4453 "[^1]: text",
4454 "[^note]:",
4455 "[ref]: http://example.com",
4456 "[wat]: url follows",
4457 ] {
4458 assert!(starts_block_construct(case), "definition: {case:?}");
4459 }
4460 for case in ["<div>content", "</div>", "<p>text", "<table>", "<pre>code", "<h1>x"] {
4462 assert!(starts_block_construct(case), "html block: {case:?}");
4463 }
4464 }
4465
4466 #[test]
4467 fn starts_block_construct_allows_ordinary_prose() {
4468 for case in [
4469 "",
4470 "word",
4471 "-5 degrees",
4472 "--flag",
4473 "-item",
4474 "#hashtag",
4475 "####### seven hashes is not a heading",
4476 "1.5 million",
4477 "1234567890. ten digits is not a list marker",
4478 "0000000001. ten digits is not a list marker either",
4479 "2. item",
4482 "7. item",
4483 "0. item",
4484 "42) x",
4485 "123456. item",
4486 "1.",
4487 "1)",
4488 "123456.",
4489 "123456)",
4490 "1.item",
4491 "1:30 pm",
4492 "*emphasis*",
4493 "**bold** text",
4494 "__bold__ text",
4495 "_emphasis_ text",
4496 "`code` span",
4497 "`` double backtick span ``",
4498 "~~strikethrough~~",
4499 "=x",
4500 "== ==",
4501 "(parenthetical)",
4502 "[link](url)",
4503 "[text][ref] more",
4504 "[bracketed] aside",
4505 "[a](b) [ref]: first bracket is a link, not a label",
4506 "[esc\\]: not a close] text",
4507 "<span>inline</span>",
4508 "<b>bold</b>",
4509 "<https://example.com> autolink",
4510 "<mailto:a@b.com>",
4511 "<notarealtag>",
4512 ] {
4513 assert!(!starts_block_construct(case), "prose: {case:?}");
4514 }
4515 }
4516
4517 #[test]
4518 fn merge_block_construct_continuations_merges_marker_led_lines() {
4519 let lines = vec![
4520 "First sentence?".to_string(),
4521 "- looks like a list item".to_string(),
4522 "Second sentence.".to_string(),
4523 ];
4524 assert_eq!(
4525 merge_block_construct_continuations(lines),
4526 vec![
4527 "First sentence? - looks like a list item".to_string(),
4528 "Second sentence.".to_string(),
4529 ]
4530 );
4531
4532 let lines = vec!["- real list content".to_string(), "continuation".to_string()];
4535 assert_eq!(
4536 merge_block_construct_continuations(lines.clone()),
4537 lines,
4538 "first line must never be merged"
4539 );
4540
4541 let lines = vec!["prose".to_string(), "1.".to_string(), "[ref]:".to_string()];
4544 assert_eq!(
4545 merge_block_construct_continuations(lines),
4546 vec!["prose 1. [ref]:".to_string()],
4547 "a merge that creates an opener must fold again"
4548 );
4549 }
4550
4551 #[test]
4552 fn wrap_never_starts_a_line_with_a_block_marker() {
4553 let options = ReflowOptions {
4554 line_length: 25,
4555 ..Default::default()
4556 };
4557 let lines = reflow_line(
4560 "Some words here and then - a dash clause that wraps around the limit.",
4561 &options,
4562 );
4563 assert_eq!(
4564 lines,
4565 vec![
4566 "Some words here and",
4567 "then - a dash clause that",
4568 "wraps around the limit."
4569 ]
4570 );
4571
4572 for input in [
4574 "Alpha beta gamma delta epsilon - dash clause here to wrap",
4575 "Alpha beta gamma delta epsilon > quote lookalike here to wrap",
4576 "Alpha beta gamma delta epsilon # heading lookalike here to wrap",
4577 "Alpha beta gamma delta epsilon 1. ordered lookalike here to wrap",
4578 "Alpha beta gamma delta epsilon * star clause here to wrap",
4579 "Alpha beta gamma delta epsilon + plus clause here to wrap",
4580 ] {
4581 for width in 10..40 {
4582 let options = ReflowOptions {
4583 line_length: width,
4584 ..Default::default()
4585 };
4586 for line in reflow_line(input, &options) {
4587 assert!(
4588 !starts_block_construct(&line),
4589 "width {width}: wrapped line opens a block construct: {line:?} (input {input:?})"
4590 );
4591 }
4592 }
4593 }
4594 }
4595
4596 #[test]
4597 fn sentence_per_line_keeps_block_markers_mid_line() {
4598 let options = ReflowOptions {
4599 line_length: 80,
4600 sentence_per_line: true,
4601 ..Default::default()
4602 };
4603 let lines = reflow_line(
4606 "Google Calendar (Can't we get rid of this dependency? - I don't really see the need)",
4607 &options,
4608 );
4609 assert_eq!(
4610 lines,
4611 vec!["Google Calendar (Can't we get rid of this dependency? - I don't really see the need)".to_string()]
4612 );
4613
4614 let lines = reflow_line("See section 4? # is the marker we use. Fine.", &options);
4616 assert_eq!(lines, vec!["See section 4? # is the marker we use.", "Fine."]);
4617
4618 let lines = reflow_line("Is this a problem? > I quote someone here.", &options);
4619 assert_eq!(lines, vec!["Is this a problem? > I quote someone here."]);
4620
4621 let lines = reflow_line("Another case! 1. Not a list. More text follows here.", &options);
4622 for line in &lines {
4623 assert!(
4624 !starts_block_construct(line),
4625 "sentence-per-line output opens a block construct: {line:?}"
4626 );
4627 }
4628 }
4629
4630 #[test]
4631 fn inline_math_directly_after_display_math_stays_atomic() {
4632 let options = ReflowOptions {
4640 line_length: 8,
4641 ..Default::default()
4642 };
4643 let lines = reflow_line("$$a$$$bb cc dd$ x", &options);
4644 assert_eq!(lines, vec!["$$a$$$bb cc dd$".to_string(), "x".to_string()]);
4645 }
4646
4647 #[test]
4648 fn test_code_span_parsing() {
4649 let elements = parse_markdown_elements_inner("`code`", false, false, None);
4651 assert_eq!(elements.len(), 1);
4652 assert!(matches!(&elements[0], Element::Code { content, marker } if content == "code" && marker == "`"));
4653
4654 let elements = parse_markdown_elements_inner("``code``", false, false, None);
4656 assert_eq!(elements.len(), 1);
4657 assert!(matches!(&elements[0], Element::Code { content, marker } if content == "code" && marker == "``"));
4658
4659 let elements = parse_markdown_elements_inner("``code`inside``", false, false, None);
4661 assert_eq!(elements.len(), 1);
4662 assert!(
4663 matches!(&elements[0], Element::Code { content, marker } if content == "code`inside" && marker == "``")
4664 );
4665
4666 let elements = parse_markdown_elements_inner("`` code ``", false, false, None);
4668 assert_eq!(elements.len(), 1);
4669 assert!(matches!(&elements[0], Element::Code { content, marker } if content == " code " && marker == "``"));
4670
4671 let elements = parse_markdown_elements_inner("`unclosed", false, false, None);
4673 assert_eq!(elements.len(), 1);
4674 assert!(matches!(&elements[0], Element::Text(s) if s == "`unclosed"));
4675
4676 let elements = parse_markdown_elements_inner("`unclosed [link](url)", false, false, None);
4678 assert_eq!(elements.len(), 2);
4680 assert!(matches!(&elements[0], Element::Text(s) if s == "`unclosed "));
4681 assert!(matches!(&elements[1], Element::Link(s) if s == "[link](url)"));
4682 }
4683
4684 #[test]
4685 fn test_reflow_performance_long_input() {
4686 let mut text = String::new();
4689 for i in 1..400 {
4690 let backticks = "`".repeat(i);
4691 text.push_str(&backticks);
4692 text.push(' ');
4693 }
4694
4695 let start = std::time::Instant::now();
4696 let elements = parse_markdown_elements_inner(&text, false, false, None);
4697 let duration = start.elapsed();
4698
4699 assert!(duration.as_millis() < 100, "Parsing took too long: {duration:?}");
4701 assert!(!elements.is_empty());
4702 }
4703
4704 #[test]
4705 fn test_reflow_performance_display_math_heavy() {
4706 let text = "$$a$$".repeat(4000);
4711
4712 let start = std::time::Instant::now();
4713 let elements = parse_markdown_elements_inner(&text, false, false, None);
4714 let duration = start.elapsed();
4715
4716 assert!(duration.as_millis() < 100, "Parsing took too long: {duration:?}");
4717 assert_eq!(elements.len(), 4000);
4718 }
4719
4720 #[test]
4721 fn inline_math_len_at_start_matches_regex_at_slice_start() {
4722 let alphabet = ['$', 'a', ' '];
4727 let mut inputs: Vec<String> = vec![String::new()];
4728 let mut frontier: Vec<String> = vec![String::new()];
4729 for _ in 0..6 {
4730 let mut longer = Vec::new();
4731 for prefix in &frontier {
4732 for ch in alphabet {
4733 let mut s = prefix.clone();
4734 s.push(ch);
4735 longer.push(s);
4736 }
4737 }
4738 inputs.extend(longer.iter().cloned());
4739 frontier = longer;
4740 }
4741 inputs.push("$αβ$x".to_string());
4743 inputs.push("$α$$".to_string());
4744
4745 for s in &inputs {
4746 let expected = INLINE_MATH_REGEX
4747 .find(s)
4748 .ok()
4749 .flatten()
4750 .filter(|m| m.start() == 0)
4751 .map(|m| m.end());
4752 assert_eq!(inline_math_len_at_start(s), expected, "input: {s:?}");
4753 }
4754 }
4755
4756 #[test]
4757 fn inline_math_probe_after_dollar_matches_uncached_parse() {
4758 let cases = [
4764 ("$$a$$$b c$ x", r#"[DisplayMath("a"), InlineMath("b c"), Text(" x")]"#),
4765 (
4766 "$$a$$$b$ $$a$$$b$",
4767 r#"[DisplayMath("a"), InlineMath("b"), Text(" "), DisplayMath("a"), InlineMath("b")]"#,
4768 ),
4769 (
4771 "$$a$$$ x $y z$",
4772 r#"[DisplayMath("a"), InlineMath(" x "), Text("y z$")]"#,
4773 ),
4774 ("$$a$$$$ x", r#"[DisplayMath("a"), Text("$$ x")]"#),
4776 ("$$a$$$$b$$ x", r#"[DisplayMath("a"), DisplayMath("b"), Text(" x")]"#),
4777 (
4779 "$a$$b$$c$$d$ tail",
4780 r#"[Text("$a"), DisplayMath("b"), Text("c$$d$ tail")]"#,
4781 ),
4782 ];
4783 for (input, expected) in cases {
4784 let elements = parse_markdown_elements_inner(input, false, false, None);
4785 assert_eq!(format!("{elements:?}"), expected, "input: {input:?}");
4786 }
4787 }
4788
4789 #[test]
4790 fn test_atomic_spans() {
4791 let text_emphasis = "hello **word1 word2**";
4793
4794 let options_disabled = ReflowOptions {
4795 line_length: 18,
4796 atomic_spans: true,
4797 ..Default::default()
4798 };
4799 let lines_disabled = reflow_line(text_emphasis, &options_disabled);
4800 assert_eq!(lines_disabled, vec!["hello", "**word1 word2**"]);
4801
4802 let options_enabled = ReflowOptions {
4803 line_length: 18,
4804 atomic_spans: false,
4805 ..Default::default()
4806 };
4807 let lines_enabled = reflow_line(text_emphasis, &options_enabled);
4808 assert_eq!(lines_enabled, vec!["hello **word1", "word2**"]);
4809
4810 let text_code = "hello `word1 word2`";
4812
4813 let lines_code_disabled = reflow_line(text_code, &options_disabled);
4814 assert_eq!(lines_code_disabled, vec!["hello", "`word1 word2`"]);
4815
4816 let lines_code_enabled = reflow_line(text_code, &options_enabled);
4817 assert_eq!(lines_code_enabled, vec!["hello `word1", "word2`"]);
4818
4819 let text_code_padding = "hello `` `word1` `word2` ``";
4821 let lines_padding_enabled = reflow_line(text_code_padding, &options_enabled);
4822 assert_eq!(lines_padding_enabled, vec![r#"hello `` `word1`"#, r#"`word2` ``"#]);
4823 }
4824
4825 #[test]
4826 fn test_emphasis_containing_markers_is_not_split() {
4827 let options = ReflowOptions {
4828 line_length: 5,
4829 atomic_spans: false,
4830 ..Default::default()
4831 };
4832 let lines = reflow_line(r#"*foo \*bar*"#, &options);
4834 assert_eq!(lines, vec![r#"*foo \*bar*"#.to_string()]);
4835 }
4836
4837 fn semantic_shape(markdown: &str) -> String {
4842 let mut options = Options::empty();
4843 options.insert(Options::ENABLE_STRIKETHROUGH);
4844 let mut out = String::new();
4845 let push_prose = |out: &mut String, text: &str| {
4846 for c in text.chars() {
4847 if c.is_whitespace() {
4848 if !out.ends_with(char::is_whitespace) {
4849 out.push(' ');
4850 }
4851 } else {
4852 out.push(c);
4853 }
4854 }
4855 };
4856 for event in Parser::new_ext(markdown, options) {
4857 match event {
4858 Event::Text(text) => push_prose(&mut out, &text),
4859 Event::SoftBreak | Event::HardBreak => push_prose(&mut out, " "),
4860 Event::Code(code) => out.push_str(&format!("<code>{code}</code>")),
4862 Event::Start(tag) => out.push_str(&format!("<{tag:?}>")),
4863 Event::End(tag) => out.push_str(&format!("</{tag:?}>")),
4864 other => out.push_str(&format!("{other:?}")),
4865 }
4866 }
4867 out.trim().to_string()
4868 }
4869
4870 #[test]
4871 fn test_wrapping_a_span_never_changes_what_it_parses_to() {
4872 let corpus = [
4876 "_This is a very, very, very, very, very long line with some `code` inside._",
4877 "_alpha beta gamma delta epsilon `a b` zeta eta theta iota kappa lambda_",
4878 "**strong text with `code` and more words than fit on one single line**",
4879 "~~struck text with `code` and more words than fit on one single line~~",
4880 "_emphasis with **nested strong that is quite long** and trailing words_",
4881 "***A doubly nested bold italic span with more words than fit on a line***",
4884 "___Another doubly nested span with more words than fit on a single line___",
4885 "**_mixed strong then emphasis with more words than fit on a single line_**",
4886 "*__mixed emphasis then strong with more words than fit on a single line__*",
4887 "**~~strong strikethrough with more words than fit on a single line here~~**",
4888 "**a * b with a stray marker and plenty more words to pass the budget**",
4891 "_foo `a` bar `b` baz qux quux corge grault garply waldo fred plugh xyzzy_",
4892 "text before _a long emphasis with `code` inside of it here_ and after",
4893 "(_a parenthesized long emphasis with `code` inside of it right here_)",
4894 r#"*foo \*bar baz qux quux corge grault garply waldo fred plugh xyzzy*"#,
4895 "_tab\tseparated `a\tb` words spread out over quite a long emphasis span_",
4896 "_This has [text](<a b c d e f g h i j k>) and trailing words to wrap._",
4899 "_This has [`code`](<a b c d e f g h i j k>) and trailing words to wrap._",
4900 r#"_See [x](https://example.com "a long link title here") and `code` too._"#,
4901 "_A [link with a long label](https://example.com/path) and `code` here._",
4902 "_An image  plus `code` and more text_",
4903 ];
4904 for text in corpus {
4905 let expected = semantic_shape(text);
4906 for line_length in [20, 30, 40, 80] {
4907 for atomic_spans in [true, false] {
4908 let options = ReflowOptions {
4909 line_length,
4910 atomic_spans,
4911 ..Default::default()
4912 };
4913 let wrapped = reflow_line(text, &options).join("\n");
4914 assert_eq!(
4915 semantic_shape(&wrapped),
4916 expected,
4917 "reflow changed the parse of {text:?} at line_length={line_length} \
4918 atomic_spans={atomic_spans}\n wrapped: {wrapped:?}"
4919 );
4920 }
4921 }
4922 }
4923 }
4924
4925 #[test]
4926 fn test_wrapping_a_span_keeps_whole_the_constructs_pulldown_cannot_see() {
4927 let cases = [
4931 (
4932 "_alpha beta gamma [[a wiki link]] delta epsilon zeta eta_",
4933 "[[a wiki link]]",
4934 ),
4935 (
4936 "_alpha beta gamma {{< foo bar >}} delta epsilon zeta eta_",
4937 "{{< foo bar >}}",
4938 ),
4939 ("_alpha beta gamma $a + b$ delta epsilon zeta eta theta_", "$a + b$"),
4940 ("_alpha beta gamma $$a + b$$ delta epsilon zeta eta theta_", "$$a + b$$"),
4941 ];
4942 for (text, construct) in cases {
4943 for line_length in [12, 20, 30] {
4944 for atomic_spans in [true, false] {
4945 let options = ReflowOptions {
4946 line_length,
4947 atomic_spans,
4948 ..Default::default()
4949 };
4950 let wrapped = reflow_line(text, &options).join("\n");
4951 assert!(
4952 wrapped.contains(construct),
4953 "{construct} was broken at line_length={line_length} \
4954 atomic_spans={atomic_spans}: {wrapped:?}"
4955 );
4956 }
4957 }
4958 }
4959 }
4960
4961 #[test]
4962 fn test_overlong_emphasis_with_nested_code_span_wraps() {
4963 let options = ReflowOptions {
4967 line_length: 80,
4968 atomic_spans: true,
4969 ..Default::default()
4970 };
4971 let text = "_This is a very, very, very, very, very, very, very long line that exceeds 80 characters with some `code` inside._";
4972 let lines = reflow_line(text, &options);
4973 assert_eq!(
4974 lines,
4975 vec![
4976 "_This is a very, very, very, very, very, very, very long line that exceeds 80",
4977 "characters with some `code` inside._",
4978 ]
4979 );
4980 }
4981
4982 #[test]
4983 fn test_overlong_emphasis_with_nested_strong_wraps() {
4984 let options = ReflowOptions {
4986 line_length: 80,
4987 atomic_spans: true,
4988 ..Default::default()
4989 };
4990 let text = "_This is a very, very, very, very, very, very, very long line that exceeds 80 characters with some **bold** inside._";
4991 let lines = reflow_line(text, &options);
4992 assert_eq!(
4993 lines,
4994 vec![
4995 "_This is a very, very, very, very, very, very, very long line that exceeds 80",
4996 "characters with some **bold** inside._",
4997 ]
4998 );
4999 }
5000
5001 #[test]
5002 fn test_overlong_doubly_nested_span_wraps() {
5003 let options = ReflowOptions {
5008 line_length: 80,
5009 atomic_spans: true,
5010 ..Default::default()
5011 };
5012 let body = "This is a very, very, very, very, very, very, very, very, very, very long line that is emphasised.";
5013 for (open, close) in [
5014 ("***", "***"),
5015 ("___", "___"),
5016 ("**_", "_**"),
5017 ("*__", "__*"),
5018 ("**~~", "~~**"),
5019 ] {
5020 let text = format!("{open}{body}{close}");
5021 assert!(text.len() > options.line_length, "case must start over budget");
5022 let lines = reflow_line(&text, &options);
5023 assert!(
5024 lines.len() > 1,
5025 "{open}...{close} should wrap but stayed on one line: {lines:?}"
5026 );
5027 assert!(
5028 lines.iter().all(|line| line.len() <= options.line_length),
5029 "{open}...{close} left a line over the budget: {lines:?}"
5030 );
5031 assert_eq!(
5032 lines.join(" "),
5033 text,
5034 "{open}...{close} wrapping must only replace a space with a newline"
5035 );
5036 }
5037 }
5038
5039 #[test]
5040 fn test_overlong_span_with_stray_marker_stays_whole() {
5041 let options = ReflowOptions {
5045 line_length: 40,
5046 atomic_spans: true,
5047 ..Default::default()
5048 };
5049 let text = "**alpha * beta gamma delta epsilon zeta eta theta iota kappa**";
5050 let lines = reflow_line(text, &options);
5051 assert_eq!(lines, vec![text], "stray marker must keep the span whole");
5052 }
5053
5054 #[test]
5055 fn test_overlong_span_never_breaks_inside_a_nested_reference_link() {
5056 let options = ReflowOptions {
5062 line_length: 30,
5063 atomic_spans: true,
5064 defined_references: Some(HashSet::from([
5065 "ref".to_string(),
5066 "one two three four five six seven".to_string(),
5068 ])),
5069 ..Default::default()
5070 };
5071 for (text, link) in [
5072 (
5073 "_**alpha [one two three four five six seven][ref] beta gamma delta**_",
5074 "[one two three four five six seven][ref]",
5075 ),
5076 (
5077 "**alpha [one two three four five six seven][ref] beta gamma delta**",
5078 "[one two three four five six seven][ref]",
5079 ),
5080 (
5081 "_**alpha ![one two three four five six seven][ref] beta gamma delta**_",
5082 "![one two three four five six seven][ref]",
5083 ),
5084 (
5085 "_**alpha [one two three four five six seven][] beta gamma delta**_",
5086 "[one two three four five six seven][]",
5087 ),
5088 (
5089 "_**alpha [one two three four five six seven] beta gamma delta**_",
5090 "[one two three four five six seven]",
5091 ),
5092 ] {
5093 let lines = reflow_line(text, &options);
5094 assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
5095 assert!(
5096 lines.iter().any(|line| line.contains(link)),
5097 "{link} must stay on one line: {lines:?}"
5098 );
5099 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
5100 }
5101 }
5102
5103 #[test]
5104 fn test_overlong_span_breaks_inside_an_undefined_shortcut_reference() {
5105 let options = ReflowOptions {
5109 line_length: 30,
5110 atomic_spans: true,
5111 defined_references: Some(HashSet::new()),
5112 ..Default::default()
5113 };
5114 let text = "_**alpha [one two three four five six seven] beta gamma delta**_";
5115 let lines = reflow_line(text, &options);
5116 assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
5117 assert!(
5118 !lines
5119 .iter()
5120 .any(|line| line.contains("[one two three four five six seven]")),
5121 "an undefined shortcut is prose and should break: {lines:?}"
5122 );
5123 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
5124 }
5125
5126 #[test]
5127 fn test_overlong_span_never_breaks_inside_a_nested_attr_list() {
5128 let attr = "{.highlight key=\"a b c\"}";
5132 let text = format!("_**alpha beta gamma delta epsilon zeta{attr} eta theta iota kappa**_");
5133 let options = ReflowOptions {
5134 line_length: 20,
5135 atomic_spans: true,
5136 attr_lists: true,
5137 ..Default::default()
5138 };
5139 let lines = reflow_line(&text, &options);
5140 assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
5141 assert!(
5142 lines.iter().any(|line| line.contains(attr)),
5143 "attr list must stay on one line: {lines:?}"
5144 );
5145 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
5146
5147 let plain = ReflowOptions {
5150 attr_lists: false,
5151 ..options
5152 };
5153 let lines = reflow_line(&text, &plain);
5154 assert!(
5155 !lines.iter().any(|line| line.contains(attr)),
5156 "without the flavor the braces are prose and should break: {lines:?}"
5157 );
5158 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
5159 }
5160
5161 #[test]
5162 fn test_overlong_emphasis_never_breaks_inside_nested_code_span() {
5163 let options = ReflowOptions {
5167 line_length: 30,
5168 atomic_spans: true,
5169 ..Default::default()
5170 };
5171 let text = "_alpha beta gamma delta epsilon `a b` zeta eta theta iota kappa_";
5172 let lines = reflow_line(text, &options);
5173 assert!(lines.len() > 1, "over-long emphasis should wrap: {lines:?}");
5174 assert!(
5175 lines.iter().any(|line| line.contains("`a b`")),
5176 "nested code span must stay whole with its interior spaces: {lines:?}"
5177 );
5178 for line in &lines {
5179 assert_eq!(
5180 line.matches('`').count() % 2,
5181 0,
5182 "no line may contain half a code span: {line:?}"
5183 );
5184 }
5185 }
5186
5187 #[test]
5188 fn test_definition_list_marker_does_not_start_line() {
5189 let options = ReflowOptions {
5190 line_length: 20,
5191 ..Default::default()
5192 };
5193 let lines = reflow_line("This is a term and : definition here.", &options);
5195 for line in &lines {
5196 assert!(
5197 !line.trim_start().starts_with(": "),
5198 "Wrapped line should not start with definition marker: {line}"
5199 );
5200 }
5201 }
5202
5203 #[test]
5204 fn test_div_marker_does_not_start_line() {
5205 let options = ReflowOptions {
5206 line_length: 20,
5207 ..Default::default()
5208 };
5209 let lines = reflow_line("This is some text with ::: class marker.", &options);
5211 for line in &lines {
5212 assert!(
5213 !line.trim_start().starts_with(":::"),
5214 "Wrapped line should not start with div marker: {line}"
5215 );
5216 }
5217 }
5218}