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 inside_quotation = is_closing_quote(next_char);
477
478 let (_space_pos, after_space_pos) = if next_char == ' ' {
480 (pos + 1, pos + 2)
482 } else if is_closing_quote(next_char) && pos + 2 < chars.len() {
483 if chars[pos + 2] == ' ' {
485 (pos + 2, pos + 3)
487 } else if (chars[pos + 2] == '*' || chars[pos + 2] == '_') && pos + 3 < chars.len() && chars[pos + 3] == ' ' {
488 (pos + 3, pos + 4)
490 } else if (chars[pos + 2] == '*' || chars[pos + 2] == '_')
491 && pos + 4 < chars.len()
492 && chars[pos + 3] == chars[pos + 2]
493 && chars[pos + 4] == ' '
494 {
495 (pos + 4, pos + 5)
497 } else {
498 return false;
499 }
500 } else if (next_char == '*' || next_char == '_') && pos + 2 < chars.len() && chars[pos + 2] == ' ' {
501 (pos + 2, pos + 3)
503 } else if (next_char == '*' || next_char == '_')
504 && pos + 3 < chars.len()
505 && chars[pos + 2] == next_char
506 && chars[pos + 3] == ' '
507 {
508 (pos + 3, pos + 4)
510 } else if next_char == '~' && pos + 3 < chars.len() && chars[pos + 2] == '~' && chars[pos + 3] == ' ' {
511 (pos + 3, pos + 4)
513 } else if next_char == '[' {
514 match footnote_refs_end(chars, pos + 1) {
520 Some(end_pos) if chars.get(end_pos) == Some(&' ') => (end_pos, end_pos + 1),
521 _ => return false,
522 }
523 } else {
524 return false;
525 };
526
527 let mut next_char_pos = after_space_pos;
529 while next_char_pos < chars.len() && chars[next_char_pos].is_whitespace() {
530 next_char_pos += 1;
531 }
532
533 if next_char_pos >= chars.len() {
535 return false;
536 }
537
538 let mut first_letter_pos = next_char_pos;
540 while first_letter_pos < chars.len()
541 && (chars[first_letter_pos] == '*'
542 || chars[first_letter_pos] == '_'
543 || chars[first_letter_pos] == '~'
544 || is_opening_quote(chars[first_letter_pos]))
545 {
546 first_letter_pos += 1;
547 }
548
549 if first_letter_pos >= chars.len() {
551 return false;
552 }
553
554 let first_char = chars[first_letter_pos];
555
556 if c == '!' || c == '?' {
562 return !inside_quotation || !require_sentence_capital || first_char.is_uppercase() || is_cjk_char(first_char);
563 }
564
565 if pos > 0 {
569 if text_ends_with_abbreviation(&text[..byte_offset_after_punct], abbreviations) {
571 return false;
572 }
573
574 if chars[pos - 1].is_numeric() && first_char.is_ascii_digit() {
576 return false;
577 }
578
579 if chars[pos - 1].is_ascii_uppercase() && (pos == 1 || (pos >= 2 && chars[pos - 2].is_whitespace())) {
583 return false;
584 }
585 }
586
587 if require_sentence_capital && !first_char.is_uppercase() && !is_cjk_char(first_char) {
590 return false;
591 }
592
593 true
594}
595
596pub fn split_into_sentences(text: &str) -> Vec<String> {
598 split_into_sentences_custom(text, &None)
599}
600
601pub fn split_into_sentences_custom(text: &str, custom_abbreviations: &Option<Vec<String>>) -> Vec<String> {
603 let abbreviations = get_abbreviations(custom_abbreviations);
604 split_into_sentences_with_set(text, &abbreviations, true)
605}
606
607fn split_into_sentences_with_set(
610 text: &str,
611 abbreviations: &HashSet<String>,
612 require_sentence_capital: bool,
613) -> Vec<String> {
614 let char_vec: Vec<char> = text.chars().collect();
615
616 let mut char_offsets = Vec::with_capacity(char_vec.len() + 1);
620 let mut offset = 0;
621 for c in &char_vec {
622 char_offsets.push(offset);
623 offset += c.len_utf8();
624 }
625 char_offsets.push(offset);
626
627 let code_spans = extract_code_spans(text);
629 let mut span_it = code_spans.iter().peekable();
630
631 let mut sentences = Vec::new();
632 let mut current_sentence = String::new();
633 let mut pos = 0;
634
635 while pos < char_vec.len() {
636 let c = char_vec[pos];
637 current_sentence.push(c);
638
639 let byte_idx = char_offsets[pos];
640
641 while let Some(span) = span_it.peek() {
643 if span.end <= byte_idx {
644 span_it.next();
645 } else {
646 break;
647 }
648 }
649
650 let in_code = if let Some(span) = span_it.peek() {
652 byte_idx >= span.start && byte_idx < span.end
653 } else {
654 false
655 };
656
657 if !in_code
658 && is_sentence_boundary(
659 text,
660 &char_vec,
661 pos,
662 char_offsets[pos + 1],
663 abbreviations,
664 require_sentence_capital,
665 )
666 {
667 if let Some(end_pos) = footnote_refs_end(&char_vec, pos + 1) {
669 while pos + 1 < end_pos {
670 pos += 1;
671 current_sentence.push(char_vec[pos]);
672 }
673 }
674
675 while pos + 1 < char_vec.len() {
677 let next = char_vec[pos + 1];
678 if next == '*' || next == '_' || next == '~' || is_closing_quote(next) {
679 pos += 1;
680 current_sentence.push(char_vec[pos]);
681 } else {
682 break;
683 }
684 }
685
686 if pos + 1 < char_vec.len() && char_vec[pos + 1] == ' ' {
688 pos += 1; }
690
691 sentences.push(current_sentence.trim().to_string());
692 current_sentence.clear();
693 }
694
695 pos += 1;
696 }
697
698 if !current_sentence.trim().is_empty() {
700 sentences.push(current_sentence.trim().to_string());
701 }
702 sentences
703}
704
705fn is_horizontal_rule(line: &str) -> bool {
707 if line.len() < 3 {
708 return false;
709 }
710
711 let mut chars = line.chars();
714 let Some(first_char) = chars.next() else {
715 return false;
716 };
717 if first_char != '-' && first_char != '_' && first_char != '*' {
718 return false;
719 }
720
721 let mut non_space_count = 1usize; for c in chars {
723 if c == ' ' {
724 continue;
725 }
726 if c != first_char {
727 return false;
728 }
729 non_space_count += 1;
730 }
731 non_space_count >= 3
732}
733
734fn is_numbered_list_item(line: &str) -> bool {
736 let mut chars = line.chars();
737
738 if !chars.next().is_some_and(char::is_numeric) {
740 return false;
741 }
742
743 while let Some(c) = chars.next() {
745 if c == '.' {
746 return chars.next() == Some(' ');
749 }
750 if !c.is_numeric() {
751 return false;
752 }
753 }
754
755 false
756}
757
758fn is_unordered_list_marker(s: &str) -> bool {
760 matches!(s.as_bytes().first(), Some(b'-' | b'*' | b'+'))
761 && !is_horizontal_rule(s)
762 && (s.len() == 1 || s.as_bytes().get(1) == Some(&b' '))
763}
764
765fn is_block_boundary_core(trimmed: &str) -> bool {
768 trimmed.is_empty()
769 || trimmed.starts_with('#')
770 || trimmed.starts_with("```")
771 || trimmed.starts_with("~~~")
772 || trimmed.starts_with('>')
773 || (trimmed.starts_with('[') && trimmed.contains("]:"))
774 || is_horizontal_rule(trimmed)
775 || is_unordered_list_marker(trimmed)
776 || is_numbered_list_item(trimmed)
777 || is_definition_list_item(trimmed)
778 || trimmed.starts_with(":::")
779}
780
781fn is_block_boundary(trimmed: &str) -> bool {
784 is_block_boundary_core(trimmed) || trimmed.starts_with('|')
785}
786
787fn is_paragraph_boundary(trimmed: &str, line: &str) -> bool {
791 is_block_boundary_core(trimmed)
792 || calculate_indentation_width_default(line) >= 4
793 || crate::utils::table_utils::TableUtils::is_potential_table_row(line)
794}
795
796fn has_hard_break(line: &str) -> bool {
802 let line = line.strip_suffix('\r').unwrap_or(line);
803 line.ends_with(" ") || line.ends_with('\\')
804}
805
806fn ends_with_sentence_punct(text: &str) -> bool {
808 text.ends_with('.') || text.ends_with('!') || text.ends_with('?')
809}
810
811fn trim_preserving_hard_break(s: &str) -> String {
817 let s = s.strip_suffix('\r').unwrap_or(s);
819
820 if s.ends_with('\\') {
822 return s.to_string();
824 }
825
826 if s.ends_with(" ") {
828 let content_end = s.trim_end().len();
830 if content_end == 0 {
831 return String::new();
833 }
834 format!("{} ", &s[..content_end])
836 } else {
837 s.trim_end().to_string()
839 }
840}
841
842fn parse_elements(text: &str, options: &ReflowOptions) -> Vec<Element> {
844 parse_markdown_elements_inner(
845 text,
846 options.attr_lists,
847 options.myst_roles,
848 options.defined_references.as_ref(),
849 )
850}
851
852pub fn reflow_line(line: &str, options: &ReflowOptions) -> Vec<String> {
862 let reflowed = reflow_line_unchecked(line, options);
863 if preserves_content(line, &reflowed) {
864 reflowed
865 } else {
866 vec![line.to_string()]
867 }
868}
869
870fn preserves_content(original: &str, reflowed: &[String]) -> bool {
877 let (original_text, original_breaks) = visible_text_and_breaks(original.chars());
878 let (reflowed_text, reflowed_breaks) =
879 visible_text_and_breaks(reflowed.iter().flat_map(|line| line.chars().chain(['\n'])));
880
881 original_text == reflowed_text && contains_all(&reflowed_breaks, &original_breaks)
882}
883
884fn visible_text_and_breaks(text: impl Iterator<Item = char>) -> (String, Vec<usize>) {
887 let mut visible = String::new();
888 let mut breaks = Vec::new();
889 let mut count = 0usize;
890 let mut pending_break = false;
891
892 for c in text {
893 if c.is_whitespace() {
894 pending_break = count > 0;
895 } else {
896 if pending_break {
897 breaks.push(count);
898 pending_break = false;
899 }
900 visible.push(c);
901 count += 1;
902 }
903 }
904
905 (visible, breaks)
906}
907
908fn contains_all(superset: &[usize], subset: &[usize]) -> bool {
910 let mut candidates = superset.iter();
911 subset
912 .iter()
913 .all(|wanted| candidates.by_ref().any(|found| found == wanted))
914}
915
916fn reflow_line_unchecked(line: &str, options: &ReflowOptions) -> Vec<String> {
917 if options.sentence_per_line {
919 let elements = parse_elements(line, options);
920 return merge_block_construct_continuations(reflow_elements_sentence_per_line(
921 &elements,
922 &options.abbreviations,
923 options.require_sentence_capital,
924 ));
925 }
926
927 if options.semantic_line_breaks {
929 let elements = parse_elements(line, options);
930 return merge_block_construct_continuations(reflow_elements_semantic(&elements, options));
931 }
932
933 if options.line_length == 0 || display_len(line, options.length_mode) <= options.line_length {
936 return vec![line.to_string()];
937 }
938
939 let elements = parse_elements(line, options);
941
942 merge_block_construct_continuations(reflow_elements(&elements, options))
944}
945
946#[derive(Debug, Clone)]
948enum Element {
949 Text(String),
951 Link(String),
953 ReferenceLink(String),
955 EmptyReferenceLink(String),
957 ShortcutReference(String),
959 InlineImage(String),
961 ReferenceImage(String),
963 EmptyReferenceImage(String),
965 LinkedImage(String),
967 FootnoteReference(String),
969 Strikethrough {
971 content: String,
972 double: bool,
974 },
975 WikiLink(String),
977 InlineMath(String),
979 DisplayMath(String),
981 EmojiShortcode(String),
983 Autolink(String),
985 HtmlTag(String),
987 HtmlEntity(String),
989 HugoShortcode(String),
991 AttrList(String),
993 MystRole(String),
997 Code { content: String, marker: String },
999 Bold {
1001 content: String,
1002 underscore: bool,
1004 },
1005 Italic {
1007 content: String,
1008 underscore: bool,
1010 },
1011}
1012
1013impl std::fmt::Display for Element {
1014 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1015 match self {
1016 Element::Text(s) => write!(f, "{s}"),
1017 Element::Link(s) => write!(f, "{s}"),
1018 Element::ReferenceLink(s) => write!(f, "{s}"),
1019 Element::EmptyReferenceLink(s) => write!(f, "{s}"),
1020 Element::ShortcutReference(s) => write!(f, "{s}"),
1021 Element::InlineImage(s) => write!(f, "{s}"),
1022 Element::ReferenceImage(s) => write!(f, "{s}"),
1023 Element::EmptyReferenceImage(s) => write!(f, "{s}"),
1024 Element::LinkedImage(s) => write!(f, "{s}"),
1025 Element::FootnoteReference(s) => write!(f, "{s}"),
1026 Element::Strikethrough { content, double } => {
1027 let marker = if *double { "~~" } else { "~" };
1028 write!(f, "{marker}{content}{marker}")
1029 }
1030 Element::WikiLink(s) => write!(f, "[[{s}]]"),
1031 Element::InlineMath(s) => write!(f, "${s}$"),
1032 Element::DisplayMath(s) => write!(f, "$${s}$$"),
1033 Element::EmojiShortcode(s) => write!(f, ":{s}:"),
1034 Element::Autolink(s) => write!(f, "{s}"),
1035 Element::HtmlTag(s) => write!(f, "{s}"),
1036 Element::HtmlEntity(s) => write!(f, "{s}"),
1037 Element::HugoShortcode(s) => write!(f, "{s}"),
1038 Element::AttrList(s) => write!(f, "{s}"),
1039 Element::MystRole(s) => write!(f, "{s}"),
1040 Element::Code { content, marker } => write!(f, "{marker}{content}{marker}"),
1041 Element::Bold { content, underscore } => {
1042 if *underscore {
1043 write!(f, "__{content}__")
1044 } else {
1045 write!(f, "**{content}**")
1046 }
1047 }
1048 Element::Italic { content, underscore } => {
1049 if *underscore {
1050 write!(f, "_{content}_")
1051 } else {
1052 write!(f, "*{content}*")
1053 }
1054 }
1055 }
1056 }
1057}
1058
1059impl Element {
1060 fn display_len(&self, mode: ReflowLengthMode) -> usize {
1061 match self {
1062 Element::Text(s)
1063 | Element::Link(s)
1064 | Element::ReferenceLink(s)
1065 | Element::EmptyReferenceLink(s)
1066 | Element::ShortcutReference(s)
1067 | Element::InlineImage(s)
1068 | Element::ReferenceImage(s)
1069 | Element::EmptyReferenceImage(s)
1070 | Element::LinkedImage(s)
1071 | Element::FootnoteReference(s)
1072 | Element::Autolink(s)
1073 | Element::HtmlTag(s)
1074 | Element::HtmlEntity(s)
1075 | Element::HugoShortcode(s)
1076 | Element::AttrList(s)
1077 | Element::MystRole(s) => display_len(s, mode),
1078 Element::WikiLink(s) => display_len(s, mode) + 4,
1079 Element::InlineMath(s) => display_len(s, mode) + 2,
1080 Element::DisplayMath(s) => display_len(s, mode) + 4,
1081 Element::EmojiShortcode(s) => display_len(s, mode) + 2,
1082 Element::Strikethrough { content, double } => display_len(content, mode) + if *double { 4 } else { 2 },
1083 Element::Code { content, marker } => display_len(content, mode) + display_len(marker, mode) * 2,
1084 Element::Bold { content, .. } => display_len(content, mode) + 4,
1085 Element::Italic { content, .. } => display_len(content, mode) + 2,
1086 }
1087 }
1088}
1089
1090#[derive(Debug, Clone)]
1092struct EmphasisSpan {
1093 start: usize,
1095 end: usize,
1097 content: String,
1099 is_strong: bool,
1101 is_strikethrough: bool,
1103 uses_underscore: bool,
1105 strikethrough_double: bool,
1108}
1109
1110fn extract_emphasis_and_code_spans(text: &str) -> (Vec<EmphasisSpan>, Vec<CodeSpan>) {
1120 let has_emphasis = text.contains(['*', '_', '~']);
1122 let has_code = text.contains('`');
1123 if !has_emphasis && !has_code {
1124 return (Vec::new(), Vec::new());
1125 }
1126
1127 let mut emphasis_spans = Vec::new();
1128 let mut code_spans = Vec::new();
1129
1130 let mut options = Options::empty();
1131 if has_emphasis {
1132 options.insert(Options::ENABLE_STRIKETHROUGH);
1133 }
1134
1135 let mut emphasis_stack: Vec<(usize, bool)> = Vec::new(); let mut strong_stack: Vec<(usize, bool)> = Vec::new();
1138 let mut strikethrough_stack: Vec<usize> = Vec::new();
1139
1140 let parser = Parser::new_ext(text, options).into_offset_iter();
1141
1142 for (event, range) in parser {
1143 match event {
1144 Event::Code(_) => {
1145 code_spans.push(CodeSpan {
1146 start: range.start,
1147 end: range.end,
1148 });
1149 }
1150 Event::Start(Tag::Emphasis) => {
1151 let uses_underscore = text.get(range.start..range.start + 1) == Some("_");
1153 emphasis_stack.push((range.start, uses_underscore));
1154 }
1155 Event::End(TagEnd::Emphasis) => {
1156 if let Some((start_byte, uses_underscore)) = emphasis_stack.pop() {
1157 let content_start = start_byte + 1;
1158 let content_end = range.end - 1;
1159 if content_end > content_start
1160 && let Some(content) = text.get(content_start..content_end)
1161 {
1162 emphasis_spans.push(EmphasisSpan {
1163 start: start_byte,
1164 end: range.end,
1165 content: content.to_string(),
1166 is_strong: false,
1167 is_strikethrough: false,
1168 uses_underscore,
1169 strikethrough_double: false,
1170 });
1171 }
1172 }
1173 }
1174 Event::Start(Tag::Strong) => {
1175 let uses_underscore = text.get(range.start..range.start + 2) == Some("__");
1176 strong_stack.push((range.start, uses_underscore));
1177 }
1178 Event::End(TagEnd::Strong) => {
1179 if let Some((start_byte, uses_underscore)) = strong_stack.pop() {
1180 let content_start = start_byte + 2;
1181 let content_end = range.end - 2;
1182 if content_end > content_start
1183 && let Some(content) = text.get(content_start..content_end)
1184 {
1185 emphasis_spans.push(EmphasisSpan {
1186 start: start_byte,
1187 end: range.end,
1188 content: content.to_string(),
1189 is_strong: true,
1190 is_strikethrough: false,
1191 uses_underscore,
1192 strikethrough_double: false,
1193 });
1194 }
1195 }
1196 }
1197 Event::Start(Tag::Strikethrough) => {
1198 strikethrough_stack.push(range.start);
1199 }
1200 Event::End(TagEnd::Strikethrough) => {
1201 if let Some(start_byte) = strikethrough_stack.pop() {
1202 let double = text.get(start_byte..start_byte + 2) == Some("~~");
1203 let marker_len = if double { 2 } else { 1 };
1204 let content_start = start_byte + marker_len;
1205 let content_end = range.end - marker_len;
1206 if content_end > content_start
1207 && let Some(content) = text.get(content_start..content_end)
1208 {
1209 emphasis_spans.push(EmphasisSpan {
1210 start: start_byte,
1211 end: range.end,
1212 content: content.to_string(),
1213 is_strong: false,
1214 is_strikethrough: true,
1215 uses_underscore: false,
1216 strikethrough_double: double,
1217 });
1218 }
1219 }
1220 }
1221 _ => {}
1222 }
1223 }
1224
1225 emphasis_spans.sort_by_key(|s| s.start);
1226 (emphasis_spans, code_spans)
1227}
1228
1229#[derive(Debug, Clone)]
1230struct CodeSpan {
1231 start: usize,
1232 end: usize,
1233}
1234
1235fn extract_code_spans(text: &str) -> Vec<CodeSpan> {
1236 if !text.contains('`') {
1238 return Vec::new();
1239 }
1240
1241 let mut spans = Vec::new();
1242 let parser = Parser::new(text).into_offset_iter();
1243 for (event, range) in parser {
1244 if let Event::Code(_) = event {
1245 spans.push(CodeSpan {
1246 start: range.start,
1247 end: range.end,
1248 });
1249 }
1250 }
1251 spans
1252}
1253
1254#[derive(Debug, Clone)]
1255struct LinkSpan {
1256 start: usize,
1257 end: usize,
1258 link_type: Option<LinkType>,
1259 is_image: bool,
1260 is_footnote: bool,
1261}
1262
1263fn extract_link_spans(text: &str, defined_references: Option<&HashSet<String>>) -> Vec<LinkSpan> {
1264 if !text.contains('[') {
1267 return Vec::new();
1268 }
1269
1270 let mut spans = Vec::new();
1271 let mut options = Options::empty();
1272 options.insert(Options::ENABLE_FOOTNOTES);
1273
1274 let resolve = move |link: BrokenLink<'_>| -> Option<(CowStr<'_>, CowStr<'_>)> {
1291 let atomic = match link.link_type {
1296 LinkType::Shortcut | LinkType::ShortcutUnknown => match defined_references {
1297 Some(defs) => defs.contains(&normalize_reference_label(link.reference.as_ref())),
1298 None => true,
1299 },
1300 _ => true,
1301 };
1302 atomic.then_some((CowStr::Borrowed(""), CowStr::Borrowed("")))
1303 };
1304 let parser = Parser::new_with_broken_link_callback(text, options, Some(resolve)).into_offset_iter();
1305 let mut stack = Vec::new();
1306
1307 for (event, range) in parser {
1308 match event {
1309 Event::Start(Tag::Link { link_type, .. }) => {
1310 stack.push((range.start, Some(link_type), false));
1311 }
1312 Event::Start(Tag::Image { link_type, .. }) => {
1313 stack.push((range.start, Some(link_type), true));
1314 }
1315 Event::End(TagEnd::Link) => {
1316 if let Some((start_byte, link_type, is_image)) = stack.pop()
1317 && stack.is_empty()
1318 {
1319 spans.push(LinkSpan {
1320 start: start_byte,
1321 end: range.end,
1322 link_type,
1323 is_image,
1324 is_footnote: false,
1325 });
1326 }
1327 }
1328 Event::End(TagEnd::Image) => {
1329 if let Some((start_byte, link_type, is_image)) = stack.pop()
1330 && stack.is_empty()
1331 {
1332 spans.push(LinkSpan {
1333 start: start_byte,
1334 end: range.end,
1335 link_type,
1336 is_image,
1337 is_footnote: false,
1338 });
1339 }
1340 }
1341 Event::FootnoteReference(_) if stack.is_empty() => {
1342 spans.push(LinkSpan {
1343 start: range.start,
1344 end: range.end,
1345 link_type: None,
1346 is_image: false,
1347 is_footnote: true,
1348 });
1349 }
1350 _ => {}
1351 }
1352 }
1353
1354 spans.sort_by_key(|s| s.start);
1355 spans
1356}
1357
1358fn myst_role_len_at(text: &str, absolute_pos: usize, code_spans: &[CodeSpan]) -> Option<usize> {
1366 let bytes = text.as_bytes();
1367 if bytes.first() != Some(&b'{') {
1368 return None;
1369 }
1370
1371 let mut j = 1;
1373 match bytes.get(j) {
1374 Some(&b) if b.is_ascii_alphabetic() || b == b'_' => {}
1375 _ => return None,
1376 }
1377 while let Some(&b) = bytes.get(j) {
1378 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b':' | b'.') {
1379 j += 1;
1380 } else {
1381 break;
1382 }
1383 }
1384 if bytes.get(j) != Some(&b'}') {
1385 return None;
1386 }
1387 j += 1; let code_span_start = absolute_pos + j;
1391 if let Ok(idx) = code_spans.binary_search_by_key(&code_span_start, |span| span.start) {
1392 let span = &code_spans[idx];
1393 let code_span_len = span.end - span.start;
1394 return Some(j + code_span_len);
1395 }
1396
1397 None
1398}
1399
1400fn inline_math_len_at_start(s: &str) -> Option<usize> {
1407 let bytes = s.as_bytes();
1408 if bytes.first() != Some(&b'$') || bytes.get(1) == Some(&b'$') {
1410 return None;
1411 }
1412 let close = 1 + s[1..].find('$')?;
1415 if bytes.get(close + 1) == Some(&b'$') {
1417 return None;
1418 }
1419 Some(close + 1)
1420}
1421
1422#[derive(Clone, Copy, Debug)]
1424struct PatternMatch {
1425 start: usize,
1426 end: usize,
1427}
1428
1429#[derive(Clone, Copy)]
1443enum PatternCache {
1444 Unsearched,
1445 NotFound,
1446 Found(PatternMatch),
1447}
1448
1449impl PatternCache {
1450 fn earliest_in(
1454 &mut self,
1455 remaining: &str,
1456 cursor: usize,
1457 find: impl FnOnce(&str) -> Option<(usize, usize)>,
1458 ) -> Option<(usize, usize)> {
1459 let stale = match self {
1460 PatternCache::Found(pm) => pm.start < cursor,
1461 PatternCache::NotFound => false,
1462 PatternCache::Unsearched => true,
1463 };
1464 if stale {
1465 *self = match find(remaining) {
1466 Some((start, end)) => PatternCache::Found(PatternMatch {
1467 start: cursor + start,
1468 end: cursor + end,
1469 }),
1470 None => PatternCache::NotFound,
1471 };
1472 }
1473 match self {
1474 PatternCache::Found(pm) => Some((pm.start - cursor, pm.end - cursor)),
1475 _ => None,
1476 }
1477 }
1478}
1479
1480fn parse_markdown_elements_inner(
1491 text: &str,
1492 attr_lists: bool,
1493 myst_roles: bool,
1494 defined_references: Option<&HashSet<String>>,
1495) -> Vec<Element> {
1496 let mut elements = Vec::new();
1497 let mut remaining = text;
1498
1499 let (emphasis_spans, code_spans) = extract_emphasis_and_code_spans(text);
1504 let link_spans = extract_link_spans(text, defined_references);
1505
1506 let mut cached_wiki_link = PatternCache::Unsearched;
1509 let mut cached_display_math = PatternCache::Unsearched;
1510 let mut cached_inline_math = PatternCache::Unsearched;
1511 let mut cached_emoji = PatternCache::Unsearched;
1512 let mut cached_html_entity = PatternCache::Unsearched;
1513 let mut cached_hugo_shortcode = PatternCache::Unsearched;
1514 let mut cached_html_tag = PatternCache::Unsearched;
1515 let mut cached_next_curly = PatternCache::Unsearched;
1516
1517 let mut link_span_idx = 0usize;
1521 let mut emphasis_span_idx = 0usize;
1522 let mut code_span_idx = 0usize;
1523
1524 while !remaining.is_empty() {
1525 let current_offset = text.len() - remaining.len();
1527 let mut earliest_match: Option<(usize, usize, &str)> = None;
1530
1531 while link_span_idx < link_spans.len() && link_spans[link_span_idx].start < current_offset {
1533 link_span_idx += 1;
1534 }
1535 let next_link: Option<&LinkSpan> = link_spans.get(link_span_idx);
1536
1537 if let Some(span) = next_link {
1538 let pos_in_remaining = span.start - current_offset;
1539 if earliest_match
1540 .as_ref()
1541 .is_none_or(|(start, _, _)| pos_in_remaining < *start)
1542 {
1543 let match_end = span.end - current_offset;
1544 earliest_match = Some((pos_in_remaining, match_end, "link_span"));
1545 }
1546 }
1547
1548 if let Some((start, end)) = cached_wiki_link.earliest_in(remaining, current_offset, |suffix| {
1550 WIKI_LINK_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, "wiki_link"));
1554 }
1555
1556 if let Some((start, end)) = cached_display_math.earliest_in(remaining, current_offset, |suffix| {
1558 DISPLAY_MATH_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1559 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1560 {
1561 earliest_match = Some((start, end, "display_math"));
1562 }
1563
1564 let inline_math_probe = if current_offset > 0 && text.as_bytes()[current_offset - 1] == b'$' {
1578 inline_math_len_at_start(remaining).map(|len| (0, len))
1579 } else {
1580 None
1581 };
1582 if let Some((start, end)) = inline_math_probe.or_else(|| {
1583 cached_inline_math.earliest_in(remaining, current_offset, |suffix| {
1584 INLINE_MATH_REGEX
1585 .find(suffix)
1586 .ok()
1587 .flatten()
1588 .map(|m| (m.start(), m.end()))
1589 })
1590 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1591 {
1592 earliest_match = Some((start, end, "inline_math"));
1593 }
1594
1595 if let Some((start, end)) = cached_emoji.earliest_in(remaining, current_offset, |suffix| {
1597 EMOJI_SHORTCODE_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, "emoji"));
1601 }
1602
1603 if let Some((start, end)) = cached_html_entity.earliest_in(remaining, current_offset, |suffix| {
1605 HTML_ENTITY_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1606 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1607 {
1608 earliest_match = Some((start, end, "html_entity"));
1609 }
1610
1611 if let Some((start, end)) = cached_hugo_shortcode.earliest_in(remaining, current_offset, |suffix| {
1614 HUGO_SHORTCODE_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1615 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1616 {
1617 earliest_match = Some((start, end, "hugo_shortcode"));
1618 }
1619
1620 if let Some((start, end)) = cached_html_tag.earliest_in(remaining, current_offset, |suffix| {
1627 let mut from = 0;
1628 while let Some(m) = HTML_TAG_PATTERN.find(&suffix[from..]) {
1629 let (tag_start, tag_end) = (from + m.start(), from + m.end());
1630 let tag = &suffix[tag_start..tag_end];
1631 let is_url_autolink = tag.starts_with("<http://")
1633 || tag.starts_with("<https://")
1634 || tag.starts_with("<mailto:")
1635 || tag.starts_with("<ftp://")
1636 || tag.starts_with("<ftps://");
1637 let is_email_autolink = {
1640 let content = tag.trim_start_matches('<').trim_end_matches('>');
1641 EMAIL_PATTERN.is_match(content)
1642 };
1643 if is_url_autolink || is_email_autolink {
1644 from = tag_end;
1645 } else {
1646 return Some((tag_start, tag_end));
1647 }
1648 }
1649 None
1650 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1651 {
1652 earliest_match = Some((start, end, "html_tag"));
1653 }
1654
1655 let mut next_special = remaining.len();
1657 let mut special_type = "";
1658 let mut pulldown_emphasis: Option<&EmphasisSpan> = None;
1659 let mut attr_list_len: usize = 0;
1660 let mut myst_role_len: usize = 0;
1661
1662 while code_span_idx < code_spans.len() && code_spans[code_span_idx].start < current_offset {
1664 code_span_idx += 1;
1665 }
1666 let next_code_span: Option<&CodeSpan> = code_spans.get(code_span_idx);
1667 if let Some(span) = next_code_span {
1668 let pos_in_remaining = span.start - current_offset;
1669 if pos_in_remaining < next_special {
1670 next_special = pos_in_remaining;
1671 special_type = "pulldown_code";
1672 }
1673 }
1674
1675 let next_curly_pos = cached_next_curly
1678 .earliest_in(remaining, current_offset, |suffix| {
1679 suffix.find('{').map(|pos| (pos, pos + 1))
1680 })
1681 .map(|(start, _)| start);
1682
1683 if myst_roles
1688 && let Some(pos) = next_curly_pos
1689 && pos < next_special
1690 && let Some(role_len) = myst_role_len_at(&remaining[pos..], current_offset + pos, &code_spans)
1691 {
1692 next_special = pos;
1693 special_type = "myst_role";
1694 myst_role_len = role_len;
1695 }
1696
1697 if attr_lists
1699 && let Some(pos) = next_curly_pos
1700 && pos < next_special
1701 && let Some(m) = ATTR_LIST_PATTERN.find(&remaining[pos..])
1702 && m.start() == 0
1703 {
1704 next_special = pos;
1705 special_type = "attr_list";
1706 attr_list_len = m.end();
1707 }
1708
1709 while emphasis_span_idx < emphasis_spans.len() && emphasis_spans[emphasis_span_idx].start < current_offset {
1711 emphasis_span_idx += 1;
1712 }
1713 if let Some(span) = emphasis_spans.get(emphasis_span_idx) {
1714 let pos_in_remaining = span.start - current_offset;
1715 if pos_in_remaining < next_special {
1716 next_special = pos_in_remaining;
1717 special_type = "pulldown_emphasis";
1718 pulldown_emphasis = Some(span);
1719 }
1720 }
1721
1722 let should_process_markdown_link = if let Some((pos, _, _)) = earliest_match {
1724 pos < next_special
1725 } else {
1726 false
1727 };
1728
1729 if should_process_markdown_link {
1730 let (pos, match_end, pattern_type) = earliest_match.unwrap();
1731
1732 if pos > 0 {
1734 elements.push(Element::Text(remaining[..pos].to_string()));
1735 }
1736
1737 match pattern_type {
1739 "link_span" => {
1740 let span = next_link.unwrap();
1741 let raw_text = remaining[pos..match_end].to_string();
1742 if span.is_footnote {
1743 elements.push(Element::FootnoteReference(raw_text));
1744 } else if span.is_image {
1745 match span.link_type {
1746 Some(LinkType::Inline) => elements.push(Element::InlineImage(raw_text)),
1747 Some(LinkType::Reference)
1750 | Some(LinkType::ReferenceUnknown)
1751 | Some(LinkType::Shortcut)
1752 | Some(LinkType::ShortcutUnknown) => elements.push(Element::ReferenceImage(raw_text)),
1753 Some(LinkType::Collapsed) | Some(LinkType::CollapsedUnknown) => {
1754 elements.push(Element::EmptyReferenceImage(raw_text))
1755 }
1756 _ => elements.push(Element::InlineImage(raw_text)),
1757 }
1758 } else {
1759 match span.link_type {
1760 Some(LinkType::Inline) => {
1761 if raw_text.starts_with('[') && raw_text.contains("![") {
1762 elements.push(Element::LinkedImage(raw_text));
1763 } else {
1764 elements.push(Element::Link(raw_text));
1765 }
1766 }
1767 Some(LinkType::Reference) | Some(LinkType::ReferenceUnknown) => {
1770 elements.push(Element::ReferenceLink(raw_text))
1771 }
1772 Some(LinkType::Collapsed) | Some(LinkType::CollapsedUnknown) => {
1773 elements.push(Element::EmptyReferenceLink(raw_text))
1774 }
1775 Some(LinkType::Shortcut) | Some(LinkType::ShortcutUnknown) => {
1776 elements.push(Element::ShortcutReference(raw_text))
1777 }
1778 Some(LinkType::Autolink) | Some(LinkType::Email) => {
1779 elements.push(Element::Autolink(raw_text))
1780 }
1781 _ => elements.push(Element::Link(raw_text)),
1782 }
1783 }
1784 remaining = &remaining[match_end..];
1785 }
1786 "wiki_link" => {
1787 if let Some(caps) = WIKI_LINK_REGEX.captures(remaining) {
1788 let content = caps.get(1).map_or("", |m| m.as_str());
1789 elements.push(Element::WikiLink(content.to_string()));
1790 remaining = &remaining[match_end..];
1791 } else {
1792 elements.push(Element::Text("[[".to_string()));
1793 remaining = &remaining[2..];
1794 }
1795 }
1796 "display_math" => {
1797 if let Some(caps) = DISPLAY_MATH_REGEX.captures(remaining) {
1798 let math = caps.get(1).map_or("", |m| m.as_str());
1799 elements.push(Element::DisplayMath(math.to_string()));
1800 remaining = &remaining[match_end..];
1801 } else {
1802 elements.push(Element::Text("$$".to_string()));
1803 remaining = &remaining[2..];
1804 }
1805 }
1806 "inline_math" => {
1807 if let Ok(Some(caps)) = INLINE_MATH_REGEX.captures(remaining) {
1808 let math = caps.get(1).map_or("", |m| m.as_str());
1809 elements.push(Element::InlineMath(math.to_string()));
1810 remaining = &remaining[match_end..];
1811 } else {
1812 elements.push(Element::Text("$".to_string()));
1813 remaining = &remaining[1..];
1814 }
1815 }
1816 "emoji" => {
1817 if let Some(caps) = EMOJI_SHORTCODE_REGEX.captures(remaining) {
1818 let emoji = caps.get(1).map_or("", |m| m.as_str());
1819 elements.push(Element::EmojiShortcode(emoji.to_string()));
1820 remaining = &remaining[match_end..];
1821 } else {
1822 elements.push(Element::Text(":".to_string()));
1823 remaining = &remaining[1..];
1824 }
1825 }
1826 "html_entity" => {
1827 elements.push(Element::HtmlEntity(remaining[pos..match_end].to_string()));
1829 remaining = &remaining[match_end..];
1830 }
1831 "hugo_shortcode" => {
1832 elements.push(Element::HugoShortcode(remaining[pos..match_end].to_string()));
1834 remaining = &remaining[match_end..];
1835 }
1836 "html_tag" => {
1837 elements.push(Element::HtmlTag(remaining[pos..match_end].to_string()));
1839 remaining = &remaining[match_end..];
1840 }
1841 _ => unreachable!("unknown pattern type: {}", pattern_type),
1842 }
1843 } else {
1844 if next_special > 0 && next_special < remaining.len() {
1848 elements.push(Element::Text(remaining[..next_special].to_string()));
1849 remaining = &remaining[next_special..];
1850 }
1851
1852 match special_type {
1854 "pulldown_code" => {
1855 let span = next_code_span.unwrap();
1856 let span_len = span.end - span.start;
1857 let code_raw = &remaining[..span_len];
1858 if let Some((content, marker)) = decompose_code_span(code_raw) {
1859 elements.push(Element::Code {
1860 content: content.to_string(),
1861 marker: marker.to_string(),
1862 });
1863 } else {
1864 elements.push(Element::Text(code_raw.to_string()));
1865 }
1866 remaining = &remaining[span_len..];
1867 }
1868 "attr_list" => {
1869 elements.push(Element::AttrList(remaining[..attr_list_len].to_string()));
1870 remaining = &remaining[attr_list_len..];
1871 }
1872 "myst_role" => {
1873 elements.push(Element::MystRole(remaining[..myst_role_len].to_string()));
1874 remaining = &remaining[myst_role_len..];
1875 }
1876 "pulldown_emphasis" => {
1877 let span = pulldown_emphasis.expect("pulldown_emphasis must be set");
1879 let span_len = span.end - span.start;
1880 if span.is_strikethrough {
1881 elements.push(Element::Strikethrough {
1882 content: span.content.clone(),
1883 double: span.strikethrough_double,
1884 });
1885 } else if span.is_strong {
1886 elements.push(Element::Bold {
1887 content: span.content.clone(),
1888 underscore: span.uses_underscore,
1889 });
1890 } else {
1891 elements.push(Element::Italic {
1892 content: span.content.clone(),
1893 underscore: span.uses_underscore,
1894 });
1895 }
1896 remaining = &remaining[span_len..];
1897 }
1898 _ => {
1899 elements.push(Element::Text(remaining.to_string()));
1901 break;
1902 }
1903 }
1904 }
1905 }
1906
1907 let mut merged_elements = Vec::new();
1909 for el in elements {
1910 match el {
1911 Element::Text(s) => {
1912 if let Some(Element::Text(last_s)) = merged_elements.last_mut() {
1913 last_s.push_str(&s);
1914 } else {
1915 merged_elements.push(Element::Text(s));
1916 }
1917 }
1918 other => merged_elements.push(other),
1919 }
1920 }
1921 merged_elements
1922}
1923
1924fn should_insert_space_before_join(current: &str) -> bool {
1925 !current.is_empty()
1926 && !current.ends_with(' ')
1927 && !current.ends_with('(')
1928 && !current.ends_with('[')
1929 && !current.ends_with('-')
1930}
1931
1932fn is_setext_or_thematic(text: &str) -> bool {
1938 let mut marker = 0u8;
1939 let mut count = 0usize;
1940 let mut has_space = false;
1941 for &b in text.as_bytes() {
1942 match b {
1943 b' ' | b'\t' => has_space = true,
1944 b'-' | b'=' | b'*' | b'_' => {
1945 if marker == 0 {
1946 marker = b;
1947 } else if b != marker {
1948 return false;
1949 }
1950 count += 1;
1951 }
1952 _ => return false,
1953 }
1954 }
1955 match marker {
1956 b'=' => !has_space,
1957 b'-' => !has_space || count >= 3,
1958 b'*' | b'_' => count >= 3,
1959 _ => false,
1960 }
1961}
1962
1963fn starts_block_construct(text: &str) -> bool {
1975 let text = text.trim_start();
1976 let bytes = text.as_bytes();
1977 let Some(&first) = bytes.first() else {
1978 return false;
1979 };
1980 let marker_then_boundary = |len: usize| bytes.len() == len || bytes[len] == b' ' || bytes[len] == b'\t';
1981 match first {
1982 b'>' => true,
1984 b'-' | b'*' | b'+' => marker_then_boundary(1) || is_setext_or_thematic(text),
1985 b'_' | b'=' => is_setext_or_thematic(text),
1986 b':' => is_definition_list_item(text) || text.starts_with(":::"),
1987 b'|' => true,
1988 b'#' => {
1989 let hashes = bytes.iter().take_while(|&&b| b == b'#').count();
1990 hashes <= 6 && marker_then_boundary(hashes)
1991 }
1992 b'`' => bytes.iter().take_while(|&&b| b == b'`').count() >= 3,
1993 b'~' => bytes.iter().take_while(|&&b| b == b'~').count() >= 3,
1994 b'0'..=b'9' => {
2001 let digits = bytes.iter().take_while(|b| b.is_ascii_digit()).count();
2002 digits <= 9
2003 && text[..digits].trim_start_matches('0') == "1"
2004 && bytes.len() > digits + 1
2005 && (bytes[digits] == b'.' || bytes[digits] == b')')
2006 && (bytes[digits + 1] == b' ' || bytes[digits + 1] == b'\t')
2007 }
2008 b'[' => {
2016 let mut escaped = false;
2017 let mut label_close = None;
2018 for (i, &b) in bytes.iter().enumerate().skip(1) {
2019 if escaped {
2020 escaped = false;
2021 } else if b == b'\\' {
2022 escaped = true;
2023 } else if b == b']' {
2024 label_close = Some(i);
2025 break;
2026 }
2027 }
2028 label_close.is_some_and(|i| bytes.get(i + 1) == Some(&b':'))
2029 }
2030 b'<' => crate::utils::html_block::parse_html_block_start(text).is_some(),
2033 _ => false,
2034 }
2035}
2036
2037fn merge_block_construct_continuations(lines: Vec<String>) -> Vec<String> {
2046 let mut merged: Vec<String> = Vec::with_capacity(lines.len());
2047 for line in lines {
2048 merged.push(line);
2049 while merged.len() > 1 && starts_block_construct(merged.last().expect("non-empty")) {
2053 let last = merged.pop().expect("non-empty");
2054 let prev = merged.last_mut().expect("len > 1");
2055 prev.push(' ');
2056 prev.push_str(last.trim_start());
2057 }
2058 }
2059 merged
2060}
2061
2062fn reflow_elements_sentence_per_line(
2064 elements: &[Element],
2065 custom_abbreviations: &Option<Vec<String>>,
2066 require_sentence_capital: bool,
2067) -> Vec<String> {
2068 let abbreviations = get_abbreviations(custom_abbreviations);
2069 let mut lines = Vec::new();
2070 let mut current_line = String::new();
2071
2072 for (idx, element) in elements.iter().enumerate() {
2073 let piece = match element {
2079 Element::Text(text) => Some(text.clone()),
2081 Element::Italic { content, underscore } => Some(wrap_emphasis(
2082 content,
2083 if *underscore { "_" } else { "*" },
2084 &mut current_line,
2085 )),
2086 Element::Bold { content, underscore } => Some(wrap_emphasis(
2087 content,
2088 if *underscore { "__" } else { "**" },
2089 &mut current_line,
2090 )),
2091 Element::Strikethrough { content, double } => Some(wrap_emphasis(
2092 content,
2093 if *double { "~~" } else { "~" },
2094 &mut current_line,
2095 )),
2096 _ => None,
2097 };
2098
2099 if let Some(piece) = piece {
2100 let combined = format!("{current_line}{piece}");
2101 let sentences = split_into_sentences_with_set(&combined, &abbreviations, require_sentence_capital);
2103
2104 if sentences.len() > 1 {
2105 let mut pending = String::new();
2109 let last = sentences.len() - 1;
2110 for (i, sentence) in sentences.iter().enumerate() {
2111 if !pending.is_empty() {
2112 pending.push(' ');
2113 }
2114 pending.push_str(sentence);
2115
2116 let closed = i < last || ends_with_sentence_punct(&pending);
2121 if closed && !text_ends_with_abbreviation(&pending, &abbreviations) {
2122 lines.push(std::mem::take(&mut pending));
2123 }
2124 }
2125 current_line = pending;
2126 } else {
2127 let trimmed = combined.trim();
2129
2130 if trimmed.is_empty() {
2134 continue;
2135 }
2136
2137 let ends_with_sentence_punct = ends_with_sentence_punct(trimmed);
2138
2139 if ends_with_sentence_punct && !text_ends_with_abbreviation(trimmed, &abbreviations) {
2140 lines.push(combined.trim_matches(is_breakable_whitespace).to_string());
2143 current_line.clear();
2144 } else {
2145 current_line = combined;
2147 }
2148 }
2149 } else {
2150 let element_str = format!("{element}");
2152 let is_adjacent = if idx > 0 {
2156 match &elements[idx - 1] {
2157 Element::Text(t) => !t.is_empty() && !t.ends_with(is_breakable_whitespace),
2158 _ => true,
2159 }
2160 } else {
2161 false
2162 };
2163
2164 if !is_adjacent && should_insert_space_before_join(¤t_line) {
2166 current_line.push(' ');
2167 }
2168 current_line.push_str(&element_str);
2169 }
2170 }
2171
2172 if !current_line.is_empty() {
2174 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
2175 }
2176 lines
2177}
2178
2179fn wrap_emphasis(content: &str, marker: &str, current_line: &mut String) -> String {
2183 if should_insert_space_before_join(current_line) {
2184 current_line.push(' ');
2185 }
2186 format!("{marker}{content}{marker}")
2187}
2188
2189const BREAK_WORDS: &[&str] = &[
2193 "and",
2194 "or",
2195 "but",
2196 "nor",
2197 "yet",
2198 "so",
2199 "for",
2200 "which",
2201 "that",
2202 "because",
2203 "when",
2204 "if",
2205 "while",
2206 "where",
2207 "although",
2208 "though",
2209 "unless",
2210 "since",
2211 "after",
2212 "before",
2213 "until",
2214 "as",
2215 "once",
2216 "whether",
2217 "however",
2218 "therefore",
2219 "moreover",
2220 "furthermore",
2221 "nevertheless",
2222 "whereas",
2223];
2224
2225fn is_clause_punctuation(c: char) -> bool {
2227 matches!(c, ',' | ';' | ':' | '\u{2014}') }
2229
2230fn clause_break_allowed_after(chars: &[char], i: usize) -> bool {
2238 if chars[i] == '\u{2014}' {
2239 return true;
2240 }
2241 match chars.get(i + 1) {
2242 None => true,
2243 Some(next) => next.is_whitespace(),
2244 }
2245}
2246
2247fn paren_group_end<'a>(slice: &'a str, element_spans: &[(usize, usize)], offset: usize) -> Option<(usize, &'a str)> {
2261 debug_assert!(slice.starts_with('('));
2262 let mut depth: i32 = 0;
2263 for (local_byte, c) in slice.char_indices() {
2264 let global_byte = offset + local_byte;
2265 if depth > 0 && is_inside_element(global_byte, element_spans) {
2270 continue;
2271 }
2272 match c {
2273 '(' => depth += 1,
2274 ')' => {
2275 depth -= 1;
2276 if depth == 0 {
2277 let end = local_byte + 1;
2278 let inner = &slice[1..local_byte];
2279 return Some((end, inner));
2280 }
2281 }
2282 _ => {}
2283 }
2284 }
2285 None
2286}
2287
2288fn split_at_parenthetical(
2305 text: &str,
2306 line_length: usize,
2307 element_spans: &[(usize, usize)],
2308 length_mode: ReflowLengthMode,
2309) -> Option<(String, String)> {
2310 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
2311
2312 if text.starts_with('(')
2314 && let Some((end_local, inner)) = paren_group_end(text, element_spans, 0)
2315 && inner.contains(' ')
2316 {
2317 let tail = &text[end_local..];
2321 let attached_len = tail
2322 .char_indices()
2323 .take_while(|(_, c)| is_closing_quote(*c) || is_clause_punctuation(*c))
2324 .last()
2325 .map_or(0, |(idx, c)| idx + c.len_utf8());
2326 let first_end = end_local + attached_len;
2327 let rest_start = first_end;
2328 let first = &text[..first_end];
2329 let first_len = display_len(first, length_mode);
2330 if first_len <= line_length {
2333 let rest = text[rest_start..].trim_start();
2334 if !rest.is_empty() {
2335 return Some((first.to_string(), rest.to_string()));
2336 }
2337 }
2338 }
2339
2340 let mut best_open_byte: Option<usize> = None;
2342 let mut pos = 0usize;
2343 while pos < text.len() {
2344 if text.as_bytes()[pos] != b'(' {
2346 let c = text[pos..].chars().next().unwrap();
2347 pos += c.len_utf8();
2348 continue;
2349 }
2350 if is_inside_element(pos, element_spans) {
2352 pos += 1;
2353 continue;
2354 }
2355 if let Some((end_local, inner)) = paren_group_end(&text[pos..], element_spans, pos) {
2356 let first = text[..pos].trim_end();
2357 let first_len = display_len(first, length_mode);
2358 if !first.is_empty()
2359 && first_len >= min_first_len
2360 && first_len <= line_length
2361 && inner.contains(' ')
2362 && best_open_byte.is_none_or(|prev| pos > prev)
2363 {
2364 best_open_byte = Some(pos);
2365 }
2366 pos += end_local;
2367 } else {
2368 pos += 1;
2369 }
2370 }
2371
2372 let open_byte = best_open_byte?;
2373 let first = text[..open_byte].trim_end().to_string();
2374 let rest = text[open_byte..].to_string();
2375 if first.is_empty() || rest.trim().is_empty() {
2376 return None;
2377 }
2378 Some((first, rest))
2379}
2380
2381fn compute_element_spans(elements: &[Element]) -> Vec<(usize, usize)> {
2385 let mut spans = Vec::new();
2386 let mut offset = 0;
2387 for element in elements {
2388 let len = element.display_len(ReflowLengthMode::Bytes);
2389 if !matches!(element, Element::Text(_)) {
2390 spans.push((offset, offset + len));
2391 }
2392 offset += len;
2393 }
2394 spans
2395}
2396
2397fn is_inside_element(pos: usize, spans: &[(usize, usize)]) -> bool {
2399 spans.iter().any(|(start, end)| pos > *start && pos < *end)
2400}
2401
2402const MIN_SPLIT_RATIO: f64 = 0.3;
2405
2406fn split_at_clause_punctuation(
2410 text: &str,
2411 line_length: usize,
2412 element_spans: &[(usize, usize)],
2413 length_mode: ReflowLengthMode,
2414) -> Option<(String, String)> {
2415 let chars: Vec<char> = text.chars().collect();
2416 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
2417
2418 let mut width_acc = 0;
2420 let mut search_end_char = 0;
2421 for (idx, &c) in chars.iter().enumerate() {
2422 let c_width = display_len(&c.to_string(), length_mode);
2423 if width_acc + c_width > line_length {
2424 break;
2425 }
2426 width_acc += c_width;
2427 search_end_char = idx + 1;
2428 }
2429
2430 let mut paren_depth: i32 = 0;
2437 let mut best_pos = None;
2438 for i in (0..search_end_char).rev() {
2439 let byte_start: usize = chars[..i].iter().map(|c| c.len_utf8()).sum();
2441 let byte_after: usize = byte_start + chars[i].len_utf8();
2443
2444 if !is_inside_element(byte_start, element_spans) {
2445 match chars[i] {
2446 ')' => paren_depth += 1,
2447 '(' => paren_depth = paren_depth.saturating_sub(1),
2448 _ => {}
2449 }
2450 }
2451
2452 if paren_depth == 0
2453 && is_clause_punctuation(chars[i])
2454 && clause_break_allowed_after(&chars, i)
2455 && !is_inside_element(byte_after, element_spans)
2456 {
2457 best_pos = Some(i);
2458 break;
2459 }
2460 }
2461
2462 let pos = best_pos?;
2463
2464 let first: String = chars[..=pos].iter().collect();
2466 let first_display_len = display_len(&first, length_mode);
2467 if first_display_len < min_first_len {
2468 return None;
2469 }
2470
2471 let rest: String = chars[pos + 1..].iter().collect();
2473 let rest = rest.trim_start().to_string();
2474
2475 if rest.is_empty() {
2476 return None;
2477 }
2478
2479 Some((first, rest))
2480}
2481
2482fn paren_depth_map(text: &str, element_spans: &[(usize, usize)]) -> Vec<i32> {
2489 let mut map = vec![0i32; text.len()];
2490 let mut depth = 0i32;
2491 for (byte, c) in text.char_indices() {
2492 if !is_inside_element(byte, element_spans) {
2493 match c {
2494 '(' => depth += 1,
2495 ')' => depth = depth.saturating_sub(1),
2496 _ => {}
2497 }
2498 }
2499 let end = (byte + c.len_utf8()).min(map.len());
2501 for slot in &mut map[byte..end] {
2502 *slot = depth;
2503 }
2504 }
2505 map
2506}
2507
2508fn is_standalone_parenthetical(line: &str) -> bool {
2517 let trimmed = line.trim();
2518 if !trimmed.starts_with('(') {
2519 return false;
2520 }
2521 let core = trimmed.trim_end_matches(|c: char| is_clause_punctuation(c));
2523 if !core.ends_with(')') {
2524 return false;
2525 }
2526 let inner = &core[1..core.len() - 1];
2528 if !inner.contains(' ') {
2529 return false;
2530 }
2531 let mut depth = 0i32;
2533 for c in core.chars() {
2534 match c {
2535 '(' => depth += 1,
2536 ')' => depth -= 1,
2537 _ => {}
2538 }
2539 if depth < 0 {
2540 return false;
2541 }
2542 }
2543 depth == 0
2544}
2545
2546fn split_at_break_word(
2550 text: &str,
2551 line_length: usize,
2552 element_spans: &[(usize, usize)],
2553 length_mode: ReflowLengthMode,
2554) -> Option<(String, String)> {
2555 let lower = text.to_lowercase();
2556 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
2557 let mut best_split: Option<(usize, usize)> = None; let depth_map = paren_depth_map(text, element_spans);
2562
2563 for &word in BREAK_WORDS {
2564 let mut search_start = 0;
2565 while let Some(pos) = lower[search_start..].find(word) {
2566 let abs_pos = search_start + pos;
2567
2568 let preceded_by_space = abs_pos == 0 || text.as_bytes().get(abs_pos - 1) == Some(&b' ');
2570 let followed_by_space = text.as_bytes().get(abs_pos + word.len()) == Some(&b' ');
2571
2572 if preceded_by_space && followed_by_space {
2573 let first_part = text[..abs_pos].trim_end();
2575 let first_part_len = display_len(first_part, length_mode);
2576
2577 let inside_paren = depth_map.get(abs_pos).is_some_and(|&d| d > 0);
2579
2580 if first_part_len >= min_first_len
2581 && first_part_len <= line_length
2582 && !is_inside_element(abs_pos, element_spans)
2583 && !inside_paren
2584 {
2585 if best_split.is_none_or(|(prev_pos, _)| abs_pos > prev_pos) {
2587 best_split = Some((abs_pos, word.len()));
2588 }
2589 }
2590 }
2591
2592 search_start = abs_pos + word.len();
2593 }
2594 }
2595
2596 let (byte_start, _word_len) = best_split?;
2597
2598 let first = text[..byte_start].trim_end().to_string();
2599 let rest = text[byte_start..].to_string();
2600
2601 if first.is_empty() || rest.trim().is_empty() {
2602 return None;
2603 }
2604
2605 Some((first, rest))
2606}
2607
2608fn cascade_split_line(text: &str, options: &ReflowOptions) -> Vec<String> {
2619 let line_length = options.line_length;
2620 let length_mode = options.length_mode;
2621 let attr_lists = options.attr_lists;
2622 let myst_roles = options.myst_roles;
2623 let defined_references = options.defined_references.as_ref();
2624 if line_length == 0 || display_len(text, length_mode) <= line_length {
2625 return vec![text.to_string()];
2626 }
2627
2628 let elements = parse_markdown_elements_inner(text, attr_lists, myst_roles, defined_references);
2629 let element_spans = compute_element_spans(&elements);
2630
2631 let rebased_spans = |start: usize| -> Vec<(usize, usize)> {
2635 if start == 0 {
2636 return element_spans.clone();
2637 }
2638 element_spans
2639 .iter()
2640 .filter(|&&(_, end)| end > start)
2641 .map(|&(s, e)| (s.saturating_sub(start), e.saturating_sub(start)))
2642 .collect()
2643 };
2644
2645 let mut result = Vec::new();
2646 let mut start = 0usize;
2647
2648 loop {
2649 let remaining = &text[start..];
2650 if display_len(remaining, length_mode) <= line_length {
2651 result.push(remaining.to_string());
2652 return result;
2653 }
2654
2655 let spans = rebased_spans(start);
2656
2657 let split = split_at_parenthetical(remaining, line_length, &spans, length_mode)
2661 .or_else(|| split_at_clause_punctuation(remaining, line_length, &spans, length_mode))
2662 .or_else(|| split_at_break_word(remaining, line_length, &spans, length_mode));
2663
2664 if let Some((first, rest)) = split {
2665 let consumed = remaining.len().saturating_sub(rest.len());
2666 if consumed == 0 {
2669 break;
2670 }
2671 result.push(first);
2672 start += consumed;
2673 continue;
2674 }
2675
2676 break;
2678 }
2679
2680 let mut fallback_options = options.clone();
2682 fallback_options.break_on_sentences = false;
2683 fallback_options.preserve_breaks = false;
2684 fallback_options.sentence_per_line = false;
2685 fallback_options.semantic_line_breaks = false;
2686 fallback_options.require_sentence_capital = true;
2687 fallback_options.max_list_continuation_indent = None;
2688 fallback_options.defined_references = None;
2689 let remaining = &text[start..];
2690 let tail_elements = if start == 0 {
2691 elements
2692 } else {
2693 parse_markdown_elements_inner(remaining, attr_lists, myst_roles, defined_references)
2694 };
2695 result.extend(reflow_elements(&tail_elements, &fallback_options));
2696 result
2697}
2698
2699fn reflow_elements_semantic(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
2703 let sentence_lines =
2705 reflow_elements_sentence_per_line(elements, &options.abbreviations, options.require_sentence_capital);
2706
2707 if options.line_length == 0 {
2710 return sentence_lines;
2711 }
2712
2713 let length_mode = options.length_mode;
2714 let mut result = Vec::new();
2715 for line in sentence_lines {
2716 if display_len(&line, length_mode) <= options.line_length {
2717 result.push(line);
2718 } else {
2719 result.extend(cascade_split_line(&line, options));
2720 }
2721 }
2722
2723 let min_line_len = ((options.line_length as f64) * MIN_SPLIT_RATIO) as usize;
2726 let mut merged: Vec<String> = Vec::with_capacity(result.len());
2727 for line in result {
2728 if !merged.is_empty() && display_len(&line, length_mode) < min_line_len && !line.trim().is_empty() {
2729 if is_standalone_parenthetical(&line) {
2732 merged.push(line);
2733 continue;
2734 }
2735
2736 let prev_ends_at_sentence = {
2738 let trimmed = merged.last().unwrap().trim_end();
2739 trimmed
2740 .chars()
2741 .rev()
2742 .find(|c| !matches!(c, '"' | '\'' | '\u{201D}' | '\u{2019}' | ')' | ']'))
2743 .is_some_and(|c| matches!(c, '.' | '!' | '?'))
2744 };
2745
2746 if !prev_ends_at_sentence {
2747 let prev = merged.last_mut().unwrap();
2748 let combined = format!("{prev} {line}");
2749 if display_len(&combined, length_mode) <= options.line_length {
2751 *prev = combined;
2752 continue;
2753 }
2754 }
2755 }
2756 merged.push(line);
2757 }
2758 merged
2759}
2760
2761fn rfind_safe_space(line: &str, element_spans: &[(usize, usize)]) -> Option<usize> {
2771 line.char_indices().rev().map(|(pos, _)| pos).find(|&pos| {
2772 line.as_bytes()[pos] == b' '
2773 && !element_spans.iter().any(|(s, e)| pos > *s && pos < *e)
2774 && !starts_block_construct(&line[pos + 1..])
2775 })
2776}
2777
2778fn break_before_attached(
2785 lines: &mut Vec<String>,
2786 current_line: &mut String,
2787 current_length: &mut usize,
2788 element_spans: &mut Vec<(usize, usize)>,
2789 attach: &str,
2790 separator: &str,
2791 length_mode: ReflowLengthMode,
2792) -> Option<usize> {
2793 let last_space = rfind_safe_space(current_line, element_spans)?;
2794 let before = current_line[..last_space]
2795 .trim_end_matches(is_breakable_whitespace)
2796 .to_string();
2797 let after = current_line[last_space + 1..].to_string();
2798 lines.push(before);
2799 let carried = after.len();
2800 *current_line = format!("{after}{separator}{attach}");
2801 *current_length = display_len(current_line, length_mode);
2802 element_spans.clear();
2803 Some(carried)
2804}
2805
2806fn reflow_elements(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
2808 let mut lines = Vec::new();
2809 let mut current_line = String::new();
2810 let mut current_length = 0;
2811 let mut current_line_element_spans: Vec<(usize, usize)> = Vec::new();
2813 let length_mode = options.length_mode;
2814
2815 for (idx, element) in elements.iter().enumerate() {
2816 let element_len = element.display_len(length_mode);
2817
2818 let is_adjacent_to_prev = if idx > 0 {
2827 match (&elements[idx - 1], element) {
2828 (Element::Text(t), _) => !t.is_empty() && !t.ends_with(is_breakable_whitespace),
2829 (_, Element::Text(t)) => !t.is_empty() && !t.starts_with(is_breakable_whitespace),
2830 _ => true,
2831 }
2832 } else {
2833 false
2834 };
2835
2836 if let Element::Text(text) = element {
2838 let has_leading_space = text.starts_with(is_breakable_whitespace);
2840 let words: Vec<&str> = split_breakable_words(text).collect();
2842
2843 for (i, word) in words.iter().enumerate() {
2844 let word_len = display_len(word, length_mode);
2845 let is_trailing_punct = word.chars().all(|c| {
2851 matches!(c, ',' | '.' | ':' | ';' | '!' | '?' | ')' | ']' | '}') || is_non_breaking_space(c)
2852 });
2853
2854 let is_first_adjacent = i == 0 && is_adjacent_to_prev;
2857
2858 if is_first_adjacent {
2859 if current_length + word_len > options.line_length
2861 && current_length > 0
2862 && break_before_attached(
2863 &mut lines,
2864 &mut current_line,
2865 &mut current_length,
2866 &mut current_line_element_spans,
2867 word,
2868 "",
2869 length_mode,
2870 )
2871 .is_some()
2872 {
2873 } else {
2878 current_line.push_str(word);
2879 current_length += word_len;
2880 }
2881 } else if current_length > 0 && current_length + 1 + word_len > options.line_length {
2882 if is_trailing_punct {
2883 if break_before_attached(
2890 &mut lines,
2891 &mut current_line,
2892 &mut current_length,
2893 &mut current_line_element_spans,
2894 word,
2895 " ",
2896 length_mode,
2897 )
2898 .is_none()
2899 {
2900 current_line.push(' ');
2901 current_line.push_str(word);
2902 current_length += 1 + word_len;
2903 }
2904 } else if !starts_block_construct(word) {
2905 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
2907 current_line = word.to_string();
2908 current_length = word_len;
2909 current_line_element_spans.clear();
2910 } else if break_before_attached(
2911 &mut lines,
2912 &mut current_line,
2913 &mut current_length,
2914 &mut current_line_element_spans,
2915 word,
2916 " ",
2917 length_mode,
2918 )
2919 .is_some()
2920 {
2921 } else {
2926 if i > 0 || has_leading_space {
2929 current_line.push(' ');
2930 current_length += 1;
2931 }
2932 current_line.push_str(word);
2933 current_length += word_len;
2934 }
2935 } else {
2936 let add_space = current_length > 0 && (i > 0 || has_leading_space);
2948 if add_space {
2949 current_line.push(' ');
2950 current_length += 1;
2951 }
2952 current_line.push_str(word);
2953 current_length += word_len;
2954 }
2955 }
2956 } else {
2957 let span_info = match element {
2958 Element::Italic { content, underscore } => {
2959 Some((content.as_str(), if *underscore { "_" } else { "*" }, false))
2960 }
2961 Element::Bold { content, underscore } => {
2962 Some((content.as_str(), if *underscore { "__" } else { "**" }, false))
2963 }
2964 Element::Strikethrough { content, double } => {
2965 Some((content.as_str(), if *double { "~~" } else { "~" }, false))
2966 }
2967 Element::Code { content, marker } => Some((content.as_str(), marker.as_str(), true)),
2968 _ => None,
2969 };
2970
2971 let breakable: Option<Vec<&str>> = match span_info {
2975 Some((content, _, is_code)) => {
2976 if is_code {
2977 (!options.atomic_spans && code_span_wraps_losslessly(content))
2978 .then(|| split_breakable_words(content).collect())
2979 } else {
2980 (!options.atomic_spans || element_len > options.line_length)
2981 .then(|| breakable_units(content, options.defined_references.as_ref(), options.attr_lists))
2982 .flatten()
2983 }
2984 }
2985 None => None,
2986 };
2987
2988 if let Some(words) = breakable {
2989 let (_, marker, is_code) = span_info.expect("breakable implies a span");
2990 let n = words.len();
2991 if n == 0 {
2992 let full = format!("{marker}{marker}");
2994 let full_len = display_len(&full, length_mode);
2995 if !is_adjacent_to_prev && current_length > 0 {
2996 current_line.push(' ');
2997 current_length += 1;
2998 }
2999 current_line.push_str(&full);
3000 current_length += full_len;
3001 } else {
3002 for (i, word) in words.iter().enumerate() {
3003 let is_first = i == 0;
3004 let is_last = i == n - 1;
3005
3006 let space_start = if is_first && is_code && word.starts_with('`') {
3007 " "
3008 } else {
3009 ""
3010 };
3011 let space_end = if is_last && is_code && word.ends_with('`') {
3012 " "
3013 } else {
3014 ""
3015 };
3016
3017 let word_str: String = match (is_first, is_last) {
3018 (true, true) => format!("{marker}{space_start}{word}{space_end}{marker}"),
3019 (true, false) => format!("{marker}{space_start}{word}"),
3020 (false, true) => format!("{word}{space_end}{marker}"),
3021 (false, false) => word.to_string(),
3022 };
3023 let word_len = display_len(&word_str, length_mode);
3024
3025 let needs_space = if is_first {
3026 !is_adjacent_to_prev && current_length > 0
3027 } else {
3028 current_length > 0
3029 };
3030
3031 if needs_space
3032 && current_length + 1 + word_len > options.line_length
3033 && !starts_block_construct(&word_str)
3034 {
3035 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
3036 current_line = word_str;
3037 current_length = word_len;
3038 current_line_element_spans.clear();
3039 } else {
3040 if needs_space {
3041 current_line.push(' ');
3042 current_length += 1;
3043 }
3044 current_line.push_str(&word_str);
3045 current_length += word_len;
3046 }
3047 }
3048 }
3049 } else {
3050 let element_str = format!("{element}");
3053
3054 if is_adjacent_to_prev {
3055 if current_length + element_len > options.line_length
3057 && let Some(carried) = break_before_attached(
3058 &mut lines,
3059 &mut current_line,
3060 &mut current_length,
3061 &mut current_line_element_spans,
3062 &element_str,
3063 "",
3064 length_mode,
3065 )
3066 {
3067 current_line_element_spans.push((carried, carried + element_str.len()));
3071 } else {
3072 let start = current_line.len();
3073 current_line.push_str(&element_str);
3074 current_length += element_len;
3075 current_line_element_spans.push((start, current_line.len()));
3076 }
3077 } else if current_length > 0 && current_length + 1 + element_len > options.line_length {
3078 if !starts_block_construct(&element_str) {
3079 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
3081 current_line.clone_from(&element_str);
3082 current_length = element_len;
3083 current_line_element_spans.clear();
3084 current_line_element_spans.push((0, element_str.len()));
3085 } else if let Some(carried) = break_before_attached(
3086 &mut lines,
3087 &mut current_line,
3088 &mut current_length,
3089 &mut current_line_element_spans,
3090 &element_str,
3091 " ",
3092 length_mode,
3093 ) {
3094 let start = carried + 1;
3098 current_line_element_spans.push((start, start + element_str.len()));
3099 } else {
3100 let ends_with_opener =
3103 current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
3104 if !ends_with_opener {
3105 current_line.push(' ');
3106 current_length += 1;
3107 }
3108 let start = current_line.len();
3109 current_line.push_str(&element_str);
3110 current_length += element_len;
3111 current_line_element_spans.push((start, current_line.len()));
3112 }
3113 } else {
3114 let ends_with_opener =
3116 current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
3117 if current_length > 0 && !ends_with_opener {
3118 current_line.push(' ');
3119 current_length += 1;
3120 }
3121 let start = current_line.len();
3122 current_line.push_str(&element_str);
3123 current_length += element_len;
3124 current_line_element_spans.push((start, current_line.len()));
3125 }
3126 }
3127 }
3128 }
3129
3130 if !current_line.is_empty() {
3132 lines.push(current_line.trim_end_matches(is_breakable_whitespace).to_string());
3133 }
3134
3135 lines
3136}
3137
3138pub fn reflow_markdown(content: &str, options: &ReflowOptions) -> String {
3140 let lines: Vec<&str> = content.lines().collect();
3141 let mut result = Vec::new();
3142 let mut i = 0;
3143
3144 while i < lines.len() {
3145 let line = lines[i];
3146 let trimmed = line.trim();
3147
3148 if trimmed.is_empty() {
3150 result.push(String::new());
3151 i += 1;
3152 continue;
3153 }
3154
3155 if trimmed.starts_with('#') {
3157 result.push(line.to_string());
3158 i += 1;
3159 continue;
3160 }
3161
3162 if trimmed.starts_with(":::") {
3164 result.push(line.to_string());
3165 i += 1;
3166 continue;
3167 }
3168
3169 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
3171 result.push(line.to_string());
3172 i += 1;
3173 while i < lines.len() {
3175 result.push(lines[i].to_string());
3176 if lines[i].trim().starts_with("```") || lines[i].trim().starts_with("~~~") {
3177 i += 1;
3178 break;
3179 }
3180 i += 1;
3181 }
3182 continue;
3183 }
3184
3185 if calculate_indentation_width_default(line) >= 4 {
3187 result.push(line.to_string());
3189 i += 1;
3190 while i < lines.len() {
3191 let next_line = lines[i];
3192 if calculate_indentation_width_default(next_line) >= 4 || next_line.trim().is_empty() {
3194 result.push(next_line.to_string());
3195 i += 1;
3196 } else {
3197 break;
3198 }
3199 }
3200 continue;
3201 }
3202
3203 if trimmed.starts_with('>') {
3205 let gt_pos = line.find('>').expect("'>' must exist since trimmed.starts_with('>')");
3208 let quote_prefix = line[0..=gt_pos].to_string();
3209 let quote_content = &line[quote_prefix.len()..].trim_start();
3210
3211 let reflowed = reflow_line(quote_content, options);
3212 for reflowed_line in &reflowed {
3213 result.push(format!("{quote_prefix} {reflowed_line}"));
3214 }
3215 i += 1;
3216 continue;
3217 }
3218
3219 if is_horizontal_rule(trimmed) {
3221 result.push(line.to_string());
3222 i += 1;
3223 continue;
3224 }
3225
3226 if is_unordered_list_marker(trimmed) || is_numbered_list_item(trimmed) {
3228 let indent = line.len() - line.trim_start().len();
3230 let indent_str = " ".repeat(indent);
3231
3232 let mut marker_end = indent;
3235 let mut content_start = indent;
3236
3237 if trimmed.chars().next().is_some_and(char::is_numeric) {
3238 if let Some(period_pos) = line[indent..].find('.') {
3240 marker_end = indent + period_pos + 1; content_start = marker_end;
3242 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
3246 content_start += 1;
3247 }
3248 }
3249 } else {
3250 marker_end = indent + 1; content_start = marker_end;
3253 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
3257 content_start += 1;
3258 }
3259 }
3260
3261 let min_continuation_indent = content_start;
3263
3264 let rest = &line[content_start..];
3267 if rest.starts_with("[ ] ") || rest.starts_with("[x] ") || rest.starts_with("[X] ") {
3268 marker_end = content_start + 3; content_start += 4; }
3271
3272 let marker = &line[indent..marker_end];
3273
3274 let mut list_content = vec![trim_preserving_hard_break(&line[content_start..])];
3277 i += 1;
3278
3279 while i < lines.len() {
3283 let next_line = lines[i];
3284 let next_trimmed = next_line.trim();
3285
3286 if is_block_boundary(next_trimmed) {
3288 break;
3289 }
3290
3291 let next_indent = next_line.len() - next_line.trim_start().len();
3293 if next_indent >= min_continuation_indent {
3294 let trimmed_start = next_line.trim_start();
3297 list_content.push(trim_preserving_hard_break(trimmed_start));
3298 i += 1;
3299 } else {
3300 break;
3302 }
3303 }
3304
3305 let combined_content = if options.preserve_breaks {
3308 list_content[0].clone()
3309 } else {
3310 let has_hard_breaks = list_content.iter().any(|line| has_hard_break(line));
3312 if has_hard_breaks {
3313 list_content.join("\n")
3315 } else {
3316 list_content.join(" ")
3318 }
3319 };
3320
3321 let trimmed_marker = marker;
3323 let continuation_spaces = if let Some(max_indent) = options.max_list_continuation_indent {
3324 indent + (content_start - indent).min(max_indent)
3327 } else {
3328 content_start
3329 };
3330
3331 let prefix_length = indent + trimmed_marker.len() + 1;
3333
3334 let adjusted_options = ReflowOptions {
3336 line_length: options.line_length.saturating_sub(prefix_length),
3337 ..options.clone()
3338 };
3339
3340 let reflowed = reflow_line(&combined_content, &adjusted_options);
3341 for (j, reflowed_line) in reflowed.iter().enumerate() {
3342 if j == 0 {
3343 result.push(format!("{indent_str}{trimmed_marker} {reflowed_line}"));
3344 } else {
3345 let continuation_indent = " ".repeat(continuation_spaces);
3347 result.push(format!("{continuation_indent}{reflowed_line}"));
3348 }
3349 }
3350 continue;
3351 }
3352
3353 if crate::utils::table_utils::TableUtils::is_potential_table_row(line) {
3355 result.push(line.to_string());
3356 i += 1;
3357 continue;
3358 }
3359
3360 if trimmed.starts_with('[') && line.contains("]:") {
3362 result.push(line.to_string());
3363 i += 1;
3364 continue;
3365 }
3366
3367 if is_definition_list_item(trimmed) {
3369 result.push(line.to_string());
3370 i += 1;
3371 continue;
3372 }
3373
3374 let mut is_single_line_paragraph = true;
3376 if i + 1 < lines.len() {
3377 let next_trimmed = lines[i + 1].trim();
3378 if !is_block_boundary(next_trimmed) {
3380 is_single_line_paragraph = false;
3381 }
3382 }
3383
3384 if is_single_line_paragraph && display_len(line, options.length_mode) <= options.line_length {
3386 result.push(line.to_string());
3387 i += 1;
3388 continue;
3389 }
3390
3391 let mut paragraph_parts = Vec::new();
3393 let mut current_part = vec![line];
3394 i += 1;
3395
3396 if options.preserve_breaks {
3398 let hard_break_type = if line.strip_suffix('\r').unwrap_or(line).ends_with('\\') {
3400 Some("\\")
3401 } else if line.ends_with(" ") {
3402 Some(" ")
3403 } else {
3404 None
3405 };
3406 let reflowed = reflow_line(line, options);
3407
3408 if let Some(break_marker) = hard_break_type {
3410 if !reflowed.is_empty() {
3411 let mut reflowed_with_break = reflowed;
3412 let last_idx = reflowed_with_break.len() - 1;
3413 if !has_hard_break(&reflowed_with_break[last_idx]) {
3414 reflowed_with_break[last_idx].push_str(break_marker);
3415 }
3416 result.extend(reflowed_with_break);
3417 }
3418 } else {
3419 result.extend(reflowed);
3420 }
3421 } else {
3422 while i < lines.len() {
3424 let prev_line = if !current_part.is_empty() {
3425 current_part.last().unwrap()
3426 } else {
3427 ""
3428 };
3429 let next_line = lines[i];
3430 let next_trimmed = next_line.trim();
3431
3432 if is_block_boundary(next_trimmed) {
3434 break;
3435 }
3436
3437 let prev_trimmed = prev_line.trim();
3440 let abbreviations = get_abbreviations(&options.abbreviations);
3441 let ends_with_sentence = (prev_trimmed.ends_with('.')
3442 || prev_trimmed.ends_with('!')
3443 || 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("?_")
3450 || prev_trimmed.ends_with(".\"")
3452 || prev_trimmed.ends_with("!\"")
3453 || prev_trimmed.ends_with("?\"")
3454 || prev_trimmed.ends_with(".'")
3455 || prev_trimmed.ends_with("!'")
3456 || prev_trimmed.ends_with("?'")
3457 || prev_trimmed.ends_with(".\u{201D}")
3458 || prev_trimmed.ends_with("!\u{201D}")
3459 || prev_trimmed.ends_with("?\u{201D}")
3460 || prev_trimmed.ends_with(".\u{2019}")
3461 || prev_trimmed.ends_with("!\u{2019}")
3462 || prev_trimmed.ends_with("?\u{2019}"))
3463 && !text_ends_with_abbreviation(
3464 prev_trimmed.trim_end_matches(['*', '_', '"', '\'', '\u{201D}', '\u{2019}']),
3465 &abbreviations,
3466 );
3467
3468 if has_hard_break(prev_line) || (options.sentence_per_line && ends_with_sentence) {
3469 paragraph_parts.push(current_part.join(" "));
3471 current_part = vec![next_line];
3472 } else {
3473 current_part.push(next_line);
3474 }
3475 i += 1;
3476 }
3477
3478 if !current_part.is_empty() {
3480 if current_part.len() == 1 {
3481 paragraph_parts.push(current_part[0].to_string());
3483 } else {
3484 paragraph_parts.push(current_part.join(" "));
3485 }
3486 }
3487
3488 for (j, part) in paragraph_parts.iter().enumerate() {
3490 let reflowed = reflow_line(part, options);
3491 result.extend(reflowed);
3492
3493 if j < paragraph_parts.len() - 1 && !result.is_empty() && !options.sentence_per_line {
3497 let last_idx = result.len() - 1;
3498 if !has_hard_break(&result[last_idx]) {
3499 result[last_idx].push_str(" ");
3500 }
3501 }
3502 }
3503 }
3504 }
3505
3506 let result_text = result.join("\n");
3508 if content.ends_with('\n') && !result_text.ends_with('\n') {
3509 format!("{result_text}\n")
3510 } else {
3511 result_text
3512 }
3513}
3514
3515#[derive(Debug, Clone)]
3517pub struct ParagraphReflow {
3518 pub start_byte: usize,
3520 pub end_byte: usize,
3522 pub reflowed_text: String,
3524}
3525
3526#[derive(Debug, Clone)]
3532pub struct BlockquoteLineData {
3533 pub(crate) content: String,
3535 pub(crate) is_explicit: bool,
3537 pub(crate) prefix: Option<String>,
3539}
3540
3541impl BlockquoteLineData {
3542 pub fn explicit(content: String, prefix: String) -> Self {
3544 Self {
3545 content,
3546 is_explicit: true,
3547 prefix: Some(prefix),
3548 }
3549 }
3550
3551 pub fn lazy(content: String) -> Self {
3553 Self {
3554 content,
3555 is_explicit: false,
3556 prefix: None,
3557 }
3558 }
3559}
3560
3561#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3563pub enum BlockquoteContinuationStyle {
3564 Explicit,
3565 Lazy,
3566}
3567
3568pub fn blockquote_continuation_style(lines: &[BlockquoteLineData]) -> BlockquoteContinuationStyle {
3576 let mut explicit_count = 0usize;
3577 let mut lazy_count = 0usize;
3578
3579 for line in lines.iter().skip(1) {
3580 if line.is_explicit {
3581 explicit_count += 1;
3582 } else {
3583 lazy_count += 1;
3584 }
3585 }
3586
3587 if explicit_count > 0 && lazy_count == 0 {
3588 BlockquoteContinuationStyle::Explicit
3589 } else if lazy_count > 0 && explicit_count == 0 {
3590 BlockquoteContinuationStyle::Lazy
3591 } else if explicit_count >= lazy_count {
3592 BlockquoteContinuationStyle::Explicit
3593 } else {
3594 BlockquoteContinuationStyle::Lazy
3595 }
3596}
3597
3598pub fn dominant_blockquote_prefix(lines: &[BlockquoteLineData], fallback: &str) -> String {
3603 let mut counts: std::collections::HashMap<String, (usize, usize)> = std::collections::HashMap::new();
3604
3605 for (idx, line) in lines.iter().enumerate() {
3606 let Some(prefix) = line.prefix.as_ref() else {
3607 continue;
3608 };
3609 counts
3610 .entry(prefix.clone())
3611 .and_modify(|entry| entry.0 += 1)
3612 .or_insert((1, idx));
3613 }
3614
3615 counts
3616 .into_iter()
3617 .max_by(|(_, (count_a, first_idx_a)), (_, (count_b, first_idx_b))| {
3618 count_a.cmp(count_b).then_with(|| first_idx_b.cmp(first_idx_a))
3619 })
3620 .map_or_else(|| fallback.to_string(), |(prefix, _)| prefix)
3621}
3622
3623pub(crate) fn should_force_explicit_blockquote_line(content_line: &str) -> bool {
3628 let trimmed = content_line.trim_start();
3629 trimmed.starts_with('>')
3630 || trimmed.starts_with('#')
3631 || trimmed.starts_with("```")
3632 || trimmed.starts_with("~~~")
3633 || is_unordered_list_marker(trimmed)
3634 || is_numbered_list_item(trimmed)
3635 || is_horizontal_rule(trimmed)
3636 || is_definition_list_item(trimmed)
3637 || (trimmed.starts_with('[') && trimmed.contains("]:"))
3638 || trimmed.starts_with(":::")
3639 || (trimmed.starts_with('<')
3640 && !trimmed.starts_with("<http")
3641 && !trimmed.starts_with("<https")
3642 && !trimmed.starts_with("<mailto:"))
3643}
3644
3645pub fn reflow_blockquote_content(
3654 lines: &[BlockquoteLineData],
3655 explicit_prefix: &str,
3656 continuation_style: BlockquoteContinuationStyle,
3657 options: &ReflowOptions,
3658) -> Vec<String> {
3659 let content_strs: Vec<&str> = lines.iter().map(|l| l.content.as_str()).collect();
3660 let segments = split_into_segments_strs(&content_strs);
3661 let mut reflowed_content_lines: Vec<String> = Vec::new();
3662
3663 for segment in segments {
3664 let hard_break_type = segment.last().and_then(|&line| {
3665 let line = line.strip_suffix('\r').unwrap_or(line);
3666 if line.ends_with('\\') {
3667 Some("\\")
3668 } else if line.ends_with(" ") {
3669 Some(" ")
3670 } else {
3671 None
3672 }
3673 });
3674
3675 let pieces: Vec<&str> = segment
3676 .iter()
3677 .map(|&line| {
3678 if let Some(l) = line.strip_suffix('\\') {
3679 l.trim_end()
3680 } else if let Some(l) = line.strip_suffix(" ") {
3681 l.trim_end()
3682 } else {
3683 line.trim_end()
3684 }
3685 })
3686 .collect();
3687
3688 let segment_text = pieces.join(" ");
3689 let segment_text = segment_text.trim();
3690 if segment_text.is_empty() {
3691 continue;
3692 }
3693
3694 let mut reflowed = reflow_line(segment_text, options);
3695 if let Some(break_marker) = hard_break_type
3696 && !reflowed.is_empty()
3697 {
3698 let last_idx = reflowed.len() - 1;
3699 if !has_hard_break(&reflowed[last_idx]) {
3700 reflowed[last_idx].push_str(break_marker);
3701 }
3702 }
3703 reflowed_content_lines.extend(reflowed);
3704 }
3705
3706 let mut styled_lines: Vec<String> = Vec::new();
3707 for (idx, line) in reflowed_content_lines.iter().enumerate() {
3708 let force_explicit = idx == 0
3709 || continuation_style == BlockquoteContinuationStyle::Explicit
3710 || should_force_explicit_blockquote_line(line);
3711 if force_explicit {
3712 styled_lines.push(format!("{explicit_prefix}{line}"));
3713 } else {
3714 styled_lines.push(line.clone());
3715 }
3716 }
3717
3718 styled_lines
3719}
3720
3721fn is_blockquote_content_boundary(content: &str) -> bool {
3722 let trimmed = content.trim();
3723 trimmed.is_empty()
3724 || is_block_boundary(trimmed)
3725 || crate::utils::table_utils::TableUtils::is_potential_table_row(content)
3726 || trimmed.starts_with(":::")
3727 || crate::utils::is_template_directive_only(content)
3728 || is_standalone_attr_list(content)
3729 || is_snippet_block_delimiter(content)
3730}
3731
3732fn split_into_segments_strs<'a>(lines: &[&'a str]) -> Vec<Vec<&'a str>> {
3733 let mut segments = Vec::new();
3734 let mut current = Vec::new();
3735
3736 for &line in lines {
3737 current.push(line);
3738 if has_hard_break(line) {
3739 segments.push(current);
3740 current = Vec::new();
3741 }
3742 }
3743
3744 if !current.is_empty() {
3745 segments.push(current);
3746 }
3747
3748 segments
3749}
3750
3751fn reflow_blockquote_paragraph_at_line(
3752 content: &str,
3753 lines: &[&str],
3754 target_idx: usize,
3755 options: &ReflowOptions,
3756) -> Option<ParagraphReflow> {
3757 let mut anchor_idx = target_idx;
3758 let mut target_level = if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[target_idx]) {
3759 parsed.nesting_level
3760 } else {
3761 let mut found = None;
3762 let mut idx = target_idx;
3763 loop {
3764 if lines[idx].trim().is_empty() {
3765 break;
3766 }
3767 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[idx]) {
3768 found = Some((idx, parsed.nesting_level));
3769 break;
3770 }
3771 if idx == 0 {
3772 break;
3773 }
3774 idx -= 1;
3775 }
3776 let (idx, level) = found?;
3777 anchor_idx = idx;
3778 level
3779 };
3780
3781 let mut para_start = anchor_idx;
3783 while para_start > 0 {
3784 let prev_idx = para_start - 1;
3785 let prev_line = lines[prev_idx];
3786
3787 if prev_line.trim().is_empty() {
3788 break;
3789 }
3790
3791 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(prev_line) {
3792 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
3793 break;
3794 }
3795 para_start = prev_idx;
3796 continue;
3797 }
3798
3799 let prev_lazy = prev_line.trim_start();
3800 if is_blockquote_content_boundary(prev_lazy) {
3801 break;
3802 }
3803 para_start = prev_idx;
3804 }
3805
3806 while para_start < lines.len() {
3808 let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[para_start]) else {
3809 para_start += 1;
3810 continue;
3811 };
3812 target_level = parsed.nesting_level;
3813 break;
3814 }
3815
3816 if para_start >= lines.len() || para_start > target_idx {
3817 return None;
3818 }
3819
3820 let mut collected: Vec<(usize, BlockquoteLineData)> = Vec::new();
3823 let mut idx = para_start;
3824 while idx < lines.len() {
3825 if !collected.is_empty() && has_hard_break(&collected[collected.len() - 1].1.content) {
3826 break;
3827 }
3828
3829 let line = lines[idx];
3830 if line.trim().is_empty() {
3831 break;
3832 }
3833
3834 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(line) {
3835 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
3836 break;
3837 }
3838 collected.push((
3839 idx,
3840 BlockquoteLineData::explicit(trim_preserving_hard_break(parsed.content), parsed.prefix.to_string()),
3841 ));
3842 idx += 1;
3843 continue;
3844 }
3845
3846 let lazy_content = line.trim_start();
3847 if is_blockquote_content_boundary(lazy_content) {
3848 break;
3849 }
3850
3851 collected.push((idx, BlockquoteLineData::lazy(trim_preserving_hard_break(lazy_content))));
3852 idx += 1;
3853 }
3854
3855 if collected.is_empty() {
3856 return None;
3857 }
3858
3859 let para_end = collected[collected.len() - 1].0;
3860 if target_idx < para_start || target_idx > para_end {
3861 return None;
3862 }
3863
3864 let line_data: Vec<BlockquoteLineData> = collected.iter().map(|(_, d)| d.clone()).collect();
3865
3866 let fallback_prefix = line_data
3867 .iter()
3868 .find_map(|d| d.prefix.clone())
3869 .unwrap_or_else(|| "> ".to_string());
3870 let explicit_prefix = dominant_blockquote_prefix(&line_data, &fallback_prefix);
3871 let continuation_style = blockquote_continuation_style(&line_data);
3872
3873 let adjusted_line_length = options
3874 .line_length
3875 .saturating_sub(display_len(&explicit_prefix, options.length_mode))
3876 .max(1);
3877
3878 let adjusted_options = ReflowOptions {
3879 line_length: adjusted_line_length,
3880 ..options.clone()
3881 };
3882
3883 let styled_lines = reflow_blockquote_content(&line_data, &explicit_prefix, continuation_style, &adjusted_options);
3884
3885 if styled_lines.is_empty() {
3886 return None;
3887 }
3888
3889 let mut start_byte = 0;
3891 for line in lines.iter().take(para_start) {
3892 start_byte += line.len() + 1;
3893 }
3894
3895 let mut end_byte = start_byte;
3896 for line in lines.iter().take(para_end + 1).skip(para_start) {
3897 end_byte += line.len() + 1;
3898 }
3899
3900 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
3901 if !includes_trailing_newline {
3902 end_byte -= 1;
3903 }
3904
3905 let reflowed_joined = styled_lines.join("\n");
3906 let reflowed_text = if includes_trailing_newline {
3907 if reflowed_joined.ends_with('\n') {
3908 reflowed_joined
3909 } else {
3910 format!("{reflowed_joined}\n")
3911 }
3912 } else if reflowed_joined.ends_with('\n') {
3913 reflowed_joined.trim_end_matches('\n').to_string()
3914 } else {
3915 reflowed_joined
3916 };
3917
3918 Some(ParagraphReflow {
3919 start_byte,
3920 end_byte,
3921 reflowed_text,
3922 })
3923}
3924
3925pub fn reflow_paragraph_at_line(content: &str, line_number: usize, line_length: usize) -> Option<ParagraphReflow> {
3943 reflow_paragraph_at_line_with_mode(content, line_number, line_length, ReflowLengthMode::default())
3944}
3945
3946pub fn reflow_paragraph_at_line_with_mode(
3948 content: &str,
3949 line_number: usize,
3950 line_length: usize,
3951 length_mode: ReflowLengthMode,
3952) -> Option<ParagraphReflow> {
3953 let options = ReflowOptions {
3954 line_length,
3955 length_mode,
3956 ..Default::default()
3957 };
3958 reflow_paragraph_at_line_with_options(content, line_number, &options)
3959}
3960
3961pub fn reflow_paragraph_at_line_with_options(
3972 content: &str,
3973 line_number: usize,
3974 options: &ReflowOptions,
3975) -> Option<ParagraphReflow> {
3976 if line_number == 0 {
3977 return None;
3978 }
3979
3980 let lines: Vec<&str> = content.lines().collect();
3981
3982 if line_number > lines.len() {
3984 return None;
3985 }
3986
3987 let target_idx = line_number - 1; let target_line = lines[target_idx];
3989 let trimmed = target_line.trim();
3990
3991 if let Some(blockquote_reflow) = reflow_blockquote_paragraph_at_line(content, &lines, target_idx, options) {
3994 return Some(blockquote_reflow);
3995 }
3996
3997 if is_paragraph_boundary(trimmed, target_line) {
3999 return None;
4000 }
4001
4002 let mut para_start = target_idx;
4004 while para_start > 0 {
4005 let prev_idx = para_start - 1;
4006 let prev_line = lines[prev_idx];
4007 let prev_trimmed = prev_line.trim();
4008
4009 if is_paragraph_boundary(prev_trimmed, prev_line) {
4011 break;
4012 }
4013
4014 para_start = prev_idx;
4015 }
4016
4017 let mut para_end = target_idx;
4019 while para_end + 1 < lines.len() {
4020 let next_idx = para_end + 1;
4021 let next_line = lines[next_idx];
4022 let next_trimmed = next_line.trim();
4023
4024 if is_paragraph_boundary(next_trimmed, next_line) {
4026 break;
4027 }
4028
4029 para_end = next_idx;
4030 }
4031
4032 let paragraph_lines = &lines[para_start..=para_end];
4034
4035 let mut start_byte = 0;
4037 for line in lines.iter().take(para_start) {
4038 start_byte += line.len() + 1; }
4040
4041 let mut end_byte = start_byte;
4042 for line in paragraph_lines {
4043 end_byte += line.len() + 1; }
4045
4046 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
4049
4050 if !includes_trailing_newline {
4052 end_byte -= 1;
4053 }
4054
4055 let paragraph_text = paragraph_lines.join("\n");
4057
4058 let reflowed = reflow_markdown(¶graph_text, options);
4060
4061 let reflowed_text = if includes_trailing_newline {
4065 if reflowed.ends_with('\n') {
4067 reflowed
4068 } else {
4069 format!("{reflowed}\n")
4070 }
4071 } else {
4072 if reflowed.ends_with('\n') {
4074 reflowed.trim_end_matches('\n').to_string()
4075 } else {
4076 reflowed
4077 }
4078 };
4079
4080 Some(ParagraphReflow {
4081 start_byte,
4082 end_byte,
4083 reflowed_text,
4084 })
4085}
4086fn decompose_code_span(raw: &str) -> Option<(&str, &str)> {
4092 let marker_len = raw.bytes().take_while(|&b| b == b'`').count();
4093 if marker_len == 0 {
4094 return None;
4095 }
4096 let marker = &raw[..marker_len];
4097 if raw.len() < marker_len * 2 {
4098 return None;
4099 }
4100 let content = &raw[marker_len..raw.len() - marker_len];
4101 Some((content, marker))
4102}
4103
4104#[cfg(test)]
4105mod tests {
4106 use super::*;
4107
4108 #[test]
4112 fn preserves_content_accepts_whitespace_changes_and_rejects_the_rest() {
4113 let accepted: &[(&str, &[&str])] = &[
4114 ("one two three", &["one two three"]),
4115 ("one two three", &["one two", "three"]),
4116 ("one two three", &["one", "two", "three"]),
4117 ("one two ", &["one two"]),
4119 ("日本語のテキスト", &["日本語の", "テキスト"]),
4121 ("_First. Second._", &["_First.", "Second._"]),
4123 ];
4124 for (original, reflowed) in accepted {
4125 let reflowed: Vec<String> = reflowed.iter().map(ToString::to_string).collect();
4126 assert!(
4127 preserves_content(original, &reflowed),
4128 "{original:?} -> {reflowed:?} only moves whitespace"
4129 );
4130 }
4131
4132 let rejected: &[(&str, &[&str])] = &[
4133 ("one two three", &["one two"]),
4135 ("one two", &["one two three"]),
4137 ("one two", &["two one"]),
4139 ("_First. Second._", &["_First._", "_Second._"]),
4141 ("alpha and beta", &["alpha", "andbeta"]),
4143 ("mot suivant : autre", &["mot suivant: autre"]),
4145 ];
4146 for (original, reflowed) in rejected {
4147 let reflowed: Vec<String> = reflowed.iter().map(ToString::to_string).collect();
4148 assert!(
4149 !preserves_content(original, &reflowed),
4150 "{original:?} -> {reflowed:?} changes the text, not just its line breaks"
4151 );
4152 }
4153 }
4154
4155 #[test]
4157 fn reflow_line_falls_back_to_the_input_when_content_would_change() {
4158 let options = ReflowOptions {
4159 line_length: 40,
4160 ..Default::default()
4161 };
4162 let line = "one two three four five six seven eight nine ten";
4163
4164 assert!(preserves_content(line, &reflow_line(line, &options)));
4165 assert_eq!(reflow_line(line, &options), reflow_line_unchecked(line, &options));
4166 }
4167
4168 #[test]
4169 fn cascade_split_line_handles_a_very_long_line_without_overflowing() {
4170 let words: Vec<String> = (0..4000).map(|i| format!("word{i}")).collect();
4176 let line = words.join(" ");
4177
4178 let options = ReflowOptions {
4179 line_length: 80,
4180 length_mode: ReflowLengthMode::Chars,
4181 ..Default::default()
4182 };
4183 let out = cascade_split_line(&line, &options);
4184
4185 assert!(out.len() > 1, "a very long line should split into many lines");
4186 for segment in &out {
4187 assert!(
4188 display_len(segment, ReflowLengthMode::Chars) <= 80 || !segment.contains(' '),
4189 "each wrapped line should fit the width (or be a single unbreakable token)"
4190 );
4191 }
4192 let rejoined = out.join(" ");
4194 let original_words: Vec<&str> = line.split(' ').collect();
4195 let result_words: Vec<&str> = rejoined.split_whitespace().collect();
4196 assert_eq!(original_words, result_words, "reflow must preserve all words in order");
4197 }
4198
4199 #[test]
4204 fn test_helper_function_text_ends_with_abbreviation() {
4205 let abbreviations = get_abbreviations(&None);
4207
4208 assert!(text_ends_with_abbreviation("Dr.", &abbreviations));
4210 assert!(text_ends_with_abbreviation("word Dr.", &abbreviations));
4211 assert!(text_ends_with_abbreviation("e.g.", &abbreviations));
4212 assert!(text_ends_with_abbreviation("i.e.", &abbreviations));
4213 assert!(text_ends_with_abbreviation("Mr.", &abbreviations));
4214 assert!(text_ends_with_abbreviation("Mrs.", &abbreviations));
4215 assert!(text_ends_with_abbreviation("Ms.", &abbreviations));
4216 assert!(text_ends_with_abbreviation("Prof.", &abbreviations));
4217
4218 assert!(!text_ends_with_abbreviation("etc.", &abbreviations));
4220 assert!(!text_ends_with_abbreviation("paradigms.", &abbreviations));
4221 assert!(!text_ends_with_abbreviation("programs.", &abbreviations));
4222 assert!(!text_ends_with_abbreviation("items.", &abbreviations));
4223 assert!(!text_ends_with_abbreviation("systems.", &abbreviations));
4224 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)); }
4230
4231 #[test]
4232 fn test_footnote_after_period_splits_sentence() {
4233 let text = "First sentence.[^1] Second sentence.";
4237 let sentences = split_into_sentences(text);
4238 assert_eq!(
4239 sentences,
4240 vec!["First sentence.[^1]".to_string(), "Second sentence.".to_string()],
4241 "footnote glued to the period should keep the boundary and stay attached to the first sentence"
4242 );
4243 }
4244
4245 #[test]
4246 fn test_multiple_consecutive_footnotes_after_period_splits_sentence() {
4247 let text = "Notes here.[^1][^2] Second sentence.";
4249 let sentences = split_into_sentences(text);
4250 assert_eq!(
4251 sentences,
4252 vec!["Notes here.[^1][^2]".to_string(), "Second sentence.".to_string()]
4253 );
4254 }
4255
4256 #[test]
4257 fn test_footnote_before_period_still_splits_sentence() {
4258 let text = "Annotation here[^1]. Second sentence.";
4262 let sentences = split_into_sentences(text);
4263 assert_eq!(
4264 sentences,
4265 vec!["Annotation here[^1].".to_string(), "Second sentence.".to_string()]
4266 );
4267 }
4268
4269 #[test]
4270 fn test_mid_sentence_footnote_does_not_split() {
4271 let text = "The system word[^1] more words. Next sentence.";
4274 let sentences = split_into_sentences(text);
4275 assert_eq!(
4276 sentences,
4277 vec![
4278 "The system word[^1] more words.".to_string(),
4279 "Next sentence.".to_string()
4280 ]
4281 );
4282 }
4283
4284 #[test]
4285 fn test_bare_numeric_bracket_after_period_does_not_split() {
4286 let text = "Citation here.[1] Second sentence.";
4289 let sentences = split_into_sentences(text);
4290 assert_eq!(
4291 sentences,
4292 vec![text.to_string()],
4293 "a bare numeric bracket must not be treated as a sentence boundary"
4294 );
4295 }
4296
4297 #[test]
4298 fn test_footnote_glued_to_following_word_does_not_split() {
4299 let text = "First sentence.[^1]Continued glued text.";
4302 let sentences = split_into_sentences(text);
4303 assert_eq!(sentences, vec![text.to_string()]);
4304 }
4305
4306 #[test]
4307 fn test_footnote_at_end_of_text_is_preserved() {
4308 let text = "Sentence.[^1]";
4311 let sentences = split_into_sentences(text);
4312 assert_eq!(sentences, vec![text.to_string()]);
4313 }
4314
4315 #[test]
4316 fn test_abbreviation_before_footnote_does_not_split() {
4317 let text = "See the notes, e.g.[^1] this one.";
4320 let sentences = split_into_sentences(text);
4321 assert_eq!(
4322 sentences,
4323 vec![text.to_string()],
4324 "e.g. is an abbreviation, not a sentence boundary"
4325 );
4326 }
4327
4328 #[test]
4329 fn test_is_unordered_list_marker() {
4330 assert!(is_unordered_list_marker("- item"));
4332 assert!(is_unordered_list_marker("* item"));
4333 assert!(is_unordered_list_marker("+ item"));
4334 assert!(is_unordered_list_marker("-")); assert!(is_unordered_list_marker("*"));
4336 assert!(is_unordered_list_marker("+"));
4337
4338 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")); }
4349
4350 #[test]
4351 fn test_is_block_boundary() {
4352 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"));
4374 assert!(!is_block_boundary("*emphasis*")); assert!(!is_block_boundary("[link](url)")); assert!(!is_block_boundary("some words here"));
4377 }
4378
4379 #[test]
4380 fn test_definition_list_boundary_in_single_line_paragraph() {
4381 let options = ReflowOptions {
4384 line_length: 80,
4385 ..Default::default()
4386 };
4387 let input = "Term\n: Definition of the term";
4388 let result = reflow_markdown(input, &options);
4389 assert!(
4391 result.contains(": Definition"),
4392 "Definition list item should not be merged into previous line. Got: {result:?}"
4393 );
4394 let lines: Vec<&str> = result.lines().collect();
4395 assert_eq!(lines.len(), 2, "Should remain two separate lines. Got: {lines:?}");
4396 assert_eq!(lines[0], "Term");
4397 assert_eq!(lines[1], ": Definition of the term");
4398 }
4399
4400 #[test]
4401 fn test_is_paragraph_boundary() {
4402 assert!(is_paragraph_boundary("# Heading", "# Heading"));
4404 assert!(is_paragraph_boundary("- item", "- item"));
4405 assert!(is_paragraph_boundary(":::", ":::"));
4406 assert!(is_paragraph_boundary(": definition", ": definition"));
4407
4408 assert!(is_paragraph_boundary("code", " code"));
4410 assert!(is_paragraph_boundary("code", "\tcode"));
4411
4412 assert!(is_paragraph_boundary("| a | b |", "| a | b |"));
4414 assert!(is_paragraph_boundary("a | b", "a | b")); assert!(!is_paragraph_boundary("regular text", "regular text"));
4418 assert!(!is_paragraph_boundary("text", " text")); }
4420
4421 #[test]
4422 fn test_div_marker_boundary_in_reflow_paragraph_at_line() {
4423 let content = "Some paragraph text here.\n\n::: {.callout-note}\nThis is a callout.\n:::\n";
4426 let result = reflow_paragraph_at_line(content, 3, 80);
4428 assert!(result.is_none(), "Div marker line should not be reflowed");
4429 }
4430
4431 #[test]
4432 fn starts_block_construct_detects_block_openers() {
4433 for case in ["- item", "-", "* item", "*", "+ item", "+", "-\titem"] {
4435 assert!(starts_block_construct(case), "bullet: {case:?}");
4436 }
4437 for case in ["1. item", "1) item", "01. x", "000000001. x", "1.\titem"] {
4440 assert!(starts_block_construct(case), "ordered: {case:?}");
4441 }
4442 for case in ["> quote", ">quote", ">"] {
4444 assert!(starts_block_construct(case), "blockquote: {case:?}");
4445 }
4446 for case in ["# heading", "###### h6", "#", "##"] {
4448 assert!(starts_block_construct(case), "heading: {case:?}");
4449 }
4450 for case in ["```", "```rust", "````", "~~~", "~~~text"] {
4452 assert!(starts_block_construct(case), "fence: {case:?}");
4453 }
4454 for case in ["---", "--", "===", "=", "***", "___", "_ _ _", "- - -"] {
4456 assert!(starts_block_construct(case), "setext/thematic: {case:?}");
4457 }
4458 for case in [
4461 "[^1]: text",
4462 "[^note]:",
4463 "[ref]: http://example.com",
4464 "[wat]: url follows",
4465 ] {
4466 assert!(starts_block_construct(case), "definition: {case:?}");
4467 }
4468 for case in ["<div>content", "</div>", "<p>text", "<table>", "<pre>code", "<h1>x"] {
4470 assert!(starts_block_construct(case), "html block: {case:?}");
4471 }
4472 }
4473
4474 #[test]
4475 fn starts_block_construct_allows_ordinary_prose() {
4476 for case in [
4477 "",
4478 "word",
4479 "-5 degrees",
4480 "--flag",
4481 "-item",
4482 "#hashtag",
4483 "####### seven hashes is not a heading",
4484 "1.5 million",
4485 "1234567890. ten digits is not a list marker",
4486 "0000000001. ten digits is not a list marker either",
4487 "2. item",
4490 "7. item",
4491 "0. item",
4492 "42) x",
4493 "123456. item",
4494 "1.",
4495 "1)",
4496 "123456.",
4497 "123456)",
4498 "1.item",
4499 "1:30 pm",
4500 "*emphasis*",
4501 "**bold** text",
4502 "__bold__ text",
4503 "_emphasis_ text",
4504 "`code` span",
4505 "`` double backtick span ``",
4506 "~~strikethrough~~",
4507 "=x",
4508 "== ==",
4509 "(parenthetical)",
4510 "[link](url)",
4511 "[text][ref] more",
4512 "[bracketed] aside",
4513 "[a](b) [ref]: first bracket is a link, not a label",
4514 "[esc\\]: not a close] text",
4515 "<span>inline</span>",
4516 "<b>bold</b>",
4517 "<https://example.com> autolink",
4518 "<mailto:a@b.com>",
4519 "<notarealtag>",
4520 ] {
4521 assert!(!starts_block_construct(case), "prose: {case:?}");
4522 }
4523 }
4524
4525 #[test]
4526 fn merge_block_construct_continuations_merges_marker_led_lines() {
4527 let lines = vec![
4528 "First sentence?".to_string(),
4529 "- looks like a list item".to_string(),
4530 "Second sentence.".to_string(),
4531 ];
4532 assert_eq!(
4533 merge_block_construct_continuations(lines),
4534 vec![
4535 "First sentence? - looks like a list item".to_string(),
4536 "Second sentence.".to_string(),
4537 ]
4538 );
4539
4540 let lines = vec!["- real list content".to_string(), "continuation".to_string()];
4543 assert_eq!(
4544 merge_block_construct_continuations(lines.clone()),
4545 lines,
4546 "first line must never be merged"
4547 );
4548
4549 let lines = vec!["prose".to_string(), "1.".to_string(), "[ref]:".to_string()];
4552 assert_eq!(
4553 merge_block_construct_continuations(lines),
4554 vec!["prose 1. [ref]:".to_string()],
4555 "a merge that creates an opener must fold again"
4556 );
4557 }
4558
4559 #[test]
4560 fn wrap_never_starts_a_line_with_a_block_marker() {
4561 let options = ReflowOptions {
4562 line_length: 25,
4563 ..Default::default()
4564 };
4565 let lines = reflow_line(
4568 "Some words here and then - a dash clause that wraps around the limit.",
4569 &options,
4570 );
4571 assert_eq!(
4572 lines,
4573 vec![
4574 "Some words here and",
4575 "then - a dash clause that",
4576 "wraps around the limit."
4577 ]
4578 );
4579
4580 for input in [
4582 "Alpha beta gamma delta epsilon - dash clause here to wrap",
4583 "Alpha beta gamma delta epsilon > quote lookalike here to wrap",
4584 "Alpha beta gamma delta epsilon # heading lookalike here to wrap",
4585 "Alpha beta gamma delta epsilon 1. ordered lookalike here to wrap",
4586 "Alpha beta gamma delta epsilon * star clause here to wrap",
4587 "Alpha beta gamma delta epsilon + plus clause here to wrap",
4588 ] {
4589 for width in 10..40 {
4590 let options = ReflowOptions {
4591 line_length: width,
4592 ..Default::default()
4593 };
4594 for line in reflow_line(input, &options) {
4595 assert!(
4596 !starts_block_construct(&line),
4597 "width {width}: wrapped line opens a block construct: {line:?} (input {input:?})"
4598 );
4599 }
4600 }
4601 }
4602 }
4603
4604 #[test]
4605 fn sentence_per_line_keeps_block_markers_mid_line() {
4606 let options = ReflowOptions {
4607 line_length: 80,
4608 sentence_per_line: true,
4609 ..Default::default()
4610 };
4611 let lines = reflow_line(
4614 "Google Calendar (Can't we get rid of this dependency? - I don't really see the need)",
4615 &options,
4616 );
4617 assert_eq!(
4618 lines,
4619 vec!["Google Calendar (Can't we get rid of this dependency? - I don't really see the need)".to_string()]
4620 );
4621
4622 let lines = reflow_line("See section 4? # is the marker we use. Fine.", &options);
4624 assert_eq!(lines, vec!["See section 4? # is the marker we use.", "Fine."]);
4625
4626 let lines = reflow_line("Is this a problem? > I quote someone here.", &options);
4627 assert_eq!(lines, vec!["Is this a problem? > I quote someone here."]);
4628
4629 let lines = reflow_line("Another case! 1. Not a list. More text follows here.", &options);
4630 for line in &lines {
4631 assert!(
4632 !starts_block_construct(line),
4633 "sentence-per-line output opens a block construct: {line:?}"
4634 );
4635 }
4636 }
4637
4638 #[test]
4639 fn inline_math_directly_after_display_math_stays_atomic() {
4640 let options = ReflowOptions {
4648 line_length: 8,
4649 ..Default::default()
4650 };
4651 let lines = reflow_line("$$a$$$bb cc dd$ x", &options);
4652 assert_eq!(lines, vec!["$$a$$$bb cc dd$".to_string(), "x".to_string()]);
4653 }
4654
4655 #[test]
4656 fn test_code_span_parsing() {
4657 let elements = parse_markdown_elements_inner("`code`", false, false, None);
4659 assert_eq!(elements.len(), 1);
4660 assert!(matches!(&elements[0], Element::Code { content, marker } if content == "code" && marker == "`"));
4661
4662 let elements = parse_markdown_elements_inner("``code``", false, false, None);
4664 assert_eq!(elements.len(), 1);
4665 assert!(matches!(&elements[0], Element::Code { content, marker } if content == "code" && marker == "``"));
4666
4667 let elements = parse_markdown_elements_inner("``code`inside``", false, false, None);
4669 assert_eq!(elements.len(), 1);
4670 assert!(
4671 matches!(&elements[0], Element::Code { content, marker } if content == "code`inside" && marker == "``")
4672 );
4673
4674 let elements = parse_markdown_elements_inner("`` code ``", false, false, None);
4676 assert_eq!(elements.len(), 1);
4677 assert!(matches!(&elements[0], Element::Code { content, marker } if content == " code " && marker == "``"));
4678
4679 let elements = parse_markdown_elements_inner("`unclosed", false, false, None);
4681 assert_eq!(elements.len(), 1);
4682 assert!(matches!(&elements[0], Element::Text(s) if s == "`unclosed"));
4683
4684 let elements = parse_markdown_elements_inner("`unclosed [link](url)", false, false, None);
4686 assert_eq!(elements.len(), 2);
4688 assert!(matches!(&elements[0], Element::Text(s) if s == "`unclosed "));
4689 assert!(matches!(&elements[1], Element::Link(s) if s == "[link](url)"));
4690 }
4691
4692 #[test]
4693 fn test_reflow_performance_long_input() {
4694 let mut text = String::new();
4697 for i in 1..400 {
4698 let backticks = "`".repeat(i);
4699 text.push_str(&backticks);
4700 text.push(' ');
4701 }
4702
4703 let start = std::time::Instant::now();
4704 let elements = parse_markdown_elements_inner(&text, false, false, None);
4705 let duration = start.elapsed();
4706
4707 assert!(duration.as_millis() < 100, "Parsing took too long: {duration:?}");
4709 assert!(!elements.is_empty());
4710 }
4711
4712 #[test]
4713 fn test_reflow_performance_display_math_heavy() {
4714 let text = "$$a$$".repeat(4000);
4719
4720 let start = std::time::Instant::now();
4721 let elements = parse_markdown_elements_inner(&text, false, false, None);
4722 let duration = start.elapsed();
4723
4724 assert!(duration.as_millis() < 100, "Parsing took too long: {duration:?}");
4725 assert_eq!(elements.len(), 4000);
4726 }
4727
4728 #[test]
4729 fn inline_math_len_at_start_matches_regex_at_slice_start() {
4730 let alphabet = ['$', 'a', ' '];
4735 let mut inputs: Vec<String> = vec![String::new()];
4736 let mut frontier: Vec<String> = vec![String::new()];
4737 for _ in 0..6 {
4738 let mut longer = Vec::new();
4739 for prefix in &frontier {
4740 for ch in alphabet {
4741 let mut s = prefix.clone();
4742 s.push(ch);
4743 longer.push(s);
4744 }
4745 }
4746 inputs.extend(longer.iter().cloned());
4747 frontier = longer;
4748 }
4749 inputs.push("$αβ$x".to_string());
4751 inputs.push("$α$$".to_string());
4752
4753 for s in &inputs {
4754 let expected = INLINE_MATH_REGEX
4755 .find(s)
4756 .ok()
4757 .flatten()
4758 .filter(|m| m.start() == 0)
4759 .map(|m| m.end());
4760 assert_eq!(inline_math_len_at_start(s), expected, "input: {s:?}");
4761 }
4762 }
4763
4764 #[test]
4765 fn inline_math_probe_after_dollar_matches_uncached_parse() {
4766 let cases = [
4772 ("$$a$$$b c$ x", r#"[DisplayMath("a"), InlineMath("b c"), Text(" x")]"#),
4773 (
4774 "$$a$$$b$ $$a$$$b$",
4775 r#"[DisplayMath("a"), InlineMath("b"), Text(" "), DisplayMath("a"), InlineMath("b")]"#,
4776 ),
4777 (
4779 "$$a$$$ x $y z$",
4780 r#"[DisplayMath("a"), InlineMath(" x "), Text("y z$")]"#,
4781 ),
4782 ("$$a$$$$ x", r#"[DisplayMath("a"), Text("$$ x")]"#),
4784 ("$$a$$$$b$$ x", r#"[DisplayMath("a"), DisplayMath("b"), Text(" x")]"#),
4785 (
4787 "$a$$b$$c$$d$ tail",
4788 r#"[Text("$a"), DisplayMath("b"), Text("c$$d$ tail")]"#,
4789 ),
4790 ];
4791 for (input, expected) in cases {
4792 let elements = parse_markdown_elements_inner(input, false, false, None);
4793 assert_eq!(format!("{elements:?}"), expected, "input: {input:?}");
4794 }
4795 }
4796
4797 #[test]
4798 fn test_atomic_spans() {
4799 let text_emphasis = "hello **word1 word2**";
4801
4802 let options_disabled = ReflowOptions {
4803 line_length: 18,
4804 atomic_spans: true,
4805 ..Default::default()
4806 };
4807 let lines_disabled = reflow_line(text_emphasis, &options_disabled);
4808 assert_eq!(lines_disabled, vec!["hello", "**word1 word2**"]);
4809
4810 let options_enabled = ReflowOptions {
4811 line_length: 18,
4812 atomic_spans: false,
4813 ..Default::default()
4814 };
4815 let lines_enabled = reflow_line(text_emphasis, &options_enabled);
4816 assert_eq!(lines_enabled, vec!["hello **word1", "word2**"]);
4817
4818 let text_code = "hello `word1 word2`";
4820
4821 let lines_code_disabled = reflow_line(text_code, &options_disabled);
4822 assert_eq!(lines_code_disabled, vec!["hello", "`word1 word2`"]);
4823
4824 let lines_code_enabled = reflow_line(text_code, &options_enabled);
4825 assert_eq!(lines_code_enabled, vec!["hello `word1", "word2`"]);
4826
4827 let text_code_padding = "hello `` `word1` `word2` ``";
4829 let lines_padding_enabled = reflow_line(text_code_padding, &options_enabled);
4830 assert_eq!(lines_padding_enabled, vec![r#"hello `` `word1`"#, r#"`word2` ``"#]);
4831 }
4832
4833 #[test]
4834 fn test_emphasis_containing_markers_is_not_split() {
4835 let options = ReflowOptions {
4836 line_length: 5,
4837 atomic_spans: false,
4838 ..Default::default()
4839 };
4840 let lines = reflow_line(r#"*foo \*bar*"#, &options);
4842 assert_eq!(lines, vec![r#"*foo \*bar*"#.to_string()]);
4843 }
4844
4845 fn semantic_shape(markdown: &str) -> String {
4850 let mut options = Options::empty();
4851 options.insert(Options::ENABLE_STRIKETHROUGH);
4852 let mut out = String::new();
4853 let push_prose = |out: &mut String, text: &str| {
4854 for c in text.chars() {
4855 if c.is_whitespace() {
4856 if !out.ends_with(char::is_whitespace) {
4857 out.push(' ');
4858 }
4859 } else {
4860 out.push(c);
4861 }
4862 }
4863 };
4864 for event in Parser::new_ext(markdown, options) {
4865 match event {
4866 Event::Text(text) => push_prose(&mut out, &text),
4867 Event::SoftBreak | Event::HardBreak => push_prose(&mut out, " "),
4868 Event::Code(code) => out.push_str(&format!("<code>{code}</code>")),
4870 Event::Start(tag) => out.push_str(&format!("<{tag:?}>")),
4871 Event::End(tag) => out.push_str(&format!("</{tag:?}>")),
4872 other => out.push_str(&format!("{other:?}")),
4873 }
4874 }
4875 out.trim().to_string()
4876 }
4877
4878 #[test]
4879 fn test_wrapping_a_span_never_changes_what_it_parses_to() {
4880 let corpus = [
4884 "_This is a very, very, very, very, very long line with some `code` inside._",
4885 "_alpha beta gamma delta epsilon `a b` zeta eta theta iota kappa lambda_",
4886 "**strong text with `code` and more words than fit on one single line**",
4887 "~~struck text with `code` and more words than fit on one single line~~",
4888 "_emphasis with **nested strong that is quite long** and trailing words_",
4889 "***A doubly nested bold italic span with more words than fit on a line***",
4892 "___Another doubly nested span with more words than fit on a single line___",
4893 "**_mixed strong then emphasis with more words than fit on a single line_**",
4894 "*__mixed emphasis then strong with more words than fit on a single line__*",
4895 "**~~strong strikethrough with more words than fit on a single line here~~**",
4896 "**a * b with a stray marker and plenty more words to pass the budget**",
4899 "_foo `a` bar `b` baz qux quux corge grault garply waldo fred plugh xyzzy_",
4900 "text before _a long emphasis with `code` inside of it here_ and after",
4901 "(_a parenthesized long emphasis with `code` inside of it right here_)",
4902 r#"*foo \*bar baz qux quux corge grault garply waldo fred plugh xyzzy*"#,
4903 "_tab\tseparated `a\tb` words spread out over quite a long emphasis span_",
4904 "_This has [text](<a b c d e f g h i j k>) and trailing words to wrap._",
4907 "_This has [`code`](<a b c d e f g h i j k>) and trailing words to wrap._",
4908 r#"_See [x](https://example.com "a long link title here") and `code` too._"#,
4909 "_A [link with a long label](https://example.com/path) and `code` here._",
4910 "_An image  plus `code` and more text_",
4911 ];
4912 for text in corpus {
4913 let expected = semantic_shape(text);
4914 for line_length in [20, 30, 40, 80] {
4915 for atomic_spans in [true, false] {
4916 let options = ReflowOptions {
4917 line_length,
4918 atomic_spans,
4919 ..Default::default()
4920 };
4921 let wrapped = reflow_line(text, &options).join("\n");
4922 assert_eq!(
4923 semantic_shape(&wrapped),
4924 expected,
4925 "reflow changed the parse of {text:?} at line_length={line_length} \
4926 atomic_spans={atomic_spans}\n wrapped: {wrapped:?}"
4927 );
4928 }
4929 }
4930 }
4931 }
4932
4933 #[test]
4934 fn test_wrapping_a_span_keeps_whole_the_constructs_pulldown_cannot_see() {
4935 let cases = [
4939 (
4940 "_alpha beta gamma [[a wiki link]] delta epsilon zeta eta_",
4941 "[[a wiki link]]",
4942 ),
4943 (
4944 "_alpha beta gamma {{< foo bar >}} delta epsilon zeta eta_",
4945 "{{< foo bar >}}",
4946 ),
4947 ("_alpha beta gamma $a + b$ delta epsilon zeta eta theta_", "$a + b$"),
4948 ("_alpha beta gamma $$a + b$$ delta epsilon zeta eta theta_", "$$a + b$$"),
4949 ];
4950 for (text, construct) in cases {
4951 for line_length in [12, 20, 30] {
4952 for atomic_spans in [true, false] {
4953 let options = ReflowOptions {
4954 line_length,
4955 atomic_spans,
4956 ..Default::default()
4957 };
4958 let wrapped = reflow_line(text, &options).join("\n");
4959 assert!(
4960 wrapped.contains(construct),
4961 "{construct} was broken at line_length={line_length} \
4962 atomic_spans={atomic_spans}: {wrapped:?}"
4963 );
4964 }
4965 }
4966 }
4967 }
4968
4969 #[test]
4970 fn test_overlong_emphasis_with_nested_code_span_wraps() {
4971 let options = ReflowOptions {
4975 line_length: 80,
4976 atomic_spans: true,
4977 ..Default::default()
4978 };
4979 let text = "_This is a very, very, very, very, very, very, very long line that exceeds 80 characters with some `code` inside._";
4980 let lines = reflow_line(text, &options);
4981 assert_eq!(
4982 lines,
4983 vec![
4984 "_This is a very, very, very, very, very, very, very long line that exceeds 80",
4985 "characters with some `code` inside._",
4986 ]
4987 );
4988 }
4989
4990 #[test]
4991 fn test_overlong_emphasis_with_nested_strong_wraps() {
4992 let options = ReflowOptions {
4994 line_length: 80,
4995 atomic_spans: true,
4996 ..Default::default()
4997 };
4998 let text = "_This is a very, very, very, very, very, very, very long line that exceeds 80 characters with some **bold** inside._";
4999 let lines = reflow_line(text, &options);
5000 assert_eq!(
5001 lines,
5002 vec![
5003 "_This is a very, very, very, very, very, very, very long line that exceeds 80",
5004 "characters with some **bold** inside._",
5005 ]
5006 );
5007 }
5008
5009 #[test]
5010 fn test_overlong_doubly_nested_span_wraps() {
5011 let options = ReflowOptions {
5016 line_length: 80,
5017 atomic_spans: true,
5018 ..Default::default()
5019 };
5020 let body = "This is a very, very, very, very, very, very, very, very, very, very long line that is emphasised.";
5021 for (open, close) in [
5022 ("***", "***"),
5023 ("___", "___"),
5024 ("**_", "_**"),
5025 ("*__", "__*"),
5026 ("**~~", "~~**"),
5027 ] {
5028 let text = format!("{open}{body}{close}");
5029 assert!(text.len() > options.line_length, "case must start over budget");
5030 let lines = reflow_line(&text, &options);
5031 assert!(
5032 lines.len() > 1,
5033 "{open}...{close} should wrap but stayed on one line: {lines:?}"
5034 );
5035 assert!(
5036 lines.iter().all(|line| line.len() <= options.line_length),
5037 "{open}...{close} left a line over the budget: {lines:?}"
5038 );
5039 assert_eq!(
5040 lines.join(" "),
5041 text,
5042 "{open}...{close} wrapping must only replace a space with a newline"
5043 );
5044 }
5045 }
5046
5047 #[test]
5048 fn test_overlong_span_with_stray_marker_stays_whole() {
5049 let options = ReflowOptions {
5053 line_length: 40,
5054 atomic_spans: true,
5055 ..Default::default()
5056 };
5057 let text = "**alpha * beta gamma delta epsilon zeta eta theta iota kappa**";
5058 let lines = reflow_line(text, &options);
5059 assert_eq!(lines, vec![text], "stray marker must keep the span whole");
5060 }
5061
5062 #[test]
5063 fn test_overlong_span_never_breaks_inside_a_nested_reference_link() {
5064 let options = ReflowOptions {
5070 line_length: 30,
5071 atomic_spans: true,
5072 defined_references: Some(HashSet::from([
5073 "ref".to_string(),
5074 "one two three four five six seven".to_string(),
5076 ])),
5077 ..Default::default()
5078 };
5079 for (text, link) in [
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][ref] beta gamma delta**",
5086 "[one two three four five six seven][ref]",
5087 ),
5088 (
5089 "_**alpha ![one two three four five six seven][ref] beta gamma delta**_",
5090 "![one two three four five six seven][ref]",
5091 ),
5092 (
5093 "_**alpha [one two three four five six seven][] beta gamma delta**_",
5094 "[one two three four five six seven][]",
5095 ),
5096 (
5097 "_**alpha [one two three four five six seven] beta gamma delta**_",
5098 "[one two three four five six seven]",
5099 ),
5100 ] {
5101 let lines = reflow_line(text, &options);
5102 assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
5103 assert!(
5104 lines.iter().any(|line| line.contains(link)),
5105 "{link} must stay on one line: {lines:?}"
5106 );
5107 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
5108 }
5109 }
5110
5111 #[test]
5112 fn test_overlong_span_breaks_inside_an_undefined_shortcut_reference() {
5113 let options = ReflowOptions {
5117 line_length: 30,
5118 atomic_spans: true,
5119 defined_references: Some(HashSet::new()),
5120 ..Default::default()
5121 };
5122 let text = "_**alpha [one two three four five six seven] beta gamma delta**_";
5123 let lines = reflow_line(text, &options);
5124 assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
5125 assert!(
5126 !lines
5127 .iter()
5128 .any(|line| line.contains("[one two three four five six seven]")),
5129 "an undefined shortcut is prose and should break: {lines:?}"
5130 );
5131 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
5132 }
5133
5134 #[test]
5135 fn test_overlong_span_never_breaks_inside_a_nested_attr_list() {
5136 let attr = "{.highlight key=\"a b c\"}";
5140 let text = format!("_**alpha beta gamma delta epsilon zeta{attr} eta theta iota kappa**_");
5141 let options = ReflowOptions {
5142 line_length: 20,
5143 atomic_spans: true,
5144 attr_lists: true,
5145 ..Default::default()
5146 };
5147 let lines = reflow_line(&text, &options);
5148 assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
5149 assert!(
5150 lines.iter().any(|line| line.contains(attr)),
5151 "attr list must stay on one line: {lines:?}"
5152 );
5153 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
5154
5155 let plain = ReflowOptions {
5158 attr_lists: false,
5159 ..options
5160 };
5161 let lines = reflow_line(&text, &plain);
5162 assert!(
5163 !lines.iter().any(|line| line.contains(attr)),
5164 "without the flavor the braces are prose and should break: {lines:?}"
5165 );
5166 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
5167 }
5168
5169 #[test]
5170 fn test_overlong_emphasis_never_breaks_inside_nested_code_span() {
5171 let options = ReflowOptions {
5175 line_length: 30,
5176 atomic_spans: true,
5177 ..Default::default()
5178 };
5179 let text = "_alpha beta gamma delta epsilon `a b` zeta eta theta iota kappa_";
5180 let lines = reflow_line(text, &options);
5181 assert!(lines.len() > 1, "over-long emphasis should wrap: {lines:?}");
5182 assert!(
5183 lines.iter().any(|line| line.contains("`a b`")),
5184 "nested code span must stay whole with its interior spaces: {lines:?}"
5185 );
5186 for line in &lines {
5187 assert_eq!(
5188 line.matches('`').count() % 2,
5189 0,
5190 "no line may contain half a code span: {line:?}"
5191 );
5192 }
5193 }
5194
5195 #[test]
5196 fn test_definition_list_marker_does_not_start_line() {
5197 let options = ReflowOptions {
5198 line_length: 20,
5199 ..Default::default()
5200 };
5201 let lines = reflow_line("This is a term and : definition here.", &options);
5203 for line in &lines {
5204 assert!(
5205 !line.trim_start().starts_with(": "),
5206 "Wrapped line should not start with definition marker: {line}"
5207 );
5208 }
5209 }
5210
5211 #[test]
5212 fn test_div_marker_does_not_start_line() {
5213 let options = ReflowOptions {
5214 line_length: 20,
5215 ..Default::default()
5216 };
5217 let lines = reflow_line("This is some text with ::: class marker.", &options);
5219 for line in &lines {
5220 assert!(
5221 !line.trim_start().starts_with(":::"),
5222 "Wrapped line should not start with div marker: {line}"
5223 );
5224 }
5225 }
5226}