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
83#[derive(Clone)]
85pub struct ReflowOptions {
86 pub line_length: usize,
88 pub break_on_sentences: bool,
90 pub preserve_breaks: bool,
92 pub sentence_per_line: bool,
94 pub semantic_line_breaks: bool,
96 pub abbreviations: Option<Vec<String>>,
100 pub length_mode: ReflowLengthMode,
102 pub attr_lists: bool,
105 pub myst_roles: bool,
109 pub require_sentence_capital: bool,
114 pub max_list_continuation_indent: Option<usize>,
118 pub defined_references: Option<HashSet<String>>,
132 pub atomic_spans: bool,
136}
137
138impl Default for ReflowOptions {
139 fn default() -> Self {
140 Self {
141 line_length: 80,
142 break_on_sentences: true,
143 preserve_breaks: false,
144 sentence_per_line: false,
145 semantic_line_breaks: false,
146 abbreviations: None,
147 length_mode: ReflowLengthMode::default(),
148 attr_lists: false,
149 myst_roles: false,
150 require_sentence_capital: true,
151 max_list_continuation_indent: None,
152 defined_references: None,
153 atomic_spans: true,
154 }
155 }
156}
157
158pub fn normalize_reference_label(label: &str) -> String {
165 label.split_whitespace().collect::<Vec<_>>().join(" ").to_lowercase()
166}
167
168fn footnote_refs_end(chars: &[char], start: usize) -> Option<usize> {
174 let mut pos = start;
175 let mut found = false;
176
177 loop {
178 if chars.get(pos) != Some(&'[') || chars.get(pos + 1) != Some(&'^') {
179 break;
180 }
181 let label_start = pos + 2;
182 let mut label_end = label_start;
183 while matches!(chars.get(label_end), Some(c) if c.is_ascii_alphanumeric() || *c == '_' || *c == '-') {
184 label_end += 1;
185 }
186 if label_end == label_start || chars.get(label_end) != Some(&']') {
187 break;
188 }
189 pos = label_end + 1;
190 found = true;
191 }
192
193 found.then_some(pos)
194}
195
196fn is_sentence_boundary(
200 text: &str,
201 chars: &[char],
202 pos: usize,
203 byte_offset_after_punct: usize,
204 abbreviations: &HashSet<String>,
205 require_sentence_capital: bool,
206) -> bool {
207 if pos + 1 >= chars.len() {
208 return false;
209 }
210
211 let c = chars[pos];
212 let next_char = chars[pos + 1];
213
214 if is_cjk_sentence_ending(c) {
217 let mut after_punct_pos = pos + 1;
219 while after_punct_pos < chars.len()
220 && (chars[after_punct_pos] == '*' || chars[after_punct_pos] == '_' || chars[after_punct_pos] == '~')
221 {
222 after_punct_pos += 1;
223 }
224
225 while after_punct_pos < chars.len() && chars[after_punct_pos].is_whitespace() {
227 after_punct_pos += 1;
228 }
229
230 if after_punct_pos >= chars.len() {
232 return false;
233 }
234
235 while after_punct_pos < chars.len()
237 && (chars[after_punct_pos] == '*' || chars[after_punct_pos] == '_' || chars[after_punct_pos] == '~')
238 {
239 after_punct_pos += 1;
240 }
241
242 if after_punct_pos >= chars.len() {
243 return false;
244 }
245
246 return true;
249 }
250
251 if c != '.' && c != '!' && c != '?' {
253 return false;
254 }
255
256 let (_space_pos, after_space_pos) = if next_char == ' ' {
258 (pos + 1, pos + 2)
260 } else if is_closing_quote(next_char) && pos + 2 < chars.len() {
261 if chars[pos + 2] == ' ' {
263 (pos + 2, pos + 3)
265 } else if (chars[pos + 2] == '*' || chars[pos + 2] == '_') && pos + 3 < chars.len() && chars[pos + 3] == ' ' {
266 (pos + 3, pos + 4)
268 } else if (chars[pos + 2] == '*' || chars[pos + 2] == '_')
269 && pos + 4 < chars.len()
270 && chars[pos + 3] == chars[pos + 2]
271 && chars[pos + 4] == ' '
272 {
273 (pos + 4, pos + 5)
275 } else {
276 return false;
277 }
278 } else if (next_char == '*' || next_char == '_') && pos + 2 < chars.len() && chars[pos + 2] == ' ' {
279 (pos + 2, pos + 3)
281 } else if (next_char == '*' || next_char == '_')
282 && pos + 3 < chars.len()
283 && chars[pos + 2] == next_char
284 && chars[pos + 3] == ' '
285 {
286 (pos + 3, pos + 4)
288 } else if next_char == '~' && pos + 3 < chars.len() && chars[pos + 2] == '~' && chars[pos + 3] == ' ' {
289 (pos + 3, pos + 4)
291 } else if next_char == '[' {
292 match footnote_refs_end(chars, pos + 1) {
298 Some(end_pos) if chars.get(end_pos) == Some(&' ') => (end_pos, end_pos + 1),
299 _ => return false,
300 }
301 } else {
302 return false;
303 };
304
305 let mut next_char_pos = after_space_pos;
307 while next_char_pos < chars.len() && chars[next_char_pos].is_whitespace() {
308 next_char_pos += 1;
309 }
310
311 if next_char_pos >= chars.len() {
313 return false;
314 }
315
316 let mut first_letter_pos = next_char_pos;
318 while first_letter_pos < chars.len()
319 && (chars[first_letter_pos] == '*'
320 || chars[first_letter_pos] == '_'
321 || chars[first_letter_pos] == '~'
322 || is_opening_quote(chars[first_letter_pos]))
323 {
324 first_letter_pos += 1;
325 }
326
327 if first_letter_pos >= chars.len() {
329 return false;
330 }
331
332 let first_char = chars[first_letter_pos];
333
334 if c == '!' || c == '?' {
336 return true;
337 }
338
339 if pos > 0 {
343 if text_ends_with_abbreviation(&text[..byte_offset_after_punct], abbreviations) {
345 return false;
346 }
347
348 if chars[pos - 1].is_numeric() && first_char.is_ascii_digit() {
350 return false;
351 }
352
353 if chars[pos - 1].is_ascii_uppercase() && (pos == 1 || (pos >= 2 && chars[pos - 2].is_whitespace())) {
357 return false;
358 }
359 }
360
361 if require_sentence_capital && !first_char.is_uppercase() && !is_cjk_char(first_char) {
364 return false;
365 }
366
367 true
368}
369
370pub fn split_into_sentences(text: &str) -> Vec<String> {
372 split_into_sentences_custom(text, &None)
373}
374
375pub fn split_into_sentences_custom(text: &str, custom_abbreviations: &Option<Vec<String>>) -> Vec<String> {
377 let abbreviations = get_abbreviations(custom_abbreviations);
378 split_into_sentences_with_set(text, &abbreviations, true)
379}
380
381fn split_into_sentences_with_set(
384 text: &str,
385 abbreviations: &HashSet<String>,
386 require_sentence_capital: bool,
387) -> Vec<String> {
388 let char_vec: Vec<char> = text.chars().collect();
389
390 let mut char_offsets = Vec::with_capacity(char_vec.len() + 1);
394 let mut offset = 0;
395 for c in &char_vec {
396 char_offsets.push(offset);
397 offset += c.len_utf8();
398 }
399 char_offsets.push(offset);
400
401 let code_spans = extract_code_spans(text);
403 let mut span_it = code_spans.iter().peekable();
404
405 let mut sentences = Vec::new();
406 let mut current_sentence = String::new();
407 let mut pos = 0;
408
409 while pos < char_vec.len() {
410 let c = char_vec[pos];
411 current_sentence.push(c);
412
413 let byte_idx = char_offsets[pos];
414
415 while let Some(span) = span_it.peek() {
417 if span.end <= byte_idx {
418 span_it.next();
419 } else {
420 break;
421 }
422 }
423
424 let in_code = if let Some(span) = span_it.peek() {
426 byte_idx >= span.start && byte_idx < span.end
427 } else {
428 false
429 };
430
431 if !in_code
432 && is_sentence_boundary(
433 text,
434 &char_vec,
435 pos,
436 char_offsets[pos + 1],
437 abbreviations,
438 require_sentence_capital,
439 )
440 {
441 if let Some(end_pos) = footnote_refs_end(&char_vec, pos + 1) {
443 while pos + 1 < end_pos {
444 pos += 1;
445 current_sentence.push(char_vec[pos]);
446 }
447 }
448
449 while pos + 1 < char_vec.len() {
451 let next = char_vec[pos + 1];
452 if next == '*' || next == '_' || next == '~' || is_closing_quote(next) {
453 pos += 1;
454 current_sentence.push(char_vec[pos]);
455 } else {
456 break;
457 }
458 }
459
460 if pos + 1 < char_vec.len() && char_vec[pos + 1] == ' ' {
462 pos += 1; }
464
465 sentences.push(current_sentence.trim().to_string());
466 current_sentence.clear();
467 }
468
469 pos += 1;
470 }
471
472 if !current_sentence.trim().is_empty() {
474 sentences.push(current_sentence.trim().to_string());
475 }
476 sentences
477}
478
479fn is_horizontal_rule(line: &str) -> bool {
481 if line.len() < 3 {
482 return false;
483 }
484
485 let mut chars = line.chars();
488 let Some(first_char) = chars.next() else {
489 return false;
490 };
491 if first_char != '-' && first_char != '_' && first_char != '*' {
492 return false;
493 }
494
495 let mut non_space_count = 1usize; for c in chars {
497 if c == ' ' {
498 continue;
499 }
500 if c != first_char {
501 return false;
502 }
503 non_space_count += 1;
504 }
505 non_space_count >= 3
506}
507
508fn is_numbered_list_item(line: &str) -> bool {
510 let mut chars = line.chars();
511
512 if !chars.next().is_some_and(char::is_numeric) {
514 return false;
515 }
516
517 while let Some(c) = chars.next() {
519 if c == '.' {
520 return chars.next() == Some(' ');
523 }
524 if !c.is_numeric() {
525 return false;
526 }
527 }
528
529 false
530}
531
532fn is_unordered_list_marker(s: &str) -> bool {
534 matches!(s.as_bytes().first(), Some(b'-' | b'*' | b'+'))
535 && !is_horizontal_rule(s)
536 && (s.len() == 1 || s.as_bytes().get(1) == Some(&b' '))
537}
538
539fn is_block_boundary_core(trimmed: &str) -> bool {
542 trimmed.is_empty()
543 || trimmed.starts_with('#')
544 || trimmed.starts_with("```")
545 || trimmed.starts_with("~~~")
546 || trimmed.starts_with('>')
547 || (trimmed.starts_with('[') && trimmed.contains("]:"))
548 || is_horizontal_rule(trimmed)
549 || is_unordered_list_marker(trimmed)
550 || is_numbered_list_item(trimmed)
551 || is_definition_list_item(trimmed)
552 || trimmed.starts_with(":::")
553}
554
555fn is_block_boundary(trimmed: &str) -> bool {
558 is_block_boundary_core(trimmed) || trimmed.starts_with('|')
559}
560
561fn is_paragraph_boundary(trimmed: &str, line: &str) -> bool {
565 is_block_boundary_core(trimmed)
566 || calculate_indentation_width_default(line) >= 4
567 || crate::utils::table_utils::TableUtils::is_potential_table_row(line)
568}
569
570fn has_hard_break(line: &str) -> bool {
576 let line = line.strip_suffix('\r').unwrap_or(line);
577 line.ends_with(" ") || line.ends_with('\\')
578}
579
580fn ends_with_sentence_punct(text: &str) -> bool {
582 text.ends_with('.') || text.ends_with('!') || text.ends_with('?')
583}
584
585fn trim_preserving_hard_break(s: &str) -> String {
591 let s = s.strip_suffix('\r').unwrap_or(s);
593
594 if s.ends_with('\\') {
596 return s.to_string();
598 }
599
600 if s.ends_with(" ") {
602 let content_end = s.trim_end().len();
604 if content_end == 0 {
605 return String::new();
607 }
608 format!("{} ", &s[..content_end])
610 } else {
611 s.trim_end().to_string()
613 }
614}
615
616fn parse_elements(text: &str, options: &ReflowOptions) -> Vec<Element> {
618 parse_markdown_elements_inner(
619 text,
620 options.attr_lists,
621 options.myst_roles,
622 options.defined_references.as_ref(),
623 )
624}
625
626pub fn reflow_line(line: &str, options: &ReflowOptions) -> Vec<String> {
627 if options.sentence_per_line {
629 let elements = parse_elements(line, options);
630 return merge_block_construct_continuations(reflow_elements_sentence_per_line(
631 &elements,
632 &options.abbreviations,
633 options.require_sentence_capital,
634 ));
635 }
636
637 if options.semantic_line_breaks {
639 let elements = parse_elements(line, options);
640 return merge_block_construct_continuations(reflow_elements_semantic(&elements, options));
641 }
642
643 if options.line_length == 0 || display_len(line, options.length_mode) <= options.line_length {
646 return vec![line.to_string()];
647 }
648
649 let elements = parse_elements(line, options);
651
652 merge_block_construct_continuations(reflow_elements(&elements, options))
654}
655
656#[derive(Debug, Clone)]
658enum Element {
659 Text(String),
661 Link(String),
663 ReferenceLink(String),
665 EmptyReferenceLink(String),
667 ShortcutReference(String),
669 InlineImage(String),
671 ReferenceImage(String),
673 EmptyReferenceImage(String),
675 LinkedImage(String),
677 FootnoteReference(String),
679 Strikethrough {
681 content: String,
682 double: bool,
684 },
685 WikiLink(String),
687 InlineMath(String),
689 DisplayMath(String),
691 EmojiShortcode(String),
693 Autolink(String),
695 HtmlTag(String),
697 HtmlEntity(String),
699 HugoShortcode(String),
701 AttrList(String),
703 MystRole(String),
707 Code { content: String, marker: String },
709 Bold {
711 content: String,
712 underscore: bool,
714 },
715 Italic {
717 content: String,
718 underscore: bool,
720 },
721}
722
723impl std::fmt::Display for Element {
724 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
725 match self {
726 Element::Text(s) => write!(f, "{s}"),
727 Element::Link(s) => write!(f, "{s}"),
728 Element::ReferenceLink(s) => write!(f, "{s}"),
729 Element::EmptyReferenceLink(s) => write!(f, "{s}"),
730 Element::ShortcutReference(s) => write!(f, "{s}"),
731 Element::InlineImage(s) => write!(f, "{s}"),
732 Element::ReferenceImage(s) => write!(f, "{s}"),
733 Element::EmptyReferenceImage(s) => write!(f, "{s}"),
734 Element::LinkedImage(s) => write!(f, "{s}"),
735 Element::FootnoteReference(s) => write!(f, "{s}"),
736 Element::Strikethrough { content, double } => {
737 let marker = if *double { "~~" } else { "~" };
738 write!(f, "{marker}{content}{marker}")
739 }
740 Element::WikiLink(s) => write!(f, "[[{s}]]"),
741 Element::InlineMath(s) => write!(f, "${s}$"),
742 Element::DisplayMath(s) => write!(f, "$${s}$$"),
743 Element::EmojiShortcode(s) => write!(f, ":{s}:"),
744 Element::Autolink(s) => write!(f, "{s}"),
745 Element::HtmlTag(s) => write!(f, "{s}"),
746 Element::HtmlEntity(s) => write!(f, "{s}"),
747 Element::HugoShortcode(s) => write!(f, "{s}"),
748 Element::AttrList(s) => write!(f, "{s}"),
749 Element::MystRole(s) => write!(f, "{s}"),
750 Element::Code { content, marker } => write!(f, "{marker}{content}{marker}"),
751 Element::Bold { content, underscore } => {
752 if *underscore {
753 write!(f, "__{content}__")
754 } else {
755 write!(f, "**{content}**")
756 }
757 }
758 Element::Italic { content, underscore } => {
759 if *underscore {
760 write!(f, "_{content}_")
761 } else {
762 write!(f, "*{content}*")
763 }
764 }
765 }
766 }
767}
768
769impl Element {
770 fn display_len(&self, mode: ReflowLengthMode) -> usize {
771 match self {
772 Element::Text(s)
773 | Element::Link(s)
774 | Element::ReferenceLink(s)
775 | Element::EmptyReferenceLink(s)
776 | Element::ShortcutReference(s)
777 | Element::InlineImage(s)
778 | Element::ReferenceImage(s)
779 | Element::EmptyReferenceImage(s)
780 | Element::LinkedImage(s)
781 | Element::FootnoteReference(s)
782 | Element::Autolink(s)
783 | Element::HtmlTag(s)
784 | Element::HtmlEntity(s)
785 | Element::HugoShortcode(s)
786 | Element::AttrList(s)
787 | Element::MystRole(s) => display_len(s, mode),
788 Element::WikiLink(s) => display_len(s, mode) + 4,
789 Element::InlineMath(s) => display_len(s, mode) + 2,
790 Element::DisplayMath(s) => display_len(s, mode) + 4,
791 Element::EmojiShortcode(s) => display_len(s, mode) + 2,
792 Element::Strikethrough { content, double } => display_len(content, mode) + if *double { 4 } else { 2 },
793 Element::Code { content, marker } => display_len(content, mode) + display_len(marker, mode) * 2,
794 Element::Bold { content, .. } => display_len(content, mode) + 4,
795 Element::Italic { content, .. } => display_len(content, mode) + 2,
796 }
797 }
798}
799
800#[derive(Debug, Clone)]
802struct EmphasisSpan {
803 start: usize,
805 end: usize,
807 content: String,
809 is_strong: bool,
811 is_strikethrough: bool,
813 uses_underscore: bool,
815 strikethrough_double: bool,
818}
819
820fn extract_emphasis_and_code_spans(text: &str) -> (Vec<EmphasisSpan>, Vec<CodeSpan>) {
830 let has_emphasis = text.contains(['*', '_', '~']);
832 let has_code = text.contains('`');
833 if !has_emphasis && !has_code {
834 return (Vec::new(), Vec::new());
835 }
836
837 let mut emphasis_spans = Vec::new();
838 let mut code_spans = Vec::new();
839
840 let mut options = Options::empty();
841 if has_emphasis {
842 options.insert(Options::ENABLE_STRIKETHROUGH);
843 }
844
845 let mut emphasis_stack: Vec<(usize, bool)> = Vec::new(); let mut strong_stack: Vec<(usize, bool)> = Vec::new();
848 let mut strikethrough_stack: Vec<usize> = Vec::new();
849
850 let parser = Parser::new_ext(text, options).into_offset_iter();
851
852 for (event, range) in parser {
853 match event {
854 Event::Code(_) => {
855 code_spans.push(CodeSpan {
856 start: range.start,
857 end: range.end,
858 });
859 }
860 Event::Start(Tag::Emphasis) => {
861 let uses_underscore = text.get(range.start..range.start + 1) == Some("_");
863 emphasis_stack.push((range.start, uses_underscore));
864 }
865 Event::End(TagEnd::Emphasis) => {
866 if let Some((start_byte, uses_underscore)) = emphasis_stack.pop() {
867 let content_start = start_byte + 1;
868 let content_end = range.end - 1;
869 if content_end > content_start
870 && let Some(content) = text.get(content_start..content_end)
871 {
872 emphasis_spans.push(EmphasisSpan {
873 start: start_byte,
874 end: range.end,
875 content: content.to_string(),
876 is_strong: false,
877 is_strikethrough: false,
878 uses_underscore,
879 strikethrough_double: false,
880 });
881 }
882 }
883 }
884 Event::Start(Tag::Strong) => {
885 let uses_underscore = text.get(range.start..range.start + 2) == Some("__");
886 strong_stack.push((range.start, uses_underscore));
887 }
888 Event::End(TagEnd::Strong) => {
889 if let Some((start_byte, uses_underscore)) = strong_stack.pop() {
890 let content_start = start_byte + 2;
891 let content_end = range.end - 2;
892 if content_end > content_start
893 && let Some(content) = text.get(content_start..content_end)
894 {
895 emphasis_spans.push(EmphasisSpan {
896 start: start_byte,
897 end: range.end,
898 content: content.to_string(),
899 is_strong: true,
900 is_strikethrough: false,
901 uses_underscore,
902 strikethrough_double: false,
903 });
904 }
905 }
906 }
907 Event::Start(Tag::Strikethrough) => {
908 strikethrough_stack.push(range.start);
909 }
910 Event::End(TagEnd::Strikethrough) => {
911 if let Some(start_byte) = strikethrough_stack.pop() {
912 let double = text.get(start_byte..start_byte + 2) == Some("~~");
913 let marker_len = if double { 2 } else { 1 };
914 let content_start = start_byte + marker_len;
915 let content_end = range.end - marker_len;
916 if content_end > content_start
917 && let Some(content) = text.get(content_start..content_end)
918 {
919 emphasis_spans.push(EmphasisSpan {
920 start: start_byte,
921 end: range.end,
922 content: content.to_string(),
923 is_strong: false,
924 is_strikethrough: true,
925 uses_underscore: false,
926 strikethrough_double: double,
927 });
928 }
929 }
930 }
931 _ => {}
932 }
933 }
934
935 emphasis_spans.sort_by_key(|s| s.start);
936 (emphasis_spans, code_spans)
937}
938
939#[derive(Debug, Clone)]
940struct CodeSpan {
941 start: usize,
942 end: usize,
943}
944
945fn extract_code_spans(text: &str) -> Vec<CodeSpan> {
946 if !text.contains('`') {
948 return Vec::new();
949 }
950
951 let mut spans = Vec::new();
952 let parser = Parser::new(text).into_offset_iter();
953 for (event, range) in parser {
954 if let Event::Code(_) = event {
955 spans.push(CodeSpan {
956 start: range.start,
957 end: range.end,
958 });
959 }
960 }
961 spans
962}
963
964#[derive(Debug, Clone)]
965struct LinkSpan {
966 start: usize,
967 end: usize,
968 link_type: Option<LinkType>,
969 is_image: bool,
970 is_footnote: bool,
971}
972
973fn extract_link_spans(text: &str, defined_references: Option<&HashSet<String>>) -> Vec<LinkSpan> {
974 if !text.contains('[') {
977 return Vec::new();
978 }
979
980 let mut spans = Vec::new();
981 let mut options = Options::empty();
982 options.insert(Options::ENABLE_FOOTNOTES);
983
984 let resolve = move |link: BrokenLink<'_>| -> Option<(CowStr<'_>, CowStr<'_>)> {
1001 let atomic = match link.link_type {
1006 LinkType::Shortcut | LinkType::ShortcutUnknown => match defined_references {
1007 Some(defs) => defs.contains(&normalize_reference_label(link.reference.as_ref())),
1008 None => true,
1009 },
1010 _ => true,
1011 };
1012 atomic.then_some((CowStr::Borrowed(""), CowStr::Borrowed("")))
1013 };
1014 let parser = Parser::new_with_broken_link_callback(text, options, Some(resolve)).into_offset_iter();
1015 let mut stack = Vec::new();
1016
1017 for (event, range) in parser {
1018 match event {
1019 Event::Start(Tag::Link { link_type, .. }) => {
1020 stack.push((range.start, Some(link_type), false));
1021 }
1022 Event::Start(Tag::Image { link_type, .. }) => {
1023 stack.push((range.start, Some(link_type), true));
1024 }
1025 Event::End(TagEnd::Link) => {
1026 if let Some((start_byte, link_type, is_image)) = stack.pop()
1027 && stack.is_empty()
1028 {
1029 spans.push(LinkSpan {
1030 start: start_byte,
1031 end: range.end,
1032 link_type,
1033 is_image,
1034 is_footnote: false,
1035 });
1036 }
1037 }
1038 Event::End(TagEnd::Image) => {
1039 if let Some((start_byte, link_type, is_image)) = stack.pop()
1040 && stack.is_empty()
1041 {
1042 spans.push(LinkSpan {
1043 start: start_byte,
1044 end: range.end,
1045 link_type,
1046 is_image,
1047 is_footnote: false,
1048 });
1049 }
1050 }
1051 Event::FootnoteReference(_) if stack.is_empty() => {
1052 spans.push(LinkSpan {
1053 start: range.start,
1054 end: range.end,
1055 link_type: None,
1056 is_image: false,
1057 is_footnote: true,
1058 });
1059 }
1060 _ => {}
1061 }
1062 }
1063
1064 spans.sort_by_key(|s| s.start);
1065 spans
1066}
1067
1068fn myst_role_len_at(text: &str, absolute_pos: usize, code_spans: &[CodeSpan]) -> Option<usize> {
1076 let bytes = text.as_bytes();
1077 if bytes.first() != Some(&b'{') {
1078 return None;
1079 }
1080
1081 let mut j = 1;
1083 match bytes.get(j) {
1084 Some(&b) if b.is_ascii_alphabetic() || b == b'_' => {}
1085 _ => return None,
1086 }
1087 while let Some(&b) = bytes.get(j) {
1088 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b':' | b'.') {
1089 j += 1;
1090 } else {
1091 break;
1092 }
1093 }
1094 if bytes.get(j) != Some(&b'}') {
1095 return None;
1096 }
1097 j += 1; let code_span_start = absolute_pos + j;
1101 if let Ok(idx) = code_spans.binary_search_by_key(&code_span_start, |span| span.start) {
1102 let span = &code_spans[idx];
1103 let code_span_len = span.end - span.start;
1104 return Some(j + code_span_len);
1105 }
1106
1107 None
1108}
1109
1110fn inline_math_len_at_start(s: &str) -> Option<usize> {
1117 let bytes = s.as_bytes();
1118 if bytes.first() != Some(&b'$') || bytes.get(1) == Some(&b'$') {
1120 return None;
1121 }
1122 let close = 1 + s[1..].find('$')?;
1125 if bytes.get(close + 1) == Some(&b'$') {
1127 return None;
1128 }
1129 Some(close + 1)
1130}
1131
1132#[derive(Clone, Copy, Debug)]
1134struct PatternMatch {
1135 start: usize,
1136 end: usize,
1137}
1138
1139#[derive(Clone, Copy)]
1153enum PatternCache {
1154 Unsearched,
1155 NotFound,
1156 Found(PatternMatch),
1157}
1158
1159impl PatternCache {
1160 fn earliest_in(
1164 &mut self,
1165 remaining: &str,
1166 cursor: usize,
1167 find: impl FnOnce(&str) -> Option<(usize, usize)>,
1168 ) -> Option<(usize, usize)> {
1169 let stale = match self {
1170 PatternCache::Found(pm) => pm.start < cursor,
1171 PatternCache::NotFound => false,
1172 PatternCache::Unsearched => true,
1173 };
1174 if stale {
1175 *self = match find(remaining) {
1176 Some((start, end)) => PatternCache::Found(PatternMatch {
1177 start: cursor + start,
1178 end: cursor + end,
1179 }),
1180 None => PatternCache::NotFound,
1181 };
1182 }
1183 match self {
1184 PatternCache::Found(pm) => Some((pm.start - cursor, pm.end - cursor)),
1185 _ => None,
1186 }
1187 }
1188}
1189
1190fn parse_markdown_elements_inner(
1201 text: &str,
1202 attr_lists: bool,
1203 myst_roles: bool,
1204 defined_references: Option<&HashSet<String>>,
1205) -> Vec<Element> {
1206 let mut elements = Vec::new();
1207 let mut remaining = text;
1208
1209 let (emphasis_spans, code_spans) = extract_emphasis_and_code_spans(text);
1214 let link_spans = extract_link_spans(text, defined_references);
1215
1216 let mut cached_wiki_link = PatternCache::Unsearched;
1219 let mut cached_display_math = PatternCache::Unsearched;
1220 let mut cached_inline_math = PatternCache::Unsearched;
1221 let mut cached_emoji = PatternCache::Unsearched;
1222 let mut cached_html_entity = PatternCache::Unsearched;
1223 let mut cached_hugo_shortcode = PatternCache::Unsearched;
1224 let mut cached_html_tag = PatternCache::Unsearched;
1225 let mut cached_next_curly = PatternCache::Unsearched;
1226
1227 let mut link_span_idx = 0usize;
1231 let mut emphasis_span_idx = 0usize;
1232 let mut code_span_idx = 0usize;
1233
1234 while !remaining.is_empty() {
1235 let current_offset = text.len() - remaining.len();
1237 let mut earliest_match: Option<(usize, usize, &str)> = None;
1240
1241 while link_span_idx < link_spans.len() && link_spans[link_span_idx].start < current_offset {
1243 link_span_idx += 1;
1244 }
1245 let next_link: Option<&LinkSpan> = link_spans.get(link_span_idx);
1246
1247 if let Some(span) = next_link {
1248 let pos_in_remaining = span.start - current_offset;
1249 if earliest_match
1250 .as_ref()
1251 .is_none_or(|(start, _, _)| pos_in_remaining < *start)
1252 {
1253 let match_end = span.end - current_offset;
1254 earliest_match = Some((pos_in_remaining, match_end, "link_span"));
1255 }
1256 }
1257
1258 if let Some((start, end)) = cached_wiki_link.earliest_in(remaining, current_offset, |suffix| {
1260 WIKI_LINK_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1261 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1262 {
1263 earliest_match = Some((start, end, "wiki_link"));
1264 }
1265
1266 if let Some((start, end)) = cached_display_math.earliest_in(remaining, current_offset, |suffix| {
1268 DISPLAY_MATH_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1269 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1270 {
1271 earliest_match = Some((start, end, "display_math"));
1272 }
1273
1274 let inline_math_probe = if current_offset > 0 && text.as_bytes()[current_offset - 1] == b'$' {
1288 inline_math_len_at_start(remaining).map(|len| (0, len))
1289 } else {
1290 None
1291 };
1292 if let Some((start, end)) = inline_math_probe.or_else(|| {
1293 cached_inline_math.earliest_in(remaining, current_offset, |suffix| {
1294 INLINE_MATH_REGEX
1295 .find(suffix)
1296 .ok()
1297 .flatten()
1298 .map(|m| (m.start(), m.end()))
1299 })
1300 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1301 {
1302 earliest_match = Some((start, end, "inline_math"));
1303 }
1304
1305 if let Some((start, end)) = cached_emoji.earliest_in(remaining, current_offset, |suffix| {
1307 EMOJI_SHORTCODE_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1308 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1309 {
1310 earliest_match = Some((start, end, "emoji"));
1311 }
1312
1313 if let Some((start, end)) = cached_html_entity.earliest_in(remaining, current_offset, |suffix| {
1315 HTML_ENTITY_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1316 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1317 {
1318 earliest_match = Some((start, end, "html_entity"));
1319 }
1320
1321 if let Some((start, end)) = cached_hugo_shortcode.earliest_in(remaining, current_offset, |suffix| {
1324 HUGO_SHORTCODE_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1325 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1326 {
1327 earliest_match = Some((start, end, "hugo_shortcode"));
1328 }
1329
1330 if let Some((start, end)) = cached_html_tag.earliest_in(remaining, current_offset, |suffix| {
1337 let mut from = 0;
1338 while let Some(m) = HTML_TAG_PATTERN.find(&suffix[from..]) {
1339 let (tag_start, tag_end) = (from + m.start(), from + m.end());
1340 let tag = &suffix[tag_start..tag_end];
1341 let is_url_autolink = tag.starts_with("<http://")
1343 || tag.starts_with("<https://")
1344 || tag.starts_with("<mailto:")
1345 || tag.starts_with("<ftp://")
1346 || tag.starts_with("<ftps://");
1347 let is_email_autolink = {
1350 let content = tag.trim_start_matches('<').trim_end_matches('>');
1351 EMAIL_PATTERN.is_match(content)
1352 };
1353 if is_url_autolink || is_email_autolink {
1354 from = tag_end;
1355 } else {
1356 return Some((tag_start, tag_end));
1357 }
1358 }
1359 None
1360 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1361 {
1362 earliest_match = Some((start, end, "html_tag"));
1363 }
1364
1365 let mut next_special = remaining.len();
1367 let mut special_type = "";
1368 let mut pulldown_emphasis: Option<&EmphasisSpan> = None;
1369 let mut attr_list_len: usize = 0;
1370 let mut myst_role_len: usize = 0;
1371
1372 while code_span_idx < code_spans.len() && code_spans[code_span_idx].start < current_offset {
1374 code_span_idx += 1;
1375 }
1376 let next_code_span: Option<&CodeSpan> = code_spans.get(code_span_idx);
1377 if let Some(span) = next_code_span {
1378 let pos_in_remaining = span.start - current_offset;
1379 if pos_in_remaining < next_special {
1380 next_special = pos_in_remaining;
1381 special_type = "pulldown_code";
1382 }
1383 }
1384
1385 let next_curly_pos = cached_next_curly
1388 .earliest_in(remaining, current_offset, |suffix| {
1389 suffix.find('{').map(|pos| (pos, pos + 1))
1390 })
1391 .map(|(start, _)| start);
1392
1393 if myst_roles
1398 && let Some(pos) = next_curly_pos
1399 && pos < next_special
1400 && let Some(role_len) = myst_role_len_at(&remaining[pos..], current_offset + pos, &code_spans)
1401 {
1402 next_special = pos;
1403 special_type = "myst_role";
1404 myst_role_len = role_len;
1405 }
1406
1407 if attr_lists
1409 && let Some(pos) = next_curly_pos
1410 && pos < next_special
1411 && let Some(m) = ATTR_LIST_PATTERN.find(&remaining[pos..])
1412 && m.start() == 0
1413 {
1414 next_special = pos;
1415 special_type = "attr_list";
1416 attr_list_len = m.end();
1417 }
1418
1419 while emphasis_span_idx < emphasis_spans.len() && emphasis_spans[emphasis_span_idx].start < current_offset {
1421 emphasis_span_idx += 1;
1422 }
1423 if let Some(span) = emphasis_spans.get(emphasis_span_idx) {
1424 let pos_in_remaining = span.start - current_offset;
1425 if pos_in_remaining < next_special {
1426 next_special = pos_in_remaining;
1427 special_type = "pulldown_emphasis";
1428 pulldown_emphasis = Some(span);
1429 }
1430 }
1431
1432 let should_process_markdown_link = if let Some((pos, _, _)) = earliest_match {
1434 pos < next_special
1435 } else {
1436 false
1437 };
1438
1439 if should_process_markdown_link {
1440 let (pos, match_end, pattern_type) = earliest_match.unwrap();
1441
1442 if pos > 0 {
1444 elements.push(Element::Text(remaining[..pos].to_string()));
1445 }
1446
1447 match pattern_type {
1449 "link_span" => {
1450 let span = next_link.unwrap();
1451 let raw_text = remaining[pos..match_end].to_string();
1452 if span.is_footnote {
1453 elements.push(Element::FootnoteReference(raw_text));
1454 } else if span.is_image {
1455 match span.link_type {
1456 Some(LinkType::Inline) => elements.push(Element::InlineImage(raw_text)),
1457 Some(LinkType::Reference)
1460 | Some(LinkType::ReferenceUnknown)
1461 | Some(LinkType::Shortcut)
1462 | Some(LinkType::ShortcutUnknown) => elements.push(Element::ReferenceImage(raw_text)),
1463 Some(LinkType::Collapsed) | Some(LinkType::CollapsedUnknown) => {
1464 elements.push(Element::EmptyReferenceImage(raw_text))
1465 }
1466 _ => elements.push(Element::InlineImage(raw_text)),
1467 }
1468 } else {
1469 match span.link_type {
1470 Some(LinkType::Inline) => {
1471 if raw_text.starts_with('[') && raw_text.contains("![") {
1472 elements.push(Element::LinkedImage(raw_text));
1473 } else {
1474 elements.push(Element::Link(raw_text));
1475 }
1476 }
1477 Some(LinkType::Reference) | Some(LinkType::ReferenceUnknown) => {
1480 elements.push(Element::ReferenceLink(raw_text))
1481 }
1482 Some(LinkType::Collapsed) | Some(LinkType::CollapsedUnknown) => {
1483 elements.push(Element::EmptyReferenceLink(raw_text))
1484 }
1485 Some(LinkType::Shortcut) | Some(LinkType::ShortcutUnknown) => {
1486 elements.push(Element::ShortcutReference(raw_text))
1487 }
1488 Some(LinkType::Autolink) | Some(LinkType::Email) => {
1489 elements.push(Element::Autolink(raw_text))
1490 }
1491 _ => elements.push(Element::Link(raw_text)),
1492 }
1493 }
1494 remaining = &remaining[match_end..];
1495 }
1496 "wiki_link" => {
1497 if let Some(caps) = WIKI_LINK_REGEX.captures(remaining) {
1498 let content = caps.get(1).map_or("", |m| m.as_str());
1499 elements.push(Element::WikiLink(content.to_string()));
1500 remaining = &remaining[match_end..];
1501 } else {
1502 elements.push(Element::Text("[[".to_string()));
1503 remaining = &remaining[2..];
1504 }
1505 }
1506 "display_math" => {
1507 if let Some(caps) = DISPLAY_MATH_REGEX.captures(remaining) {
1508 let math = caps.get(1).map_or("", |m| m.as_str());
1509 elements.push(Element::DisplayMath(math.to_string()));
1510 remaining = &remaining[match_end..];
1511 } else {
1512 elements.push(Element::Text("$$".to_string()));
1513 remaining = &remaining[2..];
1514 }
1515 }
1516 "inline_math" => {
1517 if let Ok(Some(caps)) = INLINE_MATH_REGEX.captures(remaining) {
1518 let math = caps.get(1).map_or("", |m| m.as_str());
1519 elements.push(Element::InlineMath(math.to_string()));
1520 remaining = &remaining[match_end..];
1521 } else {
1522 elements.push(Element::Text("$".to_string()));
1523 remaining = &remaining[1..];
1524 }
1525 }
1526 "emoji" => {
1527 if let Some(caps) = EMOJI_SHORTCODE_REGEX.captures(remaining) {
1528 let emoji = caps.get(1).map_or("", |m| m.as_str());
1529 elements.push(Element::EmojiShortcode(emoji.to_string()));
1530 remaining = &remaining[match_end..];
1531 } else {
1532 elements.push(Element::Text(":".to_string()));
1533 remaining = &remaining[1..];
1534 }
1535 }
1536 "html_entity" => {
1537 elements.push(Element::HtmlEntity(remaining[pos..match_end].to_string()));
1539 remaining = &remaining[match_end..];
1540 }
1541 "hugo_shortcode" => {
1542 elements.push(Element::HugoShortcode(remaining[pos..match_end].to_string()));
1544 remaining = &remaining[match_end..];
1545 }
1546 "html_tag" => {
1547 elements.push(Element::HtmlTag(remaining[pos..match_end].to_string()));
1549 remaining = &remaining[match_end..];
1550 }
1551 _ => unreachable!("unknown pattern type: {}", pattern_type),
1552 }
1553 } else {
1554 if next_special > 0 && next_special < remaining.len() {
1558 elements.push(Element::Text(remaining[..next_special].to_string()));
1559 remaining = &remaining[next_special..];
1560 }
1561
1562 match special_type {
1564 "pulldown_code" => {
1565 let span = next_code_span.unwrap();
1566 let span_len = span.end - span.start;
1567 let code_raw = &remaining[..span_len];
1568 if let Some((content, marker)) = decompose_code_span(code_raw) {
1569 elements.push(Element::Code {
1570 content: content.to_string(),
1571 marker: marker.to_string(),
1572 });
1573 } else {
1574 elements.push(Element::Text(code_raw.to_string()));
1575 }
1576 remaining = &remaining[span_len..];
1577 }
1578 "attr_list" => {
1579 elements.push(Element::AttrList(remaining[..attr_list_len].to_string()));
1580 remaining = &remaining[attr_list_len..];
1581 }
1582 "myst_role" => {
1583 elements.push(Element::MystRole(remaining[..myst_role_len].to_string()));
1584 remaining = &remaining[myst_role_len..];
1585 }
1586 "pulldown_emphasis" => {
1587 let span = pulldown_emphasis.expect("pulldown_emphasis must be set");
1589 let span_len = span.end - span.start;
1590 if span.is_strikethrough {
1591 elements.push(Element::Strikethrough {
1592 content: span.content.clone(),
1593 double: span.strikethrough_double,
1594 });
1595 } else if span.is_strong {
1596 elements.push(Element::Bold {
1597 content: span.content.clone(),
1598 underscore: span.uses_underscore,
1599 });
1600 } else {
1601 elements.push(Element::Italic {
1602 content: span.content.clone(),
1603 underscore: span.uses_underscore,
1604 });
1605 }
1606 remaining = &remaining[span_len..];
1607 }
1608 _ => {
1609 elements.push(Element::Text(remaining.to_string()));
1611 break;
1612 }
1613 }
1614 }
1615 }
1616
1617 let mut merged_elements = Vec::new();
1619 for el in elements {
1620 match el {
1621 Element::Text(s) => {
1622 if let Some(Element::Text(last_s)) = merged_elements.last_mut() {
1623 last_s.push_str(&s);
1624 } else {
1625 merged_elements.push(Element::Text(s));
1626 }
1627 }
1628 other => merged_elements.push(other),
1629 }
1630 }
1631 merged_elements
1632}
1633
1634fn should_insert_space_before_join(current: &str) -> bool {
1635 !current.is_empty()
1636 && !current.ends_with(' ')
1637 && !current.ends_with('(')
1638 && !current.ends_with('[')
1639 && !current.ends_with('-')
1640}
1641
1642fn is_setext_or_thematic(text: &str) -> bool {
1648 let mut marker = 0u8;
1649 let mut count = 0usize;
1650 let mut has_space = false;
1651 for &b in text.as_bytes() {
1652 match b {
1653 b' ' | b'\t' => has_space = true,
1654 b'-' | b'=' | b'*' | b'_' => {
1655 if marker == 0 {
1656 marker = b;
1657 } else if b != marker {
1658 return false;
1659 }
1660 count += 1;
1661 }
1662 _ => return false,
1663 }
1664 }
1665 match marker {
1666 b'=' => !has_space,
1667 b'-' => !has_space || count >= 3,
1668 b'*' | b'_' => count >= 3,
1669 _ => false,
1670 }
1671}
1672
1673fn starts_block_construct(text: &str) -> bool {
1685 let text = text.trim_start();
1686 let bytes = text.as_bytes();
1687 let Some(&first) = bytes.first() else {
1688 return false;
1689 };
1690 let marker_then_boundary = |len: usize| bytes.len() == len || bytes[len] == b' ' || bytes[len] == b'\t';
1691 match first {
1692 b'>' => true,
1694 b'-' | b'*' | b'+' => marker_then_boundary(1) || is_setext_or_thematic(text),
1695 b'_' | b'=' => is_setext_or_thematic(text),
1696 b':' => is_definition_list_item(text) || text.starts_with(":::"),
1697 b'|' => true,
1698 b'#' => {
1699 let hashes = bytes.iter().take_while(|&&b| b == b'#').count();
1700 hashes <= 6 && marker_then_boundary(hashes)
1701 }
1702 b'`' => bytes.iter().take_while(|&&b| b == b'`').count() >= 3,
1703 b'~' => bytes.iter().take_while(|&&b| b == b'~').count() >= 3,
1704 b'0'..=b'9' => {
1705 let digits = bytes.iter().take_while(|b| b.is_ascii_digit()).count();
1706 digits <= 9
1707 && bytes.len() > digits
1708 && (bytes[digits] == b'.' || bytes[digits] == b')')
1709 && marker_then_boundary(digits + 1)
1710 }
1711 b'[' => {
1719 let mut escaped = false;
1720 let mut label_close = None;
1721 for (i, &b) in bytes.iter().enumerate().skip(1) {
1722 if escaped {
1723 escaped = false;
1724 } else if b == b'\\' {
1725 escaped = true;
1726 } else if b == b']' {
1727 label_close = Some(i);
1728 break;
1729 }
1730 }
1731 label_close.is_some_and(|i| bytes.get(i + 1) == Some(&b':'))
1732 }
1733 b'<' => crate::utils::html_block::parse_html_block_start(text).is_some(),
1736 _ => false,
1737 }
1738}
1739
1740fn merge_block_construct_continuations(lines: Vec<String>) -> Vec<String> {
1749 let mut merged: Vec<String> = Vec::with_capacity(lines.len());
1750 for line in lines {
1751 match merged.last_mut() {
1752 Some(prev) if starts_block_construct(&line) => {
1753 prev.push(' ');
1754 prev.push_str(line.trim_start());
1755 }
1756 _ => merged.push(line),
1757 }
1758 }
1759 merged
1760}
1761
1762fn reflow_elements_sentence_per_line(
1764 elements: &[Element],
1765 custom_abbreviations: &Option<Vec<String>>,
1766 require_sentence_capital: bool,
1767) -> Vec<String> {
1768 let abbreviations = get_abbreviations(custom_abbreviations);
1769 let mut lines = Vec::new();
1770 let mut current_line = String::new();
1771
1772 for (idx, element) in elements.iter().enumerate() {
1773 if let Element::Text(text) = element {
1775 let combined = format!("{current_line}{text}");
1777 let sentences = split_into_sentences_with_set(&combined, &abbreviations, require_sentence_capital);
1779
1780 if sentences.len() > 1 {
1781 for (i, sentence) in sentences.iter().enumerate() {
1783 if i == 0 {
1784 let trimmed = sentence.trim();
1787
1788 if text_ends_with_abbreviation(trimmed, &abbreviations) {
1789 current_line.clone_from(sentence);
1791 } else {
1792 lines.push(sentence.clone());
1794 current_line.clear();
1795 }
1796 } else if i == sentences.len() - 1 {
1797 let trimmed = sentence.trim();
1799 let ends_with_sentence_punct = ends_with_sentence_punct(trimmed);
1800
1801 if ends_with_sentence_punct && !text_ends_with_abbreviation(trimmed, &abbreviations) {
1802 lines.push(sentence.clone());
1804 current_line.clear();
1805 } else {
1806 current_line.clone_from(sentence);
1808 }
1809 } else {
1810 lines.push(sentence.clone());
1812 }
1813 }
1814 } else {
1815 let trimmed = combined.trim();
1817
1818 if trimmed.is_empty() {
1822 continue;
1823 }
1824
1825 let ends_with_sentence_punct = ends_with_sentence_punct(trimmed);
1826
1827 if ends_with_sentence_punct && !text_ends_with_abbreviation(trimmed, &abbreviations) {
1828 lines.push(combined.trim_matches(is_breakable_whitespace).to_string());
1831 current_line.clear();
1832 } else {
1833 current_line = combined;
1835 }
1836 }
1837 } else if let Element::Italic { content, underscore } = element {
1838 let marker = if *underscore { "_" } else { "*" };
1840 handle_emphasis_sentence_split(
1841 content,
1842 marker,
1843 &abbreviations,
1844 require_sentence_capital,
1845 &mut current_line,
1846 &mut lines,
1847 );
1848 } else if let Element::Bold { content, underscore } = element {
1849 let marker = if *underscore { "__" } else { "**" };
1851 handle_emphasis_sentence_split(
1852 content,
1853 marker,
1854 &abbreviations,
1855 require_sentence_capital,
1856 &mut current_line,
1857 &mut lines,
1858 );
1859 } else if let Element::Strikethrough { content, double } = element {
1860 handle_emphasis_sentence_split(
1862 content,
1863 if *double { "~~" } else { "~" },
1864 &abbreviations,
1865 require_sentence_capital,
1866 &mut current_line,
1867 &mut lines,
1868 );
1869 } else {
1870 let element_str = format!("{element}");
1872 let is_adjacent = if idx > 0 {
1876 match &elements[idx - 1] {
1877 Element::Text(t) => !t.is_empty() && !t.ends_with(is_breakable_whitespace),
1878 _ => true,
1879 }
1880 } else {
1881 false
1882 };
1883
1884 if !is_adjacent && should_insert_space_before_join(¤t_line) {
1886 current_line.push(' ');
1887 }
1888 current_line.push_str(&element_str);
1889 }
1890 }
1891
1892 if !current_line.is_empty() {
1894 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
1895 }
1896 lines
1897}
1898
1899fn handle_emphasis_sentence_split(
1901 content: &str,
1902 marker: &str,
1903 abbreviations: &HashSet<String>,
1904 require_sentence_capital: bool,
1905 current_line: &mut String,
1906 lines: &mut Vec<String>,
1907) {
1908 let sentences = split_into_sentences_with_set(content, abbreviations, require_sentence_capital);
1910
1911 if sentences.len() <= 1 {
1912 if should_insert_space_before_join(current_line) {
1914 current_line.push(' ');
1915 }
1916 current_line.push_str(marker);
1917 current_line.push_str(content);
1918 current_line.push_str(marker);
1919
1920 let trimmed = content.trim();
1922 let ends_with_punct = ends_with_sentence_punct(trimmed);
1923 if ends_with_punct && !text_ends_with_abbreviation(trimmed, abbreviations) {
1924 lines.push(current_line.clone());
1925 current_line.clear();
1926 }
1927 } else {
1928 for (i, sentence) in sentences.iter().enumerate() {
1930 let trimmed = sentence.trim();
1931 if trimmed.is_empty() {
1932 continue;
1933 }
1934
1935 if i == 0 {
1936 if should_insert_space_before_join(current_line) {
1938 current_line.push(' ');
1939 }
1940 current_line.push_str(marker);
1941 current_line.push_str(trimmed);
1942 current_line.push_str(marker);
1943
1944 let ends_with_punct = ends_with_sentence_punct(trimmed);
1946 if ends_with_punct && !text_ends_with_abbreviation(trimmed, abbreviations) {
1947 lines.push(current_line.clone());
1948 current_line.clear();
1949 }
1950 } else if i == sentences.len() - 1 {
1951 let ends_with_punct = ends_with_sentence_punct(trimmed);
1953
1954 let mut line = String::new();
1955 line.push_str(marker);
1956 line.push_str(trimmed);
1957 line.push_str(marker);
1958
1959 if ends_with_punct && !text_ends_with_abbreviation(trimmed, abbreviations) {
1960 lines.push(line);
1961 } else {
1962 *current_line = line;
1964 }
1965 } else {
1966 let mut line = String::new();
1968 line.push_str(marker);
1969 line.push_str(trimmed);
1970 line.push_str(marker);
1971 lines.push(line);
1972 }
1973 }
1974 }
1975}
1976
1977const BREAK_WORDS: &[&str] = &[
1981 "and",
1982 "or",
1983 "but",
1984 "nor",
1985 "yet",
1986 "so",
1987 "for",
1988 "which",
1989 "that",
1990 "because",
1991 "when",
1992 "if",
1993 "while",
1994 "where",
1995 "although",
1996 "though",
1997 "unless",
1998 "since",
1999 "after",
2000 "before",
2001 "until",
2002 "as",
2003 "once",
2004 "whether",
2005 "however",
2006 "therefore",
2007 "moreover",
2008 "furthermore",
2009 "nevertheless",
2010 "whereas",
2011];
2012
2013fn is_clause_punctuation(c: char) -> bool {
2015 matches!(c, ',' | ';' | ':' | '\u{2014}') }
2017
2018fn clause_break_allowed_after(chars: &[char], i: usize) -> bool {
2026 if chars[i] == '\u{2014}' {
2027 return true;
2028 }
2029 match chars.get(i + 1) {
2030 None => true,
2031 Some(next) => next.is_whitespace(),
2032 }
2033}
2034
2035fn paren_group_end<'a>(slice: &'a str, element_spans: &[(usize, usize)], offset: usize) -> Option<(usize, &'a str)> {
2049 debug_assert!(slice.starts_with('('));
2050 let mut depth: i32 = 0;
2051 for (local_byte, c) in slice.char_indices() {
2052 let global_byte = offset + local_byte;
2053 if depth > 0 && is_inside_element(global_byte, element_spans) {
2058 continue;
2059 }
2060 match c {
2061 '(' => depth += 1,
2062 ')' => {
2063 depth -= 1;
2064 if depth == 0 {
2065 let end = local_byte + 1;
2066 let inner = &slice[1..local_byte];
2067 return Some((end, inner));
2068 }
2069 }
2070 _ => {}
2071 }
2072 }
2073 None
2074}
2075
2076fn split_at_parenthetical(
2093 text: &str,
2094 line_length: usize,
2095 element_spans: &[(usize, usize)],
2096 length_mode: ReflowLengthMode,
2097) -> Option<(String, String)> {
2098 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
2099
2100 if text.starts_with('(')
2102 && let Some((end_local, inner)) = paren_group_end(text, element_spans, 0)
2103 && inner.contains(' ')
2104 {
2105 let tail = &text[end_local..];
2109 let attached_len = tail
2110 .char_indices()
2111 .take_while(|(_, c)| is_closing_quote(*c) || is_clause_punctuation(*c))
2112 .last()
2113 .map_or(0, |(idx, c)| idx + c.len_utf8());
2114 let first_end = end_local + attached_len;
2115 let rest_start = first_end;
2116 let first = &text[..first_end];
2117 let first_len = display_len(first, length_mode);
2118 if first_len <= line_length {
2121 let rest = text[rest_start..].trim_start();
2122 if !rest.is_empty() {
2123 return Some((first.to_string(), rest.to_string()));
2124 }
2125 }
2126 }
2127
2128 let mut best_open_byte: Option<usize> = None;
2130 let mut pos = 0usize;
2131 while pos < text.len() {
2132 if text.as_bytes()[pos] != b'(' {
2134 let c = text[pos..].chars().next().unwrap();
2135 pos += c.len_utf8();
2136 continue;
2137 }
2138 if is_inside_element(pos, element_spans) {
2140 pos += 1;
2141 continue;
2142 }
2143 if let Some((end_local, inner)) = paren_group_end(&text[pos..], element_spans, pos) {
2144 let first = text[..pos].trim_end();
2145 let first_len = display_len(first, length_mode);
2146 if !first.is_empty()
2147 && first_len >= min_first_len
2148 && first_len <= line_length
2149 && inner.contains(' ')
2150 && best_open_byte.is_none_or(|prev| pos > prev)
2151 {
2152 best_open_byte = Some(pos);
2153 }
2154 pos += end_local;
2155 } else {
2156 pos += 1;
2157 }
2158 }
2159
2160 let open_byte = best_open_byte?;
2161 let first = text[..open_byte].trim_end().to_string();
2162 let rest = text[open_byte..].to_string();
2163 if first.is_empty() || rest.trim().is_empty() {
2164 return None;
2165 }
2166 Some((first, rest))
2167}
2168
2169fn compute_element_spans(elements: &[Element]) -> Vec<(usize, usize)> {
2173 let mut spans = Vec::new();
2174 let mut offset = 0;
2175 for element in elements {
2176 let len = element.display_len(ReflowLengthMode::Bytes);
2177 if !matches!(element, Element::Text(_)) {
2178 spans.push((offset, offset + len));
2179 }
2180 offset += len;
2181 }
2182 spans
2183}
2184
2185fn is_inside_element(pos: usize, spans: &[(usize, usize)]) -> bool {
2187 spans.iter().any(|(start, end)| pos > *start && pos < *end)
2188}
2189
2190const MIN_SPLIT_RATIO: f64 = 0.3;
2193
2194fn split_at_clause_punctuation(
2198 text: &str,
2199 line_length: usize,
2200 element_spans: &[(usize, usize)],
2201 length_mode: ReflowLengthMode,
2202) -> Option<(String, String)> {
2203 let chars: Vec<char> = text.chars().collect();
2204 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
2205
2206 let mut width_acc = 0;
2208 let mut search_end_char = 0;
2209 for (idx, &c) in chars.iter().enumerate() {
2210 let c_width = display_len(&c.to_string(), length_mode);
2211 if width_acc + c_width > line_length {
2212 break;
2213 }
2214 width_acc += c_width;
2215 search_end_char = idx + 1;
2216 }
2217
2218 let mut paren_depth: i32 = 0;
2225 let mut best_pos = None;
2226 for i in (0..search_end_char).rev() {
2227 let byte_start: usize = chars[..i].iter().map(|c| c.len_utf8()).sum();
2229 let byte_after: usize = byte_start + chars[i].len_utf8();
2231
2232 if !is_inside_element(byte_start, element_spans) {
2233 match chars[i] {
2234 ')' => paren_depth += 1,
2235 '(' => paren_depth = paren_depth.saturating_sub(1),
2236 _ => {}
2237 }
2238 }
2239
2240 if paren_depth == 0
2241 && is_clause_punctuation(chars[i])
2242 && clause_break_allowed_after(&chars, i)
2243 && !is_inside_element(byte_after, element_spans)
2244 {
2245 best_pos = Some(i);
2246 break;
2247 }
2248 }
2249
2250 let pos = best_pos?;
2251
2252 let first: String = chars[..=pos].iter().collect();
2254 let first_display_len = display_len(&first, length_mode);
2255 if first_display_len < min_first_len {
2256 return None;
2257 }
2258
2259 let rest: String = chars[pos + 1..].iter().collect();
2261 let rest = rest.trim_start().to_string();
2262
2263 if rest.is_empty() {
2264 return None;
2265 }
2266
2267 Some((first, rest))
2268}
2269
2270fn paren_depth_map(text: &str, element_spans: &[(usize, usize)]) -> Vec<i32> {
2277 let mut map = vec![0i32; text.len()];
2278 let mut depth = 0i32;
2279 for (byte, c) in text.char_indices() {
2280 if !is_inside_element(byte, element_spans) {
2281 match c {
2282 '(' => depth += 1,
2283 ')' => depth = depth.saturating_sub(1),
2284 _ => {}
2285 }
2286 }
2287 let end = (byte + c.len_utf8()).min(map.len());
2289 for slot in &mut map[byte..end] {
2290 *slot = depth;
2291 }
2292 }
2293 map
2294}
2295
2296fn is_standalone_parenthetical(line: &str) -> bool {
2305 let trimmed = line.trim();
2306 if !trimmed.starts_with('(') {
2307 return false;
2308 }
2309 let core = trimmed.trim_end_matches(|c: char| is_clause_punctuation(c));
2311 if !core.ends_with(')') {
2312 return false;
2313 }
2314 let inner = &core[1..core.len() - 1];
2316 if !inner.contains(' ') {
2317 return false;
2318 }
2319 let mut depth = 0i32;
2321 for c in core.chars() {
2322 match c {
2323 '(' => depth += 1,
2324 ')' => depth -= 1,
2325 _ => {}
2326 }
2327 if depth < 0 {
2328 return false;
2329 }
2330 }
2331 depth == 0
2332}
2333
2334fn split_at_break_word(
2338 text: &str,
2339 line_length: usize,
2340 element_spans: &[(usize, usize)],
2341 length_mode: ReflowLengthMode,
2342) -> Option<(String, String)> {
2343 let lower = text.to_lowercase();
2344 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
2345 let mut best_split: Option<(usize, usize)> = None; let depth_map = paren_depth_map(text, element_spans);
2350
2351 for &word in BREAK_WORDS {
2352 let mut search_start = 0;
2353 while let Some(pos) = lower[search_start..].find(word) {
2354 let abs_pos = search_start + pos;
2355
2356 let preceded_by_space = abs_pos == 0 || text.as_bytes().get(abs_pos - 1) == Some(&b' ');
2358 let followed_by_space = text.as_bytes().get(abs_pos + word.len()) == Some(&b' ');
2359
2360 if preceded_by_space && followed_by_space {
2361 let first_part = text[..abs_pos].trim_end();
2363 let first_part_len = display_len(first_part, length_mode);
2364
2365 let inside_paren = depth_map.get(abs_pos).is_some_and(|&d| d > 0);
2367
2368 if first_part_len >= min_first_len
2369 && first_part_len <= line_length
2370 && !is_inside_element(abs_pos, element_spans)
2371 && !inside_paren
2372 {
2373 if best_split.is_none_or(|(prev_pos, _)| abs_pos > prev_pos) {
2375 best_split = Some((abs_pos, word.len()));
2376 }
2377 }
2378 }
2379
2380 search_start = abs_pos + word.len();
2381 }
2382 }
2383
2384 let (byte_start, _word_len) = best_split?;
2385
2386 let first = text[..byte_start].trim_end().to_string();
2387 let rest = text[byte_start..].to_string();
2388
2389 if first.is_empty() || rest.trim().is_empty() {
2390 return None;
2391 }
2392
2393 Some((first, rest))
2394}
2395
2396fn cascade_split_line(text: &str, options: &ReflowOptions) -> Vec<String> {
2407 let line_length = options.line_length;
2408 let length_mode = options.length_mode;
2409 let attr_lists = options.attr_lists;
2410 let myst_roles = options.myst_roles;
2411 let defined_references = options.defined_references.as_ref();
2412 if line_length == 0 || display_len(text, length_mode) <= line_length {
2413 return vec![text.to_string()];
2414 }
2415
2416 let elements = parse_markdown_elements_inner(text, attr_lists, myst_roles, defined_references);
2417 let element_spans = compute_element_spans(&elements);
2418
2419 let rebased_spans = |start: usize| -> Vec<(usize, usize)> {
2423 if start == 0 {
2424 return element_spans.clone();
2425 }
2426 element_spans
2427 .iter()
2428 .filter(|&&(_, end)| end > start)
2429 .map(|&(s, e)| (s.saturating_sub(start), e.saturating_sub(start)))
2430 .collect()
2431 };
2432
2433 let mut result = Vec::new();
2434 let mut start = 0usize;
2435
2436 loop {
2437 let remaining = &text[start..];
2438 if display_len(remaining, length_mode) <= line_length {
2439 result.push(remaining.to_string());
2440 return result;
2441 }
2442
2443 let spans = rebased_spans(start);
2444
2445 let split = split_at_parenthetical(remaining, line_length, &spans, length_mode)
2449 .or_else(|| split_at_clause_punctuation(remaining, line_length, &spans, length_mode))
2450 .or_else(|| split_at_break_word(remaining, line_length, &spans, length_mode));
2451
2452 if let Some((first, rest)) = split {
2453 let consumed = remaining.len().saturating_sub(rest.len());
2454 if consumed == 0 {
2457 break;
2458 }
2459 result.push(first);
2460 start += consumed;
2461 continue;
2462 }
2463
2464 break;
2466 }
2467
2468 let mut fallback_options = options.clone();
2470 fallback_options.break_on_sentences = false;
2471 fallback_options.preserve_breaks = false;
2472 fallback_options.sentence_per_line = false;
2473 fallback_options.semantic_line_breaks = false;
2474 fallback_options.require_sentence_capital = true;
2475 fallback_options.max_list_continuation_indent = None;
2476 fallback_options.defined_references = None;
2477 let remaining = &text[start..];
2478 let tail_elements = if start == 0 {
2479 elements
2480 } else {
2481 parse_markdown_elements_inner(remaining, attr_lists, myst_roles, defined_references)
2482 };
2483 result.extend(reflow_elements(&tail_elements, &fallback_options));
2484 result
2485}
2486
2487fn reflow_elements_semantic(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
2491 let sentence_lines =
2493 reflow_elements_sentence_per_line(elements, &options.abbreviations, options.require_sentence_capital);
2494
2495 if options.line_length == 0 {
2498 return sentence_lines;
2499 }
2500
2501 let length_mode = options.length_mode;
2502 let mut result = Vec::new();
2503 for line in sentence_lines {
2504 if display_len(&line, length_mode) <= options.line_length {
2505 result.push(line);
2506 } else {
2507 result.extend(cascade_split_line(&line, options));
2508 }
2509 }
2510
2511 let min_line_len = ((options.line_length as f64) * MIN_SPLIT_RATIO) as usize;
2514 let mut merged: Vec<String> = Vec::with_capacity(result.len());
2515 for line in result {
2516 if !merged.is_empty() && display_len(&line, length_mode) < min_line_len && !line.trim().is_empty() {
2517 if is_standalone_parenthetical(&line) {
2520 merged.push(line);
2521 continue;
2522 }
2523
2524 let prev_ends_at_sentence = {
2526 let trimmed = merged.last().unwrap().trim_end();
2527 trimmed
2528 .chars()
2529 .rev()
2530 .find(|c| !matches!(c, '"' | '\'' | '\u{201D}' | '\u{2019}' | ')' | ']'))
2531 .is_some_and(|c| matches!(c, '.' | '!' | '?'))
2532 };
2533
2534 if !prev_ends_at_sentence {
2535 let prev = merged.last_mut().unwrap();
2536 let combined = format!("{prev} {line}");
2537 if display_len(&combined, length_mode) <= options.line_length {
2539 *prev = combined;
2540 continue;
2541 }
2542 }
2543 }
2544 merged.push(line);
2545 }
2546 merged
2547}
2548
2549fn rfind_safe_space(line: &str, element_spans: &[(usize, usize)]) -> Option<usize> {
2559 line.char_indices().rev().map(|(pos, _)| pos).find(|&pos| {
2560 line.as_bytes()[pos] == b' '
2561 && !element_spans.iter().any(|(s, e)| pos > *s && pos < *e)
2562 && !starts_block_construct(&line[pos + 1..])
2563 })
2564}
2565
2566fn break_before_attached(
2573 lines: &mut Vec<String>,
2574 current_line: &mut String,
2575 current_length: &mut usize,
2576 element_spans: &mut Vec<(usize, usize)>,
2577 attach: &str,
2578 separator: &str,
2579 length_mode: ReflowLengthMode,
2580) -> Option<usize> {
2581 let last_space = rfind_safe_space(current_line, element_spans)?;
2582 let before = current_line[..last_space]
2583 .trim_end_matches(is_breakable_whitespace)
2584 .to_string();
2585 let after = current_line[last_space + 1..].to_string();
2586 lines.push(before);
2587 let carried = after.len();
2588 *current_line = format!("{after}{separator}{attach}");
2589 *current_length = display_len(current_line, length_mode);
2590 element_spans.clear();
2591 Some(carried)
2592}
2593
2594fn reflow_elements(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
2596 let mut lines = Vec::new();
2597 let mut current_line = String::new();
2598 let mut current_length = 0;
2599 let mut current_line_element_spans: Vec<(usize, usize)> = Vec::new();
2601 let length_mode = options.length_mode;
2602
2603 for (idx, element) in elements.iter().enumerate() {
2604 let element_len = element.display_len(length_mode);
2605
2606 let is_adjacent_to_prev = if idx > 0 {
2615 match (&elements[idx - 1], element) {
2616 (Element::Text(t), _) => !t.is_empty() && !t.ends_with(is_breakable_whitespace),
2617 (_, Element::Text(t)) => !t.is_empty() && !t.starts_with(is_breakable_whitespace),
2618 _ => true,
2619 }
2620 } else {
2621 false
2622 };
2623
2624 if let Element::Text(text) = element {
2626 let has_leading_space = text.starts_with(is_breakable_whitespace);
2628 let words: Vec<&str> = split_breakable_words(text).collect();
2630
2631 for (i, word) in words.iter().enumerate() {
2632 let word_len = display_len(word, length_mode);
2633 let is_trailing_punct = word.chars().all(|c| {
2639 matches!(c, ',' | '.' | ':' | ';' | '!' | '?' | ')' | ']' | '}') || is_non_breaking_space(c)
2640 });
2641
2642 let is_first_adjacent = i == 0 && is_adjacent_to_prev;
2645
2646 if is_first_adjacent {
2647 if current_length + word_len > options.line_length
2649 && current_length > 0
2650 && break_before_attached(
2651 &mut lines,
2652 &mut current_line,
2653 &mut current_length,
2654 &mut current_line_element_spans,
2655 word,
2656 "",
2657 length_mode,
2658 )
2659 .is_some()
2660 {
2661 } else {
2666 current_line.push_str(word);
2667 current_length += word_len;
2668 }
2669 } else if current_length > 0 && current_length + 1 + word_len > options.line_length {
2670 if is_trailing_punct {
2671 if break_before_attached(
2678 &mut lines,
2679 &mut current_line,
2680 &mut current_length,
2681 &mut current_line_element_spans,
2682 word,
2683 " ",
2684 length_mode,
2685 )
2686 .is_none()
2687 {
2688 current_line.push(' ');
2689 current_line.push_str(word);
2690 current_length += 1 + word_len;
2691 }
2692 } else if !starts_block_construct(word) {
2693 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
2695 current_line = word.to_string();
2696 current_length = word_len;
2697 current_line_element_spans.clear();
2698 } else if break_before_attached(
2699 &mut lines,
2700 &mut current_line,
2701 &mut current_length,
2702 &mut current_line_element_spans,
2703 word,
2704 " ",
2705 length_mode,
2706 )
2707 .is_some()
2708 {
2709 } else {
2714 if i > 0 || has_leading_space {
2717 current_line.push(' ');
2718 current_length += 1;
2719 }
2720 current_line.push_str(word);
2721 current_length += word_len;
2722 }
2723 } else {
2724 let add_space = current_length > 0 && (i > 0 || has_leading_space);
2736 if add_space {
2737 current_line.push(' ');
2738 current_length += 1;
2739 }
2740 current_line.push_str(word);
2741 current_length += word_len;
2742 }
2743 }
2744 } else {
2745 let span_info = match element {
2746 Element::Italic { content, underscore } => {
2747 Some((content.as_str(), if *underscore { "_" } else { "*" }, false))
2748 }
2749 Element::Bold { content, underscore } => {
2750 Some((content.as_str(), if *underscore { "__" } else { "**" }, false))
2751 }
2752 Element::Strikethrough { content, double } => {
2753 Some((content.as_str(), if *double { "~~" } else { "~" }, false))
2754 }
2755 Element::Code { content, marker } => Some((content.as_str(), marker.as_str(), true)),
2756 _ => None,
2757 };
2758
2759 let is_eligible = match span_info {
2760 Some((content, _, is_code)) => {
2761 if is_code {
2762 !options.atomic_spans && code_span_wraps_losslessly(content)
2763 } else {
2764 (!options.atomic_spans || element_len > options.line_length)
2765 && !content.contains(['`', '*', '_', '~'])
2766 }
2767 }
2768 None => false,
2769 };
2770
2771 if is_eligible {
2772 let (content, marker, is_code) = span_info.unwrap();
2773 let words: Vec<&str> = split_breakable_words(content).collect();
2774 let n = words.len();
2775 if n == 0 {
2776 let full = format!("{marker}{marker}");
2778 let full_len = display_len(&full, length_mode);
2779 if !is_adjacent_to_prev && current_length > 0 {
2780 current_line.push(' ');
2781 current_length += 1;
2782 }
2783 current_line.push_str(&full);
2784 current_length += full_len;
2785 } else {
2786 for (i, word) in words.iter().enumerate() {
2787 let is_first = i == 0;
2788 let is_last = i == n - 1;
2789
2790 let space_start = if is_first && is_code && word.starts_with('`') {
2791 " "
2792 } else {
2793 ""
2794 };
2795 let space_end = if is_last && is_code && word.ends_with('`') {
2796 " "
2797 } else {
2798 ""
2799 };
2800
2801 let word_str: String = match (is_first, is_last) {
2802 (true, true) => format!("{marker}{space_start}{word}{space_end}{marker}"),
2803 (true, false) => format!("{marker}{space_start}{word}"),
2804 (false, true) => format!("{word}{space_end}{marker}"),
2805 (false, false) => word.to_string(),
2806 };
2807 let word_len = display_len(&word_str, length_mode);
2808
2809 let needs_space = if is_first {
2810 !is_adjacent_to_prev && current_length > 0
2811 } else {
2812 current_length > 0
2813 };
2814
2815 if needs_space
2816 && current_length + 1 + word_len > options.line_length
2817 && !starts_block_construct(&word_str)
2818 {
2819 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
2820 current_line = word_str;
2821 current_length = word_len;
2822 current_line_element_spans.clear();
2823 } else {
2824 if needs_space {
2825 current_line.push(' ');
2826 current_length += 1;
2827 }
2828 current_line.push_str(&word_str);
2829 current_length += word_len;
2830 }
2831 }
2832 }
2833 } else {
2834 let element_str = format!("{element}");
2837
2838 if is_adjacent_to_prev {
2839 if current_length + element_len > options.line_length
2841 && let Some(carried) = break_before_attached(
2842 &mut lines,
2843 &mut current_line,
2844 &mut current_length,
2845 &mut current_line_element_spans,
2846 &element_str,
2847 "",
2848 length_mode,
2849 )
2850 {
2851 current_line_element_spans.push((carried, carried + element_str.len()));
2855 } else {
2856 let start = current_line.len();
2857 current_line.push_str(&element_str);
2858 current_length += element_len;
2859 current_line_element_spans.push((start, current_line.len()));
2860 }
2861 } else if current_length > 0 && current_length + 1 + element_len > options.line_length {
2862 if !starts_block_construct(&element_str) {
2863 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
2865 current_line.clone_from(&element_str);
2866 current_length = element_len;
2867 current_line_element_spans.clear();
2868 current_line_element_spans.push((0, element_str.len()));
2869 } else if let Some(carried) = break_before_attached(
2870 &mut lines,
2871 &mut current_line,
2872 &mut current_length,
2873 &mut current_line_element_spans,
2874 &element_str,
2875 " ",
2876 length_mode,
2877 ) {
2878 let start = carried + 1;
2882 current_line_element_spans.push((start, start + element_str.len()));
2883 } else {
2884 let ends_with_opener =
2887 current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
2888 if !ends_with_opener {
2889 current_line.push(' ');
2890 current_length += 1;
2891 }
2892 let start = current_line.len();
2893 current_line.push_str(&element_str);
2894 current_length += element_len;
2895 current_line_element_spans.push((start, current_line.len()));
2896 }
2897 } else {
2898 let ends_with_opener =
2900 current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
2901 if current_length > 0 && !ends_with_opener {
2902 current_line.push(' ');
2903 current_length += 1;
2904 }
2905 let start = current_line.len();
2906 current_line.push_str(&element_str);
2907 current_length += element_len;
2908 current_line_element_spans.push((start, current_line.len()));
2909 }
2910 }
2911 }
2912 }
2913
2914 if !current_line.is_empty() {
2916 lines.push(current_line.trim_end_matches(is_breakable_whitespace).to_string());
2917 }
2918
2919 lines
2920}
2921
2922pub fn reflow_markdown(content: &str, options: &ReflowOptions) -> String {
2924 let lines: Vec<&str> = content.lines().collect();
2925 let mut result = Vec::new();
2926 let mut i = 0;
2927
2928 while i < lines.len() {
2929 let line = lines[i];
2930 let trimmed = line.trim();
2931
2932 if trimmed.is_empty() {
2934 result.push(String::new());
2935 i += 1;
2936 continue;
2937 }
2938
2939 if trimmed.starts_with('#') {
2941 result.push(line.to_string());
2942 i += 1;
2943 continue;
2944 }
2945
2946 if trimmed.starts_with(":::") {
2948 result.push(line.to_string());
2949 i += 1;
2950 continue;
2951 }
2952
2953 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
2955 result.push(line.to_string());
2956 i += 1;
2957 while i < lines.len() {
2959 result.push(lines[i].to_string());
2960 if lines[i].trim().starts_with("```") || lines[i].trim().starts_with("~~~") {
2961 i += 1;
2962 break;
2963 }
2964 i += 1;
2965 }
2966 continue;
2967 }
2968
2969 if calculate_indentation_width_default(line) >= 4 {
2971 result.push(line.to_string());
2973 i += 1;
2974 while i < lines.len() {
2975 let next_line = lines[i];
2976 if calculate_indentation_width_default(next_line) >= 4 || next_line.trim().is_empty() {
2978 result.push(next_line.to_string());
2979 i += 1;
2980 } else {
2981 break;
2982 }
2983 }
2984 continue;
2985 }
2986
2987 if trimmed.starts_with('>') {
2989 let gt_pos = line.find('>').expect("'>' must exist since trimmed.starts_with('>')");
2992 let quote_prefix = line[0..=gt_pos].to_string();
2993 let quote_content = &line[quote_prefix.len()..].trim_start();
2994
2995 let reflowed = reflow_line(quote_content, options);
2996 for reflowed_line in &reflowed {
2997 result.push(format!("{quote_prefix} {reflowed_line}"));
2998 }
2999 i += 1;
3000 continue;
3001 }
3002
3003 if is_horizontal_rule(trimmed) {
3005 result.push(line.to_string());
3006 i += 1;
3007 continue;
3008 }
3009
3010 if is_unordered_list_marker(trimmed) || is_numbered_list_item(trimmed) {
3012 let indent = line.len() - line.trim_start().len();
3014 let indent_str = " ".repeat(indent);
3015
3016 let mut marker_end = indent;
3019 let mut content_start = indent;
3020
3021 if trimmed.chars().next().is_some_and(char::is_numeric) {
3022 if let Some(period_pos) = line[indent..].find('.') {
3024 marker_end = indent + period_pos + 1; content_start = marker_end;
3026 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
3030 content_start += 1;
3031 }
3032 }
3033 } else {
3034 marker_end = indent + 1; content_start = marker_end;
3037 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
3041 content_start += 1;
3042 }
3043 }
3044
3045 let min_continuation_indent = content_start;
3047
3048 let rest = &line[content_start..];
3051 if rest.starts_with("[ ] ") || rest.starts_with("[x] ") || rest.starts_with("[X] ") {
3052 marker_end = content_start + 3; content_start += 4; }
3055
3056 let marker = &line[indent..marker_end];
3057
3058 let mut list_content = vec![trim_preserving_hard_break(&line[content_start..])];
3061 i += 1;
3062
3063 while i < lines.len() {
3067 let next_line = lines[i];
3068 let next_trimmed = next_line.trim();
3069
3070 if is_block_boundary(next_trimmed) {
3072 break;
3073 }
3074
3075 let next_indent = next_line.len() - next_line.trim_start().len();
3077 if next_indent >= min_continuation_indent {
3078 let trimmed_start = next_line.trim_start();
3081 list_content.push(trim_preserving_hard_break(trimmed_start));
3082 i += 1;
3083 } else {
3084 break;
3086 }
3087 }
3088
3089 let combined_content = if options.preserve_breaks {
3092 list_content[0].clone()
3093 } else {
3094 let has_hard_breaks = list_content.iter().any(|line| has_hard_break(line));
3096 if has_hard_breaks {
3097 list_content.join("\n")
3099 } else {
3100 list_content.join(" ")
3102 }
3103 };
3104
3105 let trimmed_marker = marker;
3107 let continuation_spaces = if let Some(max_indent) = options.max_list_continuation_indent {
3108 indent + (content_start - indent).min(max_indent)
3111 } else {
3112 content_start
3113 };
3114
3115 let prefix_length = indent + trimmed_marker.len() + 1;
3117
3118 let adjusted_options = ReflowOptions {
3120 line_length: options.line_length.saturating_sub(prefix_length),
3121 ..options.clone()
3122 };
3123
3124 let reflowed = reflow_line(&combined_content, &adjusted_options);
3125 for (j, reflowed_line) in reflowed.iter().enumerate() {
3126 if j == 0 {
3127 result.push(format!("{indent_str}{trimmed_marker} {reflowed_line}"));
3128 } else {
3129 let continuation_indent = " ".repeat(continuation_spaces);
3131 result.push(format!("{continuation_indent}{reflowed_line}"));
3132 }
3133 }
3134 continue;
3135 }
3136
3137 if crate::utils::table_utils::TableUtils::is_potential_table_row(line) {
3139 result.push(line.to_string());
3140 i += 1;
3141 continue;
3142 }
3143
3144 if trimmed.starts_with('[') && line.contains("]:") {
3146 result.push(line.to_string());
3147 i += 1;
3148 continue;
3149 }
3150
3151 if is_definition_list_item(trimmed) {
3153 result.push(line.to_string());
3154 i += 1;
3155 continue;
3156 }
3157
3158 let mut is_single_line_paragraph = true;
3160 if i + 1 < lines.len() {
3161 let next_trimmed = lines[i + 1].trim();
3162 if !is_block_boundary(next_trimmed) {
3164 is_single_line_paragraph = false;
3165 }
3166 }
3167
3168 if is_single_line_paragraph && display_len(line, options.length_mode) <= options.line_length {
3170 result.push(line.to_string());
3171 i += 1;
3172 continue;
3173 }
3174
3175 let mut paragraph_parts = Vec::new();
3177 let mut current_part = vec![line];
3178 i += 1;
3179
3180 if options.preserve_breaks {
3182 let hard_break_type = if line.strip_suffix('\r').unwrap_or(line).ends_with('\\') {
3184 Some("\\")
3185 } else if line.ends_with(" ") {
3186 Some(" ")
3187 } else {
3188 None
3189 };
3190 let reflowed = reflow_line(line, options);
3191
3192 if let Some(break_marker) = hard_break_type {
3194 if !reflowed.is_empty() {
3195 let mut reflowed_with_break = reflowed;
3196 let last_idx = reflowed_with_break.len() - 1;
3197 if !has_hard_break(&reflowed_with_break[last_idx]) {
3198 reflowed_with_break[last_idx].push_str(break_marker);
3199 }
3200 result.extend(reflowed_with_break);
3201 }
3202 } else {
3203 result.extend(reflowed);
3204 }
3205 } else {
3206 while i < lines.len() {
3208 let prev_line = if !current_part.is_empty() {
3209 current_part.last().unwrap()
3210 } else {
3211 ""
3212 };
3213 let next_line = lines[i];
3214 let next_trimmed = next_line.trim();
3215
3216 if is_block_boundary(next_trimmed) {
3218 break;
3219 }
3220
3221 let prev_trimmed = prev_line.trim();
3224 let abbreviations = get_abbreviations(&options.abbreviations);
3225 let ends_with_sentence = (prev_trimmed.ends_with('.')
3226 || prev_trimmed.ends_with('!')
3227 || prev_trimmed.ends_with('?')
3228 || prev_trimmed.ends_with(".*")
3229 || prev_trimmed.ends_with("!*")
3230 || prev_trimmed.ends_with("?*")
3231 || prev_trimmed.ends_with("._")
3232 || prev_trimmed.ends_with("!_")
3233 || prev_trimmed.ends_with("?_")
3234 || prev_trimmed.ends_with(".\"")
3236 || prev_trimmed.ends_with("!\"")
3237 || prev_trimmed.ends_with("?\"")
3238 || prev_trimmed.ends_with(".'")
3239 || prev_trimmed.ends_with("!'")
3240 || prev_trimmed.ends_with("?'")
3241 || prev_trimmed.ends_with(".\u{201D}")
3242 || prev_trimmed.ends_with("!\u{201D}")
3243 || prev_trimmed.ends_with("?\u{201D}")
3244 || prev_trimmed.ends_with(".\u{2019}")
3245 || prev_trimmed.ends_with("!\u{2019}")
3246 || prev_trimmed.ends_with("?\u{2019}"))
3247 && !text_ends_with_abbreviation(
3248 prev_trimmed.trim_end_matches(['*', '_', '"', '\'', '\u{201D}', '\u{2019}']),
3249 &abbreviations,
3250 );
3251
3252 if has_hard_break(prev_line) || (options.sentence_per_line && ends_with_sentence) {
3253 paragraph_parts.push(current_part.join(" "));
3255 current_part = vec![next_line];
3256 } else {
3257 current_part.push(next_line);
3258 }
3259 i += 1;
3260 }
3261
3262 if !current_part.is_empty() {
3264 if current_part.len() == 1 {
3265 paragraph_parts.push(current_part[0].to_string());
3267 } else {
3268 paragraph_parts.push(current_part.join(" "));
3269 }
3270 }
3271
3272 for (j, part) in paragraph_parts.iter().enumerate() {
3274 let reflowed = reflow_line(part, options);
3275 result.extend(reflowed);
3276
3277 if j < paragraph_parts.len() - 1 && !result.is_empty() && !options.sentence_per_line {
3281 let last_idx = result.len() - 1;
3282 if !has_hard_break(&result[last_idx]) {
3283 result[last_idx].push_str(" ");
3284 }
3285 }
3286 }
3287 }
3288 }
3289
3290 let result_text = result.join("\n");
3292 if content.ends_with('\n') && !result_text.ends_with('\n') {
3293 format!("{result_text}\n")
3294 } else {
3295 result_text
3296 }
3297}
3298
3299#[derive(Debug, Clone)]
3301pub struct ParagraphReflow {
3302 pub start_byte: usize,
3304 pub end_byte: usize,
3306 pub reflowed_text: String,
3308}
3309
3310#[derive(Debug, Clone)]
3316pub struct BlockquoteLineData {
3317 pub(crate) content: String,
3319 pub(crate) is_explicit: bool,
3321 pub(crate) prefix: Option<String>,
3323}
3324
3325impl BlockquoteLineData {
3326 pub fn explicit(content: String, prefix: String) -> Self {
3328 Self {
3329 content,
3330 is_explicit: true,
3331 prefix: Some(prefix),
3332 }
3333 }
3334
3335 pub fn lazy(content: String) -> Self {
3337 Self {
3338 content,
3339 is_explicit: false,
3340 prefix: None,
3341 }
3342 }
3343}
3344
3345#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3347pub enum BlockquoteContinuationStyle {
3348 Explicit,
3349 Lazy,
3350}
3351
3352pub fn blockquote_continuation_style(lines: &[BlockquoteLineData]) -> BlockquoteContinuationStyle {
3360 let mut explicit_count = 0usize;
3361 let mut lazy_count = 0usize;
3362
3363 for line in lines.iter().skip(1) {
3364 if line.is_explicit {
3365 explicit_count += 1;
3366 } else {
3367 lazy_count += 1;
3368 }
3369 }
3370
3371 if explicit_count > 0 && lazy_count == 0 {
3372 BlockquoteContinuationStyle::Explicit
3373 } else if lazy_count > 0 && explicit_count == 0 {
3374 BlockquoteContinuationStyle::Lazy
3375 } else if explicit_count >= lazy_count {
3376 BlockquoteContinuationStyle::Explicit
3377 } else {
3378 BlockquoteContinuationStyle::Lazy
3379 }
3380}
3381
3382pub fn dominant_blockquote_prefix(lines: &[BlockquoteLineData], fallback: &str) -> String {
3387 let mut counts: std::collections::HashMap<String, (usize, usize)> = std::collections::HashMap::new();
3388
3389 for (idx, line) in lines.iter().enumerate() {
3390 let Some(prefix) = line.prefix.as_ref() else {
3391 continue;
3392 };
3393 counts
3394 .entry(prefix.clone())
3395 .and_modify(|entry| entry.0 += 1)
3396 .or_insert((1, idx));
3397 }
3398
3399 counts
3400 .into_iter()
3401 .max_by(|(_, (count_a, first_idx_a)), (_, (count_b, first_idx_b))| {
3402 count_a.cmp(count_b).then_with(|| first_idx_b.cmp(first_idx_a))
3403 })
3404 .map_or_else(|| fallback.to_string(), |(prefix, _)| prefix)
3405}
3406
3407pub(crate) fn should_force_explicit_blockquote_line(content_line: &str) -> bool {
3412 let trimmed = content_line.trim_start();
3413 trimmed.starts_with('>')
3414 || trimmed.starts_with('#')
3415 || trimmed.starts_with("```")
3416 || trimmed.starts_with("~~~")
3417 || is_unordered_list_marker(trimmed)
3418 || is_numbered_list_item(trimmed)
3419 || is_horizontal_rule(trimmed)
3420 || is_definition_list_item(trimmed)
3421 || (trimmed.starts_with('[') && trimmed.contains("]:"))
3422 || trimmed.starts_with(":::")
3423 || (trimmed.starts_with('<')
3424 && !trimmed.starts_with("<http")
3425 && !trimmed.starts_with("<https")
3426 && !trimmed.starts_with("<mailto:"))
3427}
3428
3429pub fn reflow_blockquote_content(
3438 lines: &[BlockquoteLineData],
3439 explicit_prefix: &str,
3440 continuation_style: BlockquoteContinuationStyle,
3441 options: &ReflowOptions,
3442) -> Vec<String> {
3443 let content_strs: Vec<&str> = lines.iter().map(|l| l.content.as_str()).collect();
3444 let segments = split_into_segments_strs(&content_strs);
3445 let mut reflowed_content_lines: Vec<String> = Vec::new();
3446
3447 for segment in segments {
3448 let hard_break_type = segment.last().and_then(|&line| {
3449 let line = line.strip_suffix('\r').unwrap_or(line);
3450 if line.ends_with('\\') {
3451 Some("\\")
3452 } else if line.ends_with(" ") {
3453 Some(" ")
3454 } else {
3455 None
3456 }
3457 });
3458
3459 let pieces: Vec<&str> = segment
3460 .iter()
3461 .map(|&line| {
3462 if let Some(l) = line.strip_suffix('\\') {
3463 l.trim_end()
3464 } else if let Some(l) = line.strip_suffix(" ") {
3465 l.trim_end()
3466 } else {
3467 line.trim_end()
3468 }
3469 })
3470 .collect();
3471
3472 let segment_text = pieces.join(" ");
3473 let segment_text = segment_text.trim();
3474 if segment_text.is_empty() {
3475 continue;
3476 }
3477
3478 let mut reflowed = reflow_line(segment_text, options);
3479 if let Some(break_marker) = hard_break_type
3480 && !reflowed.is_empty()
3481 {
3482 let last_idx = reflowed.len() - 1;
3483 if !has_hard_break(&reflowed[last_idx]) {
3484 reflowed[last_idx].push_str(break_marker);
3485 }
3486 }
3487 reflowed_content_lines.extend(reflowed);
3488 }
3489
3490 let mut styled_lines: Vec<String> = Vec::new();
3491 for (idx, line) in reflowed_content_lines.iter().enumerate() {
3492 let force_explicit = idx == 0
3493 || continuation_style == BlockquoteContinuationStyle::Explicit
3494 || should_force_explicit_blockquote_line(line);
3495 if force_explicit {
3496 styled_lines.push(format!("{explicit_prefix}{line}"));
3497 } else {
3498 styled_lines.push(line.clone());
3499 }
3500 }
3501
3502 styled_lines
3503}
3504
3505fn is_blockquote_content_boundary(content: &str) -> bool {
3506 let trimmed = content.trim();
3507 trimmed.is_empty()
3508 || is_block_boundary(trimmed)
3509 || crate::utils::table_utils::TableUtils::is_potential_table_row(content)
3510 || trimmed.starts_with(":::")
3511 || crate::utils::is_template_directive_only(content)
3512 || is_standalone_attr_list(content)
3513 || is_snippet_block_delimiter(content)
3514}
3515
3516fn split_into_segments_strs<'a>(lines: &[&'a str]) -> Vec<Vec<&'a str>> {
3517 let mut segments = Vec::new();
3518 let mut current = Vec::new();
3519
3520 for &line in lines {
3521 current.push(line);
3522 if has_hard_break(line) {
3523 segments.push(current);
3524 current = Vec::new();
3525 }
3526 }
3527
3528 if !current.is_empty() {
3529 segments.push(current);
3530 }
3531
3532 segments
3533}
3534
3535fn reflow_blockquote_paragraph_at_line(
3536 content: &str,
3537 lines: &[&str],
3538 target_idx: usize,
3539 options: &ReflowOptions,
3540) -> Option<ParagraphReflow> {
3541 let mut anchor_idx = target_idx;
3542 let mut target_level = if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[target_idx]) {
3543 parsed.nesting_level
3544 } else {
3545 let mut found = None;
3546 let mut idx = target_idx;
3547 loop {
3548 if lines[idx].trim().is_empty() {
3549 break;
3550 }
3551 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[idx]) {
3552 found = Some((idx, parsed.nesting_level));
3553 break;
3554 }
3555 if idx == 0 {
3556 break;
3557 }
3558 idx -= 1;
3559 }
3560 let (idx, level) = found?;
3561 anchor_idx = idx;
3562 level
3563 };
3564
3565 let mut para_start = anchor_idx;
3567 while para_start > 0 {
3568 let prev_idx = para_start - 1;
3569 let prev_line = lines[prev_idx];
3570
3571 if prev_line.trim().is_empty() {
3572 break;
3573 }
3574
3575 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(prev_line) {
3576 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
3577 break;
3578 }
3579 para_start = prev_idx;
3580 continue;
3581 }
3582
3583 let prev_lazy = prev_line.trim_start();
3584 if is_blockquote_content_boundary(prev_lazy) {
3585 break;
3586 }
3587 para_start = prev_idx;
3588 }
3589
3590 while para_start < lines.len() {
3592 let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[para_start]) else {
3593 para_start += 1;
3594 continue;
3595 };
3596 target_level = parsed.nesting_level;
3597 break;
3598 }
3599
3600 if para_start >= lines.len() || para_start > target_idx {
3601 return None;
3602 }
3603
3604 let mut collected: Vec<(usize, BlockquoteLineData)> = Vec::new();
3607 let mut idx = para_start;
3608 while idx < lines.len() {
3609 if !collected.is_empty() && has_hard_break(&collected[collected.len() - 1].1.content) {
3610 break;
3611 }
3612
3613 let line = lines[idx];
3614 if line.trim().is_empty() {
3615 break;
3616 }
3617
3618 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(line) {
3619 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
3620 break;
3621 }
3622 collected.push((
3623 idx,
3624 BlockquoteLineData::explicit(trim_preserving_hard_break(parsed.content), parsed.prefix.to_string()),
3625 ));
3626 idx += 1;
3627 continue;
3628 }
3629
3630 let lazy_content = line.trim_start();
3631 if is_blockquote_content_boundary(lazy_content) {
3632 break;
3633 }
3634
3635 collected.push((idx, BlockquoteLineData::lazy(trim_preserving_hard_break(lazy_content))));
3636 idx += 1;
3637 }
3638
3639 if collected.is_empty() {
3640 return None;
3641 }
3642
3643 let para_end = collected[collected.len() - 1].0;
3644 if target_idx < para_start || target_idx > para_end {
3645 return None;
3646 }
3647
3648 let line_data: Vec<BlockquoteLineData> = collected.iter().map(|(_, d)| d.clone()).collect();
3649
3650 let fallback_prefix = line_data
3651 .iter()
3652 .find_map(|d| d.prefix.clone())
3653 .unwrap_or_else(|| "> ".to_string());
3654 let explicit_prefix = dominant_blockquote_prefix(&line_data, &fallback_prefix);
3655 let continuation_style = blockquote_continuation_style(&line_data);
3656
3657 let adjusted_line_length = options
3658 .line_length
3659 .saturating_sub(display_len(&explicit_prefix, options.length_mode))
3660 .max(1);
3661
3662 let adjusted_options = ReflowOptions {
3663 line_length: adjusted_line_length,
3664 ..options.clone()
3665 };
3666
3667 let styled_lines = reflow_blockquote_content(&line_data, &explicit_prefix, continuation_style, &adjusted_options);
3668
3669 if styled_lines.is_empty() {
3670 return None;
3671 }
3672
3673 let mut start_byte = 0;
3675 for line in lines.iter().take(para_start) {
3676 start_byte += line.len() + 1;
3677 }
3678
3679 let mut end_byte = start_byte;
3680 for line in lines.iter().take(para_end + 1).skip(para_start) {
3681 end_byte += line.len() + 1;
3682 }
3683
3684 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
3685 if !includes_trailing_newline {
3686 end_byte -= 1;
3687 }
3688
3689 let reflowed_joined = styled_lines.join("\n");
3690 let reflowed_text = if includes_trailing_newline {
3691 if reflowed_joined.ends_with('\n') {
3692 reflowed_joined
3693 } else {
3694 format!("{reflowed_joined}\n")
3695 }
3696 } else if reflowed_joined.ends_with('\n') {
3697 reflowed_joined.trim_end_matches('\n').to_string()
3698 } else {
3699 reflowed_joined
3700 };
3701
3702 Some(ParagraphReflow {
3703 start_byte,
3704 end_byte,
3705 reflowed_text,
3706 })
3707}
3708
3709pub fn reflow_paragraph_at_line(content: &str, line_number: usize, line_length: usize) -> Option<ParagraphReflow> {
3727 reflow_paragraph_at_line_with_mode(content, line_number, line_length, ReflowLengthMode::default())
3728}
3729
3730pub fn reflow_paragraph_at_line_with_mode(
3732 content: &str,
3733 line_number: usize,
3734 line_length: usize,
3735 length_mode: ReflowLengthMode,
3736) -> Option<ParagraphReflow> {
3737 let options = ReflowOptions {
3738 line_length,
3739 length_mode,
3740 ..Default::default()
3741 };
3742 reflow_paragraph_at_line_with_options(content, line_number, &options)
3743}
3744
3745pub fn reflow_paragraph_at_line_with_options(
3756 content: &str,
3757 line_number: usize,
3758 options: &ReflowOptions,
3759) -> Option<ParagraphReflow> {
3760 if line_number == 0 {
3761 return None;
3762 }
3763
3764 let lines: Vec<&str> = content.lines().collect();
3765
3766 if line_number > lines.len() {
3768 return None;
3769 }
3770
3771 let target_idx = line_number - 1; let target_line = lines[target_idx];
3773 let trimmed = target_line.trim();
3774
3775 if let Some(blockquote_reflow) = reflow_blockquote_paragraph_at_line(content, &lines, target_idx, options) {
3778 return Some(blockquote_reflow);
3779 }
3780
3781 if is_paragraph_boundary(trimmed, target_line) {
3783 return None;
3784 }
3785
3786 let mut para_start = target_idx;
3788 while para_start > 0 {
3789 let prev_idx = para_start - 1;
3790 let prev_line = lines[prev_idx];
3791 let prev_trimmed = prev_line.trim();
3792
3793 if is_paragraph_boundary(prev_trimmed, prev_line) {
3795 break;
3796 }
3797
3798 para_start = prev_idx;
3799 }
3800
3801 let mut para_end = target_idx;
3803 while para_end + 1 < lines.len() {
3804 let next_idx = para_end + 1;
3805 let next_line = lines[next_idx];
3806 let next_trimmed = next_line.trim();
3807
3808 if is_paragraph_boundary(next_trimmed, next_line) {
3810 break;
3811 }
3812
3813 para_end = next_idx;
3814 }
3815
3816 let paragraph_lines = &lines[para_start..=para_end];
3818
3819 let mut start_byte = 0;
3821 for line in lines.iter().take(para_start) {
3822 start_byte += line.len() + 1; }
3824
3825 let mut end_byte = start_byte;
3826 for line in paragraph_lines {
3827 end_byte += line.len() + 1; }
3829
3830 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
3833
3834 if !includes_trailing_newline {
3836 end_byte -= 1;
3837 }
3838
3839 let paragraph_text = paragraph_lines.join("\n");
3841
3842 let reflowed = reflow_markdown(¶graph_text, options);
3844
3845 let reflowed_text = if includes_trailing_newline {
3849 if reflowed.ends_with('\n') {
3851 reflowed
3852 } else {
3853 format!("{reflowed}\n")
3854 }
3855 } else {
3856 if reflowed.ends_with('\n') {
3858 reflowed.trim_end_matches('\n').to_string()
3859 } else {
3860 reflowed
3861 }
3862 };
3863
3864 Some(ParagraphReflow {
3865 start_byte,
3866 end_byte,
3867 reflowed_text,
3868 })
3869}
3870fn decompose_code_span(raw: &str) -> Option<(&str, &str)> {
3876 let marker_len = raw.bytes().take_while(|&b| b == b'`').count();
3877 if marker_len == 0 {
3878 return None;
3879 }
3880 let marker = &raw[..marker_len];
3881 if raw.len() < marker_len * 2 {
3882 return None;
3883 }
3884 let content = &raw[marker_len..raw.len() - marker_len];
3885 Some((content, marker))
3886}
3887
3888#[cfg(test)]
3889mod tests {
3890 use super::*;
3891
3892 #[test]
3893 fn cascade_split_line_handles_a_very_long_line_without_overflowing() {
3894 let words: Vec<String> = (0..4000).map(|i| format!("word{i}")).collect();
3900 let line = words.join(" ");
3901
3902 let options = ReflowOptions {
3903 line_length: 80,
3904 length_mode: ReflowLengthMode::Chars,
3905 ..Default::default()
3906 };
3907 let out = cascade_split_line(&line, &options);
3908
3909 assert!(out.len() > 1, "a very long line should split into many lines");
3910 for segment in &out {
3911 assert!(
3912 display_len(segment, ReflowLengthMode::Chars) <= 80 || !segment.contains(' '),
3913 "each wrapped line should fit the width (or be a single unbreakable token)"
3914 );
3915 }
3916 let rejoined = out.join(" ");
3918 let original_words: Vec<&str> = line.split(' ').collect();
3919 let result_words: Vec<&str> = rejoined.split_whitespace().collect();
3920 assert_eq!(original_words, result_words, "reflow must preserve all words in order");
3921 }
3922
3923 #[test]
3928 fn test_helper_function_text_ends_with_abbreviation() {
3929 let abbreviations = get_abbreviations(&None);
3931
3932 assert!(text_ends_with_abbreviation("Dr.", &abbreviations));
3934 assert!(text_ends_with_abbreviation("word Dr.", &abbreviations));
3935 assert!(text_ends_with_abbreviation("e.g.", &abbreviations));
3936 assert!(text_ends_with_abbreviation("i.e.", &abbreviations));
3937 assert!(text_ends_with_abbreviation("Mr.", &abbreviations));
3938 assert!(text_ends_with_abbreviation("Mrs.", &abbreviations));
3939 assert!(text_ends_with_abbreviation("Ms.", &abbreviations));
3940 assert!(text_ends_with_abbreviation("Prof.", &abbreviations));
3941
3942 assert!(!text_ends_with_abbreviation("etc.", &abbreviations));
3944 assert!(!text_ends_with_abbreviation("paradigms.", &abbreviations));
3945 assert!(!text_ends_with_abbreviation("programs.", &abbreviations));
3946 assert!(!text_ends_with_abbreviation("items.", &abbreviations));
3947 assert!(!text_ends_with_abbreviation("systems.", &abbreviations));
3948 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)); }
3954
3955 #[test]
3956 fn test_footnote_after_period_splits_sentence() {
3957 let text = "First sentence.[^1] Second sentence.";
3961 let sentences = split_into_sentences(text);
3962 assert_eq!(
3963 sentences,
3964 vec!["First sentence.[^1]".to_string(), "Second sentence.".to_string()],
3965 "footnote glued to the period should keep the boundary and stay attached to the first sentence"
3966 );
3967 }
3968
3969 #[test]
3970 fn test_multiple_consecutive_footnotes_after_period_splits_sentence() {
3971 let text = "Notes here.[^1][^2] Second sentence.";
3973 let sentences = split_into_sentences(text);
3974 assert_eq!(
3975 sentences,
3976 vec!["Notes here.[^1][^2]".to_string(), "Second sentence.".to_string()]
3977 );
3978 }
3979
3980 #[test]
3981 fn test_footnote_before_period_still_splits_sentence() {
3982 let text = "Annotation here[^1]. Second sentence.";
3986 let sentences = split_into_sentences(text);
3987 assert_eq!(
3988 sentences,
3989 vec!["Annotation here[^1].".to_string(), "Second sentence.".to_string()]
3990 );
3991 }
3992
3993 #[test]
3994 fn test_mid_sentence_footnote_does_not_split() {
3995 let text = "The system word[^1] more words. Next sentence.";
3998 let sentences = split_into_sentences(text);
3999 assert_eq!(
4000 sentences,
4001 vec![
4002 "The system word[^1] more words.".to_string(),
4003 "Next sentence.".to_string()
4004 ]
4005 );
4006 }
4007
4008 #[test]
4009 fn test_bare_numeric_bracket_after_period_does_not_split() {
4010 let text = "Citation here.[1] Second sentence.";
4013 let sentences = split_into_sentences(text);
4014 assert_eq!(
4015 sentences,
4016 vec![text.to_string()],
4017 "a bare numeric bracket must not be treated as a sentence boundary"
4018 );
4019 }
4020
4021 #[test]
4022 fn test_footnote_glued_to_following_word_does_not_split() {
4023 let text = "First sentence.[^1]Continued glued text.";
4026 let sentences = split_into_sentences(text);
4027 assert_eq!(sentences, vec![text.to_string()]);
4028 }
4029
4030 #[test]
4031 fn test_footnote_at_end_of_text_is_preserved() {
4032 let text = "Sentence.[^1]";
4035 let sentences = split_into_sentences(text);
4036 assert_eq!(sentences, vec![text.to_string()]);
4037 }
4038
4039 #[test]
4040 fn test_abbreviation_before_footnote_does_not_split() {
4041 let text = "See the notes, e.g.[^1] this one.";
4044 let sentences = split_into_sentences(text);
4045 assert_eq!(
4046 sentences,
4047 vec![text.to_string()],
4048 "e.g. is an abbreviation, not a sentence boundary"
4049 );
4050 }
4051
4052 #[test]
4053 fn test_is_unordered_list_marker() {
4054 assert!(is_unordered_list_marker("- item"));
4056 assert!(is_unordered_list_marker("* item"));
4057 assert!(is_unordered_list_marker("+ item"));
4058 assert!(is_unordered_list_marker("-")); assert!(is_unordered_list_marker("*"));
4060 assert!(is_unordered_list_marker("+"));
4061
4062 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")); }
4073
4074 #[test]
4075 fn test_is_block_boundary() {
4076 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"));
4098 assert!(!is_block_boundary("*emphasis*")); assert!(!is_block_boundary("[link](url)")); assert!(!is_block_boundary("some words here"));
4101 }
4102
4103 #[test]
4104 fn test_definition_list_boundary_in_single_line_paragraph() {
4105 let options = ReflowOptions {
4108 line_length: 80,
4109 ..Default::default()
4110 };
4111 let input = "Term\n: Definition of the term";
4112 let result = reflow_markdown(input, &options);
4113 assert!(
4115 result.contains(": Definition"),
4116 "Definition list item should not be merged into previous line. Got: {result:?}"
4117 );
4118 let lines: Vec<&str> = result.lines().collect();
4119 assert_eq!(lines.len(), 2, "Should remain two separate lines. Got: {lines:?}");
4120 assert_eq!(lines[0], "Term");
4121 assert_eq!(lines[1], ": Definition of the term");
4122 }
4123
4124 #[test]
4125 fn test_is_paragraph_boundary() {
4126 assert!(is_paragraph_boundary("# Heading", "# Heading"));
4128 assert!(is_paragraph_boundary("- item", "- item"));
4129 assert!(is_paragraph_boundary(":::", ":::"));
4130 assert!(is_paragraph_boundary(": definition", ": definition"));
4131
4132 assert!(is_paragraph_boundary("code", " code"));
4134 assert!(is_paragraph_boundary("code", "\tcode"));
4135
4136 assert!(is_paragraph_boundary("| a | b |", "| a | b |"));
4138 assert!(is_paragraph_boundary("a | b", "a | b")); assert!(!is_paragraph_boundary("regular text", "regular text"));
4142 assert!(!is_paragraph_boundary("text", " text")); }
4144
4145 #[test]
4146 fn test_div_marker_boundary_in_reflow_paragraph_at_line() {
4147 let content = "Some paragraph text here.\n\n::: {.callout-note}\nThis is a callout.\n:::\n";
4150 let result = reflow_paragraph_at_line(content, 3, 80);
4152 assert!(result.is_none(), "Div marker line should not be reflowed");
4153 }
4154
4155 #[test]
4156 fn starts_block_construct_detects_block_openers() {
4157 for case in ["- item", "-", "* item", "*", "+ item", "+", "-\titem"] {
4159 assert!(starts_block_construct(case), "bullet: {case:?}");
4160 }
4161 for case in ["1. item", "1) item", "9. x", "123456789. x", "1.", "42) x"] {
4163 assert!(starts_block_construct(case), "ordered: {case:?}");
4164 }
4165 for case in ["> quote", ">quote", ">"] {
4167 assert!(starts_block_construct(case), "blockquote: {case:?}");
4168 }
4169 for case in ["# heading", "###### h6", "#", "##"] {
4171 assert!(starts_block_construct(case), "heading: {case:?}");
4172 }
4173 for case in ["```", "```rust", "````", "~~~", "~~~text"] {
4175 assert!(starts_block_construct(case), "fence: {case:?}");
4176 }
4177 for case in ["---", "--", "===", "=", "***", "___", "_ _ _", "- - -"] {
4179 assert!(starts_block_construct(case), "setext/thematic: {case:?}");
4180 }
4181 for case in [
4184 "[^1]: text",
4185 "[^note]:",
4186 "[ref]: http://example.com",
4187 "[wat]: url follows",
4188 ] {
4189 assert!(starts_block_construct(case), "definition: {case:?}");
4190 }
4191 for case in ["<div>content", "</div>", "<p>text", "<table>", "<pre>code", "<h1>x"] {
4193 assert!(starts_block_construct(case), "html block: {case:?}");
4194 }
4195 }
4196
4197 #[test]
4198 fn starts_block_construct_allows_ordinary_prose() {
4199 for case in [
4200 "",
4201 "word",
4202 "-5 degrees",
4203 "--flag",
4204 "-item",
4205 "#hashtag",
4206 "####### seven hashes is not a heading",
4207 "1.5 million",
4208 "1234567890. ten digits is not a list marker",
4209 "1:30 pm",
4210 "*emphasis*",
4211 "**bold** text",
4212 "__bold__ text",
4213 "_emphasis_ text",
4214 "`code` span",
4215 "`` double backtick span ``",
4216 "~~strikethrough~~",
4217 "=x",
4218 "== ==",
4219 "(parenthetical)",
4220 "[link](url)",
4221 "[text][ref] more",
4222 "[bracketed] aside",
4223 "[a](b) [ref]: first bracket is a link, not a label",
4224 "[esc\\]: not a close] text",
4225 "<span>inline</span>",
4226 "<b>bold</b>",
4227 "<https://example.com> autolink",
4228 "<mailto:a@b.com>",
4229 "<notarealtag>",
4230 ] {
4231 assert!(!starts_block_construct(case), "prose: {case:?}");
4232 }
4233 }
4234
4235 #[test]
4236 fn merge_block_construct_continuations_merges_marker_led_lines() {
4237 let lines = vec![
4238 "First sentence?".to_string(),
4239 "- looks like a list item".to_string(),
4240 "Second sentence.".to_string(),
4241 ];
4242 assert_eq!(
4243 merge_block_construct_continuations(lines),
4244 vec![
4245 "First sentence? - looks like a list item".to_string(),
4246 "Second sentence.".to_string(),
4247 ]
4248 );
4249
4250 let lines = vec!["- real list content".to_string(), "continuation".to_string()];
4253 assert_eq!(
4254 merge_block_construct_continuations(lines.clone()),
4255 lines,
4256 "first line must never be merged"
4257 );
4258 }
4259
4260 #[test]
4261 fn wrap_never_starts_a_line_with_a_block_marker() {
4262 let options = ReflowOptions {
4263 line_length: 25,
4264 ..Default::default()
4265 };
4266 let lines = reflow_line(
4269 "Some words here and then - a dash clause that wraps around the limit.",
4270 &options,
4271 );
4272 assert_eq!(
4273 lines,
4274 vec![
4275 "Some words here and",
4276 "then - a dash clause that",
4277 "wraps around the limit."
4278 ]
4279 );
4280
4281 for input in [
4283 "Alpha beta gamma delta epsilon - dash clause here to wrap",
4284 "Alpha beta gamma delta epsilon > quote lookalike here to wrap",
4285 "Alpha beta gamma delta epsilon # heading lookalike here to wrap",
4286 "Alpha beta gamma delta epsilon 1. ordered lookalike here to wrap",
4287 "Alpha beta gamma delta epsilon * star clause here to wrap",
4288 "Alpha beta gamma delta epsilon + plus clause here to wrap",
4289 ] {
4290 for width in 10..40 {
4291 let options = ReflowOptions {
4292 line_length: width,
4293 ..Default::default()
4294 };
4295 for line in reflow_line(input, &options) {
4296 assert!(
4297 !starts_block_construct(&line),
4298 "width {width}: wrapped line opens a block construct: {line:?} (input {input:?})"
4299 );
4300 }
4301 }
4302 }
4303 }
4304
4305 #[test]
4306 fn sentence_per_line_keeps_block_markers_mid_line() {
4307 let options = ReflowOptions {
4308 line_length: 80,
4309 sentence_per_line: true,
4310 ..Default::default()
4311 };
4312 let lines = reflow_line(
4315 "Google Calendar (Can't we get rid of this dependency? - I don't really see the need)",
4316 &options,
4317 );
4318 assert_eq!(
4319 lines,
4320 vec!["Google Calendar (Can't we get rid of this dependency? - I don't really see the need)".to_string()]
4321 );
4322
4323 let lines = reflow_line("See section 4? # is the marker we use. Fine.", &options);
4325 assert_eq!(lines, vec!["See section 4? # is the marker we use.", "Fine."]);
4326
4327 let lines = reflow_line("Is this a problem? > I quote someone here.", &options);
4328 assert_eq!(lines, vec!["Is this a problem? > I quote someone here."]);
4329
4330 let lines = reflow_line("Another case! 1. Not a list. More text follows here.", &options);
4331 for line in &lines {
4332 assert!(
4333 !starts_block_construct(line),
4334 "sentence-per-line output opens a block construct: {line:?}"
4335 );
4336 }
4337 }
4338
4339 #[test]
4340 fn inline_math_directly_after_display_math_stays_atomic() {
4341 let options = ReflowOptions {
4349 line_length: 8,
4350 ..Default::default()
4351 };
4352 let lines = reflow_line("$$a$$$bb cc dd$ x", &options);
4353 assert_eq!(lines, vec!["$$a$$$bb cc dd$".to_string(), "x".to_string()]);
4354 }
4355
4356 #[test]
4357 fn test_code_span_parsing() {
4358 let elements = parse_markdown_elements_inner("`code`", false, false, None);
4360 assert_eq!(elements.len(), 1);
4361 assert!(matches!(&elements[0], Element::Code { content, marker } if content == "code" && marker == "`"));
4362
4363 let elements = parse_markdown_elements_inner("``code``", false, false, None);
4365 assert_eq!(elements.len(), 1);
4366 assert!(matches!(&elements[0], Element::Code { content, marker } if content == "code" && marker == "``"));
4367
4368 let elements = parse_markdown_elements_inner("``code`inside``", false, false, None);
4370 assert_eq!(elements.len(), 1);
4371 assert!(
4372 matches!(&elements[0], Element::Code { content, marker } if content == "code`inside" && marker == "``")
4373 );
4374
4375 let elements = parse_markdown_elements_inner("`` code ``", false, false, None);
4377 assert_eq!(elements.len(), 1);
4378 assert!(matches!(&elements[0], Element::Code { content, marker } if content == " code " && marker == "``"));
4379
4380 let elements = parse_markdown_elements_inner("`unclosed", false, false, None);
4382 assert_eq!(elements.len(), 1);
4383 assert!(matches!(&elements[0], Element::Text(s) if s == "`unclosed"));
4384
4385 let elements = parse_markdown_elements_inner("`unclosed [link](url)", false, false, None);
4387 assert_eq!(elements.len(), 2);
4389 assert!(matches!(&elements[0], Element::Text(s) if s == "`unclosed "));
4390 assert!(matches!(&elements[1], Element::Link(s) if s == "[link](url)"));
4391 }
4392
4393 #[test]
4394 fn test_reflow_performance_long_input() {
4395 let mut text = String::new();
4398 for i in 1..400 {
4399 let backticks = "`".repeat(i);
4400 text.push_str(&backticks);
4401 text.push(' ');
4402 }
4403
4404 let start = std::time::Instant::now();
4405 let elements = parse_markdown_elements_inner(&text, false, false, None);
4406 let duration = start.elapsed();
4407
4408 assert!(duration.as_millis() < 100, "Parsing took too long: {duration:?}");
4410 assert!(!elements.is_empty());
4411 }
4412
4413 #[test]
4414 fn test_reflow_performance_display_math_heavy() {
4415 let text = "$$a$$".repeat(4000);
4420
4421 let start = std::time::Instant::now();
4422 let elements = parse_markdown_elements_inner(&text, false, false, None);
4423 let duration = start.elapsed();
4424
4425 assert!(duration.as_millis() < 100, "Parsing took too long: {duration:?}");
4426 assert_eq!(elements.len(), 4000);
4427 }
4428
4429 #[test]
4430 fn inline_math_len_at_start_matches_regex_at_slice_start() {
4431 let alphabet = ['$', 'a', ' '];
4436 let mut inputs: Vec<String> = vec![String::new()];
4437 let mut frontier: Vec<String> = vec![String::new()];
4438 for _ in 0..6 {
4439 let mut longer = Vec::new();
4440 for prefix in &frontier {
4441 for ch in alphabet {
4442 let mut s = prefix.clone();
4443 s.push(ch);
4444 longer.push(s);
4445 }
4446 }
4447 inputs.extend(longer.iter().cloned());
4448 frontier = longer;
4449 }
4450 inputs.push("$αβ$x".to_string());
4452 inputs.push("$α$$".to_string());
4453
4454 for s in &inputs {
4455 let expected = INLINE_MATH_REGEX
4456 .find(s)
4457 .ok()
4458 .flatten()
4459 .filter(|m| m.start() == 0)
4460 .map(|m| m.end());
4461 assert_eq!(inline_math_len_at_start(s), expected, "input: {s:?}");
4462 }
4463 }
4464
4465 #[test]
4466 fn inline_math_probe_after_dollar_matches_uncached_parse() {
4467 let cases = [
4473 ("$$a$$$b c$ x", r#"[DisplayMath("a"), InlineMath("b c"), Text(" x")]"#),
4474 (
4475 "$$a$$$b$ $$a$$$b$",
4476 r#"[DisplayMath("a"), InlineMath("b"), Text(" "), DisplayMath("a"), InlineMath("b")]"#,
4477 ),
4478 (
4480 "$$a$$$ x $y z$",
4481 r#"[DisplayMath("a"), InlineMath(" x "), Text("y z$")]"#,
4482 ),
4483 ("$$a$$$$ x", r#"[DisplayMath("a"), Text("$$ x")]"#),
4485 ("$$a$$$$b$$ x", r#"[DisplayMath("a"), DisplayMath("b"), Text(" x")]"#),
4486 (
4488 "$a$$b$$c$$d$ tail",
4489 r#"[Text("$a"), DisplayMath("b"), Text("c$$d$ tail")]"#,
4490 ),
4491 ];
4492 for (input, expected) in cases {
4493 let elements = parse_markdown_elements_inner(input, false, false, None);
4494 assert_eq!(format!("{elements:?}"), expected, "input: {input:?}");
4495 }
4496 }
4497
4498 #[test]
4499 fn test_atomic_spans() {
4500 let text_emphasis = "hello **word1 word2**";
4502
4503 let options_disabled = ReflowOptions {
4504 line_length: 18,
4505 atomic_spans: true,
4506 ..Default::default()
4507 };
4508 let lines_disabled = reflow_line(text_emphasis, &options_disabled);
4509 assert_eq!(lines_disabled, vec!["hello", "**word1 word2**"]);
4510
4511 let options_enabled = ReflowOptions {
4512 line_length: 18,
4513 atomic_spans: false,
4514 ..Default::default()
4515 };
4516 let lines_enabled = reflow_line(text_emphasis, &options_enabled);
4517 assert_eq!(lines_enabled, vec!["hello **word1", "word2**"]);
4518
4519 let text_code = "hello `word1 word2`";
4521
4522 let lines_code_disabled = reflow_line(text_code, &options_disabled);
4523 assert_eq!(lines_code_disabled, vec!["hello", "`word1 word2`"]);
4524
4525 let lines_code_enabled = reflow_line(text_code, &options_enabled);
4526 assert_eq!(lines_code_enabled, vec!["hello `word1", "word2`"]);
4527
4528 let text_code_padding = "hello `` `word1` `word2` ``";
4530 let lines_padding_enabled = reflow_line(text_code_padding, &options_enabled);
4531 assert_eq!(lines_padding_enabled, vec![r#"hello `` `word1`"#, r#"`word2` ``"#]);
4532 }
4533
4534 #[test]
4535 fn test_emphasis_containing_markers_is_not_split() {
4536 let options = ReflowOptions {
4537 line_length: 5,
4538 atomic_spans: false,
4539 ..Default::default()
4540 };
4541 let lines = reflow_line(r#"*foo \*bar*"#, &options);
4543 assert_eq!(lines, vec![r#"*foo \*bar*"#.to_string()]);
4544 }
4545
4546 #[test]
4547 fn test_definition_list_marker_does_not_start_line() {
4548 let options = ReflowOptions {
4549 line_length: 20,
4550 ..Default::default()
4551 };
4552 let lines = reflow_line("This is a term and : definition here.", &options);
4554 for line in &lines {
4555 assert!(
4556 !line.trim_start().starts_with(": "),
4557 "Wrapped line should not start with definition marker: {line}"
4558 );
4559 }
4560 }
4561
4562 #[test]
4563 fn test_div_marker_does_not_start_line() {
4564 let options = ReflowOptions {
4565 line_length: 20,
4566 ..Default::default()
4567 };
4568 let lines = reflow_line("This is some text with ::: class marker.", &options);
4570 for line in &lines {
4571 assert!(
4572 !line.trim_start().starts_with(":::"),
4573 "Wrapped line should not start with div marker: {line}"
4574 );
4575 }
4576 }
4577}