1use crate::utils::calculate_indentation_width_default;
7use crate::utils::is_definition_list_item;
8use crate::utils::mkdocs_attr_list::{ATTR_LIST_PATTERN, is_standalone_attr_list};
9use crate::utils::mkdocs_snippets::is_snippet_block_delimiter;
10use crate::utils::regex_cache::{
11 DISPLAY_MATH_REGEX, EMAIL_PATTERN, EMOJI_SHORTCODE_REGEX, HTML_ENTITY_REGEX, HTML_TAG_PATTERN,
12 HUGO_SHORTCODE_REGEX, INLINE_MATH_REGEX, WIKI_LINK_REGEX,
13};
14use crate::utils::sentence_utils::{
15 get_abbreviations, is_cjk_char, is_cjk_sentence_ending, is_closing_quote, is_opening_quote,
16 text_ends_with_abbreviation,
17};
18use pulldown_cmark::{BrokenLink, CowStr, Event, LinkType, Options, Parser, Tag, TagEnd};
19use std::collections::HashSet;
20use unicode_width::UnicodeWidthStr;
21
22#[derive(Clone, Copy, Debug, Default, PartialEq)]
24pub enum ReflowLengthMode {
25 Chars,
27 #[default]
29 Visual,
30 Bytes,
32}
33
34fn display_len(s: &str, mode: ReflowLengthMode) -> usize {
36 match mode {
37 ReflowLengthMode::Chars => s.chars().count(),
38 ReflowLengthMode::Visual => s.width(),
39 ReflowLengthMode::Bytes => s.len(),
40 }
41}
42
43fn is_non_breaking_space(c: char) -> bool {
47 matches!(c, '\u{00A0}' | '\u{202F}' | '\u{2007}')
48}
49
50fn is_breakable_whitespace(c: char) -> bool {
55 c.is_whitespace() && !is_non_breaking_space(c)
56}
57
58fn split_breakable_words(text: &str) -> impl Iterator<Item = &str> {
60 text.split(is_breakable_whitespace).filter(|word| !word.is_empty())
61}
62
63fn code_span_wraps_losslessly(content: &str) -> bool {
72 let mut prev_ws = false;
73 for c in content.chars() {
74 let ws = is_breakable_whitespace(c);
75 if ws && (prev_ws || c != ' ') {
76 return false;
77 }
78 prev_ws = ws;
79 }
80 true
81}
82
83fn nested_construct_ranges(content: &str) -> Vec<(usize, usize)> {
89 let mut options = Options::empty();
90 options.insert(Options::ENABLE_STRIKETHROUGH);
91
92 let mut ranges: Vec<(usize, usize)> = Vec::new();
93 for (event, range) in Parser::new_ext(content, options).into_offset_iter() {
94 let protect = matches!(
95 event,
96 Event::Code(_)
97 | Event::InlineHtml(_)
98 | Event::Start(Tag::Emphasis | Tag::Strong | Tag::Strikethrough | Tag::Link { .. } | Tag::Image { .. })
99 );
100 if protect {
101 ranges.push((range.start, range.end));
102 }
103 }
104
105 for found in WIKI_LINK_REGEX
109 .find_iter(content)
110 .chain(HUGO_SHORTCODE_REGEX.find_iter(content))
111 .chain(DISPLAY_MATH_REGEX.find_iter(content))
112 {
113 ranges.push((found.start(), found.end()));
114 }
115 let mut from = 0;
116 while let Ok(Some(found)) = INLINE_MATH_REGEX.find_from_pos(content, from) {
117 ranges.push((found.start(), found.end()));
118 from = found.end();
119 }
120
121 ranges.sort_unstable();
124 let mut merged: Vec<(usize, usize)> = Vec::with_capacity(ranges.len());
125 for (start, end) in ranges {
126 match merged.last_mut() {
127 Some(last) if start <= last.1 => last.1 = last.1.max(end),
128 _ => merged.push((start, end)),
129 }
130 }
131 merged
132}
133
134fn breakable_units(content: &str) -> Option<Vec<&str>> {
149 if !content.contains(['`', '*', '_', '~', '[', '<', '$', '{']) {
152 return Some(split_breakable_words(content).collect());
153 }
154
155 let protected = nested_construct_ranges(content);
156
157 let mut units = Vec::new();
158 let mut unit_start = None;
159 let mut next_range = 0;
160 for (offset, ch) in content.char_indices() {
161 while protected.get(next_range).is_some_and(|&(_, end)| end <= offset) {
162 next_range += 1;
163 }
164 if protected.get(next_range).is_some_and(|&(start, _)| offset >= start) {
165 if unit_start.is_none() {
168 unit_start = Some(offset);
169 }
170 continue;
171 }
172 if matches!(ch, '`' | '*' | '_' | '~') {
173 return None;
174 }
175 if is_breakable_whitespace(ch) {
176 if let Some(start) = unit_start.take() {
177 units.push(&content[start..offset]);
178 }
179 } else if unit_start.is_none() {
180 unit_start = Some(offset);
181 }
182 }
183 if let Some(start) = unit_start {
184 units.push(&content[start..]);
185 }
186 Some(units)
187}
188
189#[derive(Clone)]
191pub struct ReflowOptions {
192 pub line_length: usize,
194 pub break_on_sentences: bool,
196 pub preserve_breaks: bool,
198 pub sentence_per_line: bool,
200 pub semantic_line_breaks: bool,
202 pub abbreviations: Option<Vec<String>>,
206 pub length_mode: ReflowLengthMode,
208 pub attr_lists: bool,
211 pub myst_roles: bool,
215 pub require_sentence_capital: bool,
220 pub max_list_continuation_indent: Option<usize>,
224 pub defined_references: Option<HashSet<String>>,
238 pub atomic_spans: bool,
242}
243
244impl Default for ReflowOptions {
245 fn default() -> Self {
246 Self {
247 line_length: 80,
248 break_on_sentences: true,
249 preserve_breaks: false,
250 sentence_per_line: false,
251 semantic_line_breaks: false,
252 abbreviations: None,
253 length_mode: ReflowLengthMode::default(),
254 attr_lists: false,
255 myst_roles: false,
256 require_sentence_capital: true,
257 max_list_continuation_indent: None,
258 defined_references: None,
259 atomic_spans: true,
260 }
261 }
262}
263
264pub fn normalize_reference_label(label: &str) -> String {
271 label.split_whitespace().collect::<Vec<_>>().join(" ").to_lowercase()
272}
273
274fn footnote_refs_end(chars: &[char], start: usize) -> Option<usize> {
280 let mut pos = start;
281 let mut found = false;
282
283 loop {
284 if chars.get(pos) != Some(&'[') || chars.get(pos + 1) != Some(&'^') {
285 break;
286 }
287 let label_start = pos + 2;
288 let mut label_end = label_start;
289 while matches!(chars.get(label_end), Some(c) if c.is_ascii_alphanumeric() || *c == '_' || *c == '-') {
290 label_end += 1;
291 }
292 if label_end == label_start || chars.get(label_end) != Some(&']') {
293 break;
294 }
295 pos = label_end + 1;
296 found = true;
297 }
298
299 found.then_some(pos)
300}
301
302fn is_sentence_boundary(
306 text: &str,
307 chars: &[char],
308 pos: usize,
309 byte_offset_after_punct: usize,
310 abbreviations: &HashSet<String>,
311 require_sentence_capital: bool,
312) -> bool {
313 if pos + 1 >= chars.len() {
314 return false;
315 }
316
317 let c = chars[pos];
318 let next_char = chars[pos + 1];
319
320 if is_cjk_sentence_ending(c) {
323 let mut after_punct_pos = pos + 1;
325 while after_punct_pos < chars.len()
326 && (chars[after_punct_pos] == '*' || chars[after_punct_pos] == '_' || chars[after_punct_pos] == '~')
327 {
328 after_punct_pos += 1;
329 }
330
331 while after_punct_pos < chars.len() && chars[after_punct_pos].is_whitespace() {
333 after_punct_pos += 1;
334 }
335
336 if after_punct_pos >= chars.len() {
338 return false;
339 }
340
341 while after_punct_pos < chars.len()
343 && (chars[after_punct_pos] == '*' || chars[after_punct_pos] == '_' || chars[after_punct_pos] == '~')
344 {
345 after_punct_pos += 1;
346 }
347
348 if after_punct_pos >= chars.len() {
349 return false;
350 }
351
352 return true;
355 }
356
357 if c != '.' && c != '!' && c != '?' {
359 return false;
360 }
361
362 let (_space_pos, after_space_pos) = if next_char == ' ' {
364 (pos + 1, pos + 2)
366 } else if is_closing_quote(next_char) && pos + 2 < chars.len() {
367 if chars[pos + 2] == ' ' {
369 (pos + 2, pos + 3)
371 } else if (chars[pos + 2] == '*' || chars[pos + 2] == '_') && pos + 3 < chars.len() && chars[pos + 3] == ' ' {
372 (pos + 3, pos + 4)
374 } else if (chars[pos + 2] == '*' || chars[pos + 2] == '_')
375 && pos + 4 < chars.len()
376 && chars[pos + 3] == chars[pos + 2]
377 && chars[pos + 4] == ' '
378 {
379 (pos + 4, pos + 5)
381 } else {
382 return false;
383 }
384 } else if (next_char == '*' || next_char == '_') && pos + 2 < chars.len() && chars[pos + 2] == ' ' {
385 (pos + 2, pos + 3)
387 } else if (next_char == '*' || next_char == '_')
388 && pos + 3 < chars.len()
389 && chars[pos + 2] == next_char
390 && chars[pos + 3] == ' '
391 {
392 (pos + 3, pos + 4)
394 } else if next_char == '~' && pos + 3 < chars.len() && chars[pos + 2] == '~' && chars[pos + 3] == ' ' {
395 (pos + 3, pos + 4)
397 } else if next_char == '[' {
398 match footnote_refs_end(chars, pos + 1) {
404 Some(end_pos) if chars.get(end_pos) == Some(&' ') => (end_pos, end_pos + 1),
405 _ => return false,
406 }
407 } else {
408 return false;
409 };
410
411 let mut next_char_pos = after_space_pos;
413 while next_char_pos < chars.len() && chars[next_char_pos].is_whitespace() {
414 next_char_pos += 1;
415 }
416
417 if next_char_pos >= chars.len() {
419 return false;
420 }
421
422 let mut first_letter_pos = next_char_pos;
424 while first_letter_pos < chars.len()
425 && (chars[first_letter_pos] == '*'
426 || chars[first_letter_pos] == '_'
427 || chars[first_letter_pos] == '~'
428 || is_opening_quote(chars[first_letter_pos]))
429 {
430 first_letter_pos += 1;
431 }
432
433 if first_letter_pos >= chars.len() {
435 return false;
436 }
437
438 let first_char = chars[first_letter_pos];
439
440 if c == '!' || c == '?' {
442 return true;
443 }
444
445 if pos > 0 {
449 if text_ends_with_abbreviation(&text[..byte_offset_after_punct], abbreviations) {
451 return false;
452 }
453
454 if chars[pos - 1].is_numeric() && first_char.is_ascii_digit() {
456 return false;
457 }
458
459 if chars[pos - 1].is_ascii_uppercase() && (pos == 1 || (pos >= 2 && chars[pos - 2].is_whitespace())) {
463 return false;
464 }
465 }
466
467 if require_sentence_capital && !first_char.is_uppercase() && !is_cjk_char(first_char) {
470 return false;
471 }
472
473 true
474}
475
476pub fn split_into_sentences(text: &str) -> Vec<String> {
478 split_into_sentences_custom(text, &None)
479}
480
481pub fn split_into_sentences_custom(text: &str, custom_abbreviations: &Option<Vec<String>>) -> Vec<String> {
483 let abbreviations = get_abbreviations(custom_abbreviations);
484 split_into_sentences_with_set(text, &abbreviations, true)
485}
486
487fn split_into_sentences_with_set(
490 text: &str,
491 abbreviations: &HashSet<String>,
492 require_sentence_capital: bool,
493) -> Vec<String> {
494 let char_vec: Vec<char> = text.chars().collect();
495
496 let mut char_offsets = Vec::with_capacity(char_vec.len() + 1);
500 let mut offset = 0;
501 for c in &char_vec {
502 char_offsets.push(offset);
503 offset += c.len_utf8();
504 }
505 char_offsets.push(offset);
506
507 let code_spans = extract_code_spans(text);
509 let mut span_it = code_spans.iter().peekable();
510
511 let mut sentences = Vec::new();
512 let mut current_sentence = String::new();
513 let mut pos = 0;
514
515 while pos < char_vec.len() {
516 let c = char_vec[pos];
517 current_sentence.push(c);
518
519 let byte_idx = char_offsets[pos];
520
521 while let Some(span) = span_it.peek() {
523 if span.end <= byte_idx {
524 span_it.next();
525 } else {
526 break;
527 }
528 }
529
530 let in_code = if let Some(span) = span_it.peek() {
532 byte_idx >= span.start && byte_idx < span.end
533 } else {
534 false
535 };
536
537 if !in_code
538 && is_sentence_boundary(
539 text,
540 &char_vec,
541 pos,
542 char_offsets[pos + 1],
543 abbreviations,
544 require_sentence_capital,
545 )
546 {
547 if let Some(end_pos) = footnote_refs_end(&char_vec, pos + 1) {
549 while pos + 1 < end_pos {
550 pos += 1;
551 current_sentence.push(char_vec[pos]);
552 }
553 }
554
555 while pos + 1 < char_vec.len() {
557 let next = char_vec[pos + 1];
558 if next == '*' || next == '_' || next == '~' || is_closing_quote(next) {
559 pos += 1;
560 current_sentence.push(char_vec[pos]);
561 } else {
562 break;
563 }
564 }
565
566 if pos + 1 < char_vec.len() && char_vec[pos + 1] == ' ' {
568 pos += 1; }
570
571 sentences.push(current_sentence.trim().to_string());
572 current_sentence.clear();
573 }
574
575 pos += 1;
576 }
577
578 if !current_sentence.trim().is_empty() {
580 sentences.push(current_sentence.trim().to_string());
581 }
582 sentences
583}
584
585fn is_horizontal_rule(line: &str) -> bool {
587 if line.len() < 3 {
588 return false;
589 }
590
591 let mut chars = line.chars();
594 let Some(first_char) = chars.next() else {
595 return false;
596 };
597 if first_char != '-' && first_char != '_' && first_char != '*' {
598 return false;
599 }
600
601 let mut non_space_count = 1usize; for c in chars {
603 if c == ' ' {
604 continue;
605 }
606 if c != first_char {
607 return false;
608 }
609 non_space_count += 1;
610 }
611 non_space_count >= 3
612}
613
614fn is_numbered_list_item(line: &str) -> bool {
616 let mut chars = line.chars();
617
618 if !chars.next().is_some_and(char::is_numeric) {
620 return false;
621 }
622
623 while let Some(c) = chars.next() {
625 if c == '.' {
626 return chars.next() == Some(' ');
629 }
630 if !c.is_numeric() {
631 return false;
632 }
633 }
634
635 false
636}
637
638fn is_unordered_list_marker(s: &str) -> bool {
640 matches!(s.as_bytes().first(), Some(b'-' | b'*' | b'+'))
641 && !is_horizontal_rule(s)
642 && (s.len() == 1 || s.as_bytes().get(1) == Some(&b' '))
643}
644
645fn is_block_boundary_core(trimmed: &str) -> bool {
648 trimmed.is_empty()
649 || trimmed.starts_with('#')
650 || trimmed.starts_with("```")
651 || trimmed.starts_with("~~~")
652 || trimmed.starts_with('>')
653 || (trimmed.starts_with('[') && trimmed.contains("]:"))
654 || is_horizontal_rule(trimmed)
655 || is_unordered_list_marker(trimmed)
656 || is_numbered_list_item(trimmed)
657 || is_definition_list_item(trimmed)
658 || trimmed.starts_with(":::")
659}
660
661fn is_block_boundary(trimmed: &str) -> bool {
664 is_block_boundary_core(trimmed) || trimmed.starts_with('|')
665}
666
667fn is_paragraph_boundary(trimmed: &str, line: &str) -> bool {
671 is_block_boundary_core(trimmed)
672 || calculate_indentation_width_default(line) >= 4
673 || crate::utils::table_utils::TableUtils::is_potential_table_row(line)
674}
675
676fn has_hard_break(line: &str) -> bool {
682 let line = line.strip_suffix('\r').unwrap_or(line);
683 line.ends_with(" ") || line.ends_with('\\')
684}
685
686fn ends_with_sentence_punct(text: &str) -> bool {
688 text.ends_with('.') || text.ends_with('!') || text.ends_with('?')
689}
690
691fn trim_preserving_hard_break(s: &str) -> String {
697 let s = s.strip_suffix('\r').unwrap_or(s);
699
700 if s.ends_with('\\') {
702 return s.to_string();
704 }
705
706 if s.ends_with(" ") {
708 let content_end = s.trim_end().len();
710 if content_end == 0 {
711 return String::new();
713 }
714 format!("{} ", &s[..content_end])
716 } else {
717 s.trim_end().to_string()
719 }
720}
721
722fn parse_elements(text: &str, options: &ReflowOptions) -> Vec<Element> {
724 parse_markdown_elements_inner(
725 text,
726 options.attr_lists,
727 options.myst_roles,
728 options.defined_references.as_ref(),
729 )
730}
731
732pub fn reflow_line(line: &str, options: &ReflowOptions) -> Vec<String> {
733 if options.sentence_per_line {
735 let elements = parse_elements(line, options);
736 return merge_block_construct_continuations(reflow_elements_sentence_per_line(
737 &elements,
738 &options.abbreviations,
739 options.require_sentence_capital,
740 ));
741 }
742
743 if options.semantic_line_breaks {
745 let elements = parse_elements(line, options);
746 return merge_block_construct_continuations(reflow_elements_semantic(&elements, options));
747 }
748
749 if options.line_length == 0 || display_len(line, options.length_mode) <= options.line_length {
752 return vec![line.to_string()];
753 }
754
755 let elements = parse_elements(line, options);
757
758 merge_block_construct_continuations(reflow_elements(&elements, options))
760}
761
762#[derive(Debug, Clone)]
764enum Element {
765 Text(String),
767 Link(String),
769 ReferenceLink(String),
771 EmptyReferenceLink(String),
773 ShortcutReference(String),
775 InlineImage(String),
777 ReferenceImage(String),
779 EmptyReferenceImage(String),
781 LinkedImage(String),
783 FootnoteReference(String),
785 Strikethrough {
787 content: String,
788 double: bool,
790 },
791 WikiLink(String),
793 InlineMath(String),
795 DisplayMath(String),
797 EmojiShortcode(String),
799 Autolink(String),
801 HtmlTag(String),
803 HtmlEntity(String),
805 HugoShortcode(String),
807 AttrList(String),
809 MystRole(String),
813 Code { content: String, marker: String },
815 Bold {
817 content: String,
818 underscore: bool,
820 },
821 Italic {
823 content: String,
824 underscore: bool,
826 },
827}
828
829impl std::fmt::Display for Element {
830 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
831 match self {
832 Element::Text(s) => write!(f, "{s}"),
833 Element::Link(s) => write!(f, "{s}"),
834 Element::ReferenceLink(s) => write!(f, "{s}"),
835 Element::EmptyReferenceLink(s) => write!(f, "{s}"),
836 Element::ShortcutReference(s) => write!(f, "{s}"),
837 Element::InlineImage(s) => write!(f, "{s}"),
838 Element::ReferenceImage(s) => write!(f, "{s}"),
839 Element::EmptyReferenceImage(s) => write!(f, "{s}"),
840 Element::LinkedImage(s) => write!(f, "{s}"),
841 Element::FootnoteReference(s) => write!(f, "{s}"),
842 Element::Strikethrough { content, double } => {
843 let marker = if *double { "~~" } else { "~" };
844 write!(f, "{marker}{content}{marker}")
845 }
846 Element::WikiLink(s) => write!(f, "[[{s}]]"),
847 Element::InlineMath(s) => write!(f, "${s}$"),
848 Element::DisplayMath(s) => write!(f, "$${s}$$"),
849 Element::EmojiShortcode(s) => write!(f, ":{s}:"),
850 Element::Autolink(s) => write!(f, "{s}"),
851 Element::HtmlTag(s) => write!(f, "{s}"),
852 Element::HtmlEntity(s) => write!(f, "{s}"),
853 Element::HugoShortcode(s) => write!(f, "{s}"),
854 Element::AttrList(s) => write!(f, "{s}"),
855 Element::MystRole(s) => write!(f, "{s}"),
856 Element::Code { content, marker } => write!(f, "{marker}{content}{marker}"),
857 Element::Bold { content, underscore } => {
858 if *underscore {
859 write!(f, "__{content}__")
860 } else {
861 write!(f, "**{content}**")
862 }
863 }
864 Element::Italic { content, underscore } => {
865 if *underscore {
866 write!(f, "_{content}_")
867 } else {
868 write!(f, "*{content}*")
869 }
870 }
871 }
872 }
873}
874
875impl Element {
876 fn display_len(&self, mode: ReflowLengthMode) -> usize {
877 match self {
878 Element::Text(s)
879 | Element::Link(s)
880 | Element::ReferenceLink(s)
881 | Element::EmptyReferenceLink(s)
882 | Element::ShortcutReference(s)
883 | Element::InlineImage(s)
884 | Element::ReferenceImage(s)
885 | Element::EmptyReferenceImage(s)
886 | Element::LinkedImage(s)
887 | Element::FootnoteReference(s)
888 | Element::Autolink(s)
889 | Element::HtmlTag(s)
890 | Element::HtmlEntity(s)
891 | Element::HugoShortcode(s)
892 | Element::AttrList(s)
893 | Element::MystRole(s) => display_len(s, mode),
894 Element::WikiLink(s) => display_len(s, mode) + 4,
895 Element::InlineMath(s) => display_len(s, mode) + 2,
896 Element::DisplayMath(s) => display_len(s, mode) + 4,
897 Element::EmojiShortcode(s) => display_len(s, mode) + 2,
898 Element::Strikethrough { content, double } => display_len(content, mode) + if *double { 4 } else { 2 },
899 Element::Code { content, marker } => display_len(content, mode) + display_len(marker, mode) * 2,
900 Element::Bold { content, .. } => display_len(content, mode) + 4,
901 Element::Italic { content, .. } => display_len(content, mode) + 2,
902 }
903 }
904}
905
906#[derive(Debug, Clone)]
908struct EmphasisSpan {
909 start: usize,
911 end: usize,
913 content: String,
915 is_strong: bool,
917 is_strikethrough: bool,
919 uses_underscore: bool,
921 strikethrough_double: bool,
924}
925
926fn extract_emphasis_and_code_spans(text: &str) -> (Vec<EmphasisSpan>, Vec<CodeSpan>) {
936 let has_emphasis = text.contains(['*', '_', '~']);
938 let has_code = text.contains('`');
939 if !has_emphasis && !has_code {
940 return (Vec::new(), Vec::new());
941 }
942
943 let mut emphasis_spans = Vec::new();
944 let mut code_spans = Vec::new();
945
946 let mut options = Options::empty();
947 if has_emphasis {
948 options.insert(Options::ENABLE_STRIKETHROUGH);
949 }
950
951 let mut emphasis_stack: Vec<(usize, bool)> = Vec::new(); let mut strong_stack: Vec<(usize, bool)> = Vec::new();
954 let mut strikethrough_stack: Vec<usize> = Vec::new();
955
956 let parser = Parser::new_ext(text, options).into_offset_iter();
957
958 for (event, range) in parser {
959 match event {
960 Event::Code(_) => {
961 code_spans.push(CodeSpan {
962 start: range.start,
963 end: range.end,
964 });
965 }
966 Event::Start(Tag::Emphasis) => {
967 let uses_underscore = text.get(range.start..range.start + 1) == Some("_");
969 emphasis_stack.push((range.start, uses_underscore));
970 }
971 Event::End(TagEnd::Emphasis) => {
972 if let Some((start_byte, uses_underscore)) = emphasis_stack.pop() {
973 let content_start = start_byte + 1;
974 let content_end = range.end - 1;
975 if content_end > content_start
976 && let Some(content) = text.get(content_start..content_end)
977 {
978 emphasis_spans.push(EmphasisSpan {
979 start: start_byte,
980 end: range.end,
981 content: content.to_string(),
982 is_strong: false,
983 is_strikethrough: false,
984 uses_underscore,
985 strikethrough_double: false,
986 });
987 }
988 }
989 }
990 Event::Start(Tag::Strong) => {
991 let uses_underscore = text.get(range.start..range.start + 2) == Some("__");
992 strong_stack.push((range.start, uses_underscore));
993 }
994 Event::End(TagEnd::Strong) => {
995 if let Some((start_byte, uses_underscore)) = strong_stack.pop() {
996 let content_start = start_byte + 2;
997 let content_end = range.end - 2;
998 if content_end > content_start
999 && let Some(content) = text.get(content_start..content_end)
1000 {
1001 emphasis_spans.push(EmphasisSpan {
1002 start: start_byte,
1003 end: range.end,
1004 content: content.to_string(),
1005 is_strong: true,
1006 is_strikethrough: false,
1007 uses_underscore,
1008 strikethrough_double: false,
1009 });
1010 }
1011 }
1012 }
1013 Event::Start(Tag::Strikethrough) => {
1014 strikethrough_stack.push(range.start);
1015 }
1016 Event::End(TagEnd::Strikethrough) => {
1017 if let Some(start_byte) = strikethrough_stack.pop() {
1018 let double = text.get(start_byte..start_byte + 2) == Some("~~");
1019 let marker_len = if double { 2 } else { 1 };
1020 let content_start = start_byte + marker_len;
1021 let content_end = range.end - marker_len;
1022 if content_end > content_start
1023 && let Some(content) = text.get(content_start..content_end)
1024 {
1025 emphasis_spans.push(EmphasisSpan {
1026 start: start_byte,
1027 end: range.end,
1028 content: content.to_string(),
1029 is_strong: false,
1030 is_strikethrough: true,
1031 uses_underscore: false,
1032 strikethrough_double: double,
1033 });
1034 }
1035 }
1036 }
1037 _ => {}
1038 }
1039 }
1040
1041 emphasis_spans.sort_by_key(|s| s.start);
1042 (emphasis_spans, code_spans)
1043}
1044
1045#[derive(Debug, Clone)]
1046struct CodeSpan {
1047 start: usize,
1048 end: usize,
1049}
1050
1051fn extract_code_spans(text: &str) -> Vec<CodeSpan> {
1052 if !text.contains('`') {
1054 return Vec::new();
1055 }
1056
1057 let mut spans = Vec::new();
1058 let parser = Parser::new(text).into_offset_iter();
1059 for (event, range) in parser {
1060 if let Event::Code(_) = event {
1061 spans.push(CodeSpan {
1062 start: range.start,
1063 end: range.end,
1064 });
1065 }
1066 }
1067 spans
1068}
1069
1070#[derive(Debug, Clone)]
1071struct LinkSpan {
1072 start: usize,
1073 end: usize,
1074 link_type: Option<LinkType>,
1075 is_image: bool,
1076 is_footnote: bool,
1077}
1078
1079fn extract_link_spans(text: &str, defined_references: Option<&HashSet<String>>) -> Vec<LinkSpan> {
1080 if !text.contains('[') {
1083 return Vec::new();
1084 }
1085
1086 let mut spans = Vec::new();
1087 let mut options = Options::empty();
1088 options.insert(Options::ENABLE_FOOTNOTES);
1089
1090 let resolve = move |link: BrokenLink<'_>| -> Option<(CowStr<'_>, CowStr<'_>)> {
1107 let atomic = match link.link_type {
1112 LinkType::Shortcut | LinkType::ShortcutUnknown => match defined_references {
1113 Some(defs) => defs.contains(&normalize_reference_label(link.reference.as_ref())),
1114 None => true,
1115 },
1116 _ => true,
1117 };
1118 atomic.then_some((CowStr::Borrowed(""), CowStr::Borrowed("")))
1119 };
1120 let parser = Parser::new_with_broken_link_callback(text, options, Some(resolve)).into_offset_iter();
1121 let mut stack = Vec::new();
1122
1123 for (event, range) in parser {
1124 match event {
1125 Event::Start(Tag::Link { link_type, .. }) => {
1126 stack.push((range.start, Some(link_type), false));
1127 }
1128 Event::Start(Tag::Image { link_type, .. }) => {
1129 stack.push((range.start, Some(link_type), true));
1130 }
1131 Event::End(TagEnd::Link) => {
1132 if let Some((start_byte, link_type, is_image)) = stack.pop()
1133 && stack.is_empty()
1134 {
1135 spans.push(LinkSpan {
1136 start: start_byte,
1137 end: range.end,
1138 link_type,
1139 is_image,
1140 is_footnote: false,
1141 });
1142 }
1143 }
1144 Event::End(TagEnd::Image) => {
1145 if let Some((start_byte, link_type, is_image)) = stack.pop()
1146 && stack.is_empty()
1147 {
1148 spans.push(LinkSpan {
1149 start: start_byte,
1150 end: range.end,
1151 link_type,
1152 is_image,
1153 is_footnote: false,
1154 });
1155 }
1156 }
1157 Event::FootnoteReference(_) if stack.is_empty() => {
1158 spans.push(LinkSpan {
1159 start: range.start,
1160 end: range.end,
1161 link_type: None,
1162 is_image: false,
1163 is_footnote: true,
1164 });
1165 }
1166 _ => {}
1167 }
1168 }
1169
1170 spans.sort_by_key(|s| s.start);
1171 spans
1172}
1173
1174fn myst_role_len_at(text: &str, absolute_pos: usize, code_spans: &[CodeSpan]) -> Option<usize> {
1182 let bytes = text.as_bytes();
1183 if bytes.first() != Some(&b'{') {
1184 return None;
1185 }
1186
1187 let mut j = 1;
1189 match bytes.get(j) {
1190 Some(&b) if b.is_ascii_alphabetic() || b == b'_' => {}
1191 _ => return None,
1192 }
1193 while let Some(&b) = bytes.get(j) {
1194 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b':' | b'.') {
1195 j += 1;
1196 } else {
1197 break;
1198 }
1199 }
1200 if bytes.get(j) != Some(&b'}') {
1201 return None;
1202 }
1203 j += 1; let code_span_start = absolute_pos + j;
1207 if let Ok(idx) = code_spans.binary_search_by_key(&code_span_start, |span| span.start) {
1208 let span = &code_spans[idx];
1209 let code_span_len = span.end - span.start;
1210 return Some(j + code_span_len);
1211 }
1212
1213 None
1214}
1215
1216fn inline_math_len_at_start(s: &str) -> Option<usize> {
1223 let bytes = s.as_bytes();
1224 if bytes.first() != Some(&b'$') || bytes.get(1) == Some(&b'$') {
1226 return None;
1227 }
1228 let close = 1 + s[1..].find('$')?;
1231 if bytes.get(close + 1) == Some(&b'$') {
1233 return None;
1234 }
1235 Some(close + 1)
1236}
1237
1238#[derive(Clone, Copy, Debug)]
1240struct PatternMatch {
1241 start: usize,
1242 end: usize,
1243}
1244
1245#[derive(Clone, Copy)]
1259enum PatternCache {
1260 Unsearched,
1261 NotFound,
1262 Found(PatternMatch),
1263}
1264
1265impl PatternCache {
1266 fn earliest_in(
1270 &mut self,
1271 remaining: &str,
1272 cursor: usize,
1273 find: impl FnOnce(&str) -> Option<(usize, usize)>,
1274 ) -> Option<(usize, usize)> {
1275 let stale = match self {
1276 PatternCache::Found(pm) => pm.start < cursor,
1277 PatternCache::NotFound => false,
1278 PatternCache::Unsearched => true,
1279 };
1280 if stale {
1281 *self = match find(remaining) {
1282 Some((start, end)) => PatternCache::Found(PatternMatch {
1283 start: cursor + start,
1284 end: cursor + end,
1285 }),
1286 None => PatternCache::NotFound,
1287 };
1288 }
1289 match self {
1290 PatternCache::Found(pm) => Some((pm.start - cursor, pm.end - cursor)),
1291 _ => None,
1292 }
1293 }
1294}
1295
1296fn parse_markdown_elements_inner(
1307 text: &str,
1308 attr_lists: bool,
1309 myst_roles: bool,
1310 defined_references: Option<&HashSet<String>>,
1311) -> Vec<Element> {
1312 let mut elements = Vec::new();
1313 let mut remaining = text;
1314
1315 let (emphasis_spans, code_spans) = extract_emphasis_and_code_spans(text);
1320 let link_spans = extract_link_spans(text, defined_references);
1321
1322 let mut cached_wiki_link = PatternCache::Unsearched;
1325 let mut cached_display_math = PatternCache::Unsearched;
1326 let mut cached_inline_math = PatternCache::Unsearched;
1327 let mut cached_emoji = PatternCache::Unsearched;
1328 let mut cached_html_entity = PatternCache::Unsearched;
1329 let mut cached_hugo_shortcode = PatternCache::Unsearched;
1330 let mut cached_html_tag = PatternCache::Unsearched;
1331 let mut cached_next_curly = PatternCache::Unsearched;
1332
1333 let mut link_span_idx = 0usize;
1337 let mut emphasis_span_idx = 0usize;
1338 let mut code_span_idx = 0usize;
1339
1340 while !remaining.is_empty() {
1341 let current_offset = text.len() - remaining.len();
1343 let mut earliest_match: Option<(usize, usize, &str)> = None;
1346
1347 while link_span_idx < link_spans.len() && link_spans[link_span_idx].start < current_offset {
1349 link_span_idx += 1;
1350 }
1351 let next_link: Option<&LinkSpan> = link_spans.get(link_span_idx);
1352
1353 if let Some(span) = next_link {
1354 let pos_in_remaining = span.start - current_offset;
1355 if earliest_match
1356 .as_ref()
1357 .is_none_or(|(start, _, _)| pos_in_remaining < *start)
1358 {
1359 let match_end = span.end - current_offset;
1360 earliest_match = Some((pos_in_remaining, match_end, "link_span"));
1361 }
1362 }
1363
1364 if let Some((start, end)) = cached_wiki_link.earliest_in(remaining, current_offset, |suffix| {
1366 WIKI_LINK_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1367 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1368 {
1369 earliest_match = Some((start, end, "wiki_link"));
1370 }
1371
1372 if let Some((start, end)) = cached_display_math.earliest_in(remaining, current_offset, |suffix| {
1374 DISPLAY_MATH_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1375 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1376 {
1377 earliest_match = Some((start, end, "display_math"));
1378 }
1379
1380 let inline_math_probe = if current_offset > 0 && text.as_bytes()[current_offset - 1] == b'$' {
1394 inline_math_len_at_start(remaining).map(|len| (0, len))
1395 } else {
1396 None
1397 };
1398 if let Some((start, end)) = inline_math_probe.or_else(|| {
1399 cached_inline_math.earliest_in(remaining, current_offset, |suffix| {
1400 INLINE_MATH_REGEX
1401 .find(suffix)
1402 .ok()
1403 .flatten()
1404 .map(|m| (m.start(), m.end()))
1405 })
1406 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1407 {
1408 earliest_match = Some((start, end, "inline_math"));
1409 }
1410
1411 if let Some((start, end)) = cached_emoji.earliest_in(remaining, current_offset, |suffix| {
1413 EMOJI_SHORTCODE_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1414 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1415 {
1416 earliest_match = Some((start, end, "emoji"));
1417 }
1418
1419 if let Some((start, end)) = cached_html_entity.earliest_in(remaining, current_offset, |suffix| {
1421 HTML_ENTITY_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1422 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1423 {
1424 earliest_match = Some((start, end, "html_entity"));
1425 }
1426
1427 if let Some((start, end)) = cached_hugo_shortcode.earliest_in(remaining, current_offset, |suffix| {
1430 HUGO_SHORTCODE_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1431 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1432 {
1433 earliest_match = Some((start, end, "hugo_shortcode"));
1434 }
1435
1436 if let Some((start, end)) = cached_html_tag.earliest_in(remaining, current_offset, |suffix| {
1443 let mut from = 0;
1444 while let Some(m) = HTML_TAG_PATTERN.find(&suffix[from..]) {
1445 let (tag_start, tag_end) = (from + m.start(), from + m.end());
1446 let tag = &suffix[tag_start..tag_end];
1447 let is_url_autolink = tag.starts_with("<http://")
1449 || tag.starts_with("<https://")
1450 || tag.starts_with("<mailto:")
1451 || tag.starts_with("<ftp://")
1452 || tag.starts_with("<ftps://");
1453 let is_email_autolink = {
1456 let content = tag.trim_start_matches('<').trim_end_matches('>');
1457 EMAIL_PATTERN.is_match(content)
1458 };
1459 if is_url_autolink || is_email_autolink {
1460 from = tag_end;
1461 } else {
1462 return Some((tag_start, tag_end));
1463 }
1464 }
1465 None
1466 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1467 {
1468 earliest_match = Some((start, end, "html_tag"));
1469 }
1470
1471 let mut next_special = remaining.len();
1473 let mut special_type = "";
1474 let mut pulldown_emphasis: Option<&EmphasisSpan> = None;
1475 let mut attr_list_len: usize = 0;
1476 let mut myst_role_len: usize = 0;
1477
1478 while code_span_idx < code_spans.len() && code_spans[code_span_idx].start < current_offset {
1480 code_span_idx += 1;
1481 }
1482 let next_code_span: Option<&CodeSpan> = code_spans.get(code_span_idx);
1483 if let Some(span) = next_code_span {
1484 let pos_in_remaining = span.start - current_offset;
1485 if pos_in_remaining < next_special {
1486 next_special = pos_in_remaining;
1487 special_type = "pulldown_code";
1488 }
1489 }
1490
1491 let next_curly_pos = cached_next_curly
1494 .earliest_in(remaining, current_offset, |suffix| {
1495 suffix.find('{').map(|pos| (pos, pos + 1))
1496 })
1497 .map(|(start, _)| start);
1498
1499 if myst_roles
1504 && let Some(pos) = next_curly_pos
1505 && pos < next_special
1506 && let Some(role_len) = myst_role_len_at(&remaining[pos..], current_offset + pos, &code_spans)
1507 {
1508 next_special = pos;
1509 special_type = "myst_role";
1510 myst_role_len = role_len;
1511 }
1512
1513 if attr_lists
1515 && let Some(pos) = next_curly_pos
1516 && pos < next_special
1517 && let Some(m) = ATTR_LIST_PATTERN.find(&remaining[pos..])
1518 && m.start() == 0
1519 {
1520 next_special = pos;
1521 special_type = "attr_list";
1522 attr_list_len = m.end();
1523 }
1524
1525 while emphasis_span_idx < emphasis_spans.len() && emphasis_spans[emphasis_span_idx].start < current_offset {
1527 emphasis_span_idx += 1;
1528 }
1529 if let Some(span) = emphasis_spans.get(emphasis_span_idx) {
1530 let pos_in_remaining = span.start - current_offset;
1531 if pos_in_remaining < next_special {
1532 next_special = pos_in_remaining;
1533 special_type = "pulldown_emphasis";
1534 pulldown_emphasis = Some(span);
1535 }
1536 }
1537
1538 let should_process_markdown_link = if let Some((pos, _, _)) = earliest_match {
1540 pos < next_special
1541 } else {
1542 false
1543 };
1544
1545 if should_process_markdown_link {
1546 let (pos, match_end, pattern_type) = earliest_match.unwrap();
1547
1548 if pos > 0 {
1550 elements.push(Element::Text(remaining[..pos].to_string()));
1551 }
1552
1553 match pattern_type {
1555 "link_span" => {
1556 let span = next_link.unwrap();
1557 let raw_text = remaining[pos..match_end].to_string();
1558 if span.is_footnote {
1559 elements.push(Element::FootnoteReference(raw_text));
1560 } else if span.is_image {
1561 match span.link_type {
1562 Some(LinkType::Inline) => elements.push(Element::InlineImage(raw_text)),
1563 Some(LinkType::Reference)
1566 | Some(LinkType::ReferenceUnknown)
1567 | Some(LinkType::Shortcut)
1568 | Some(LinkType::ShortcutUnknown) => elements.push(Element::ReferenceImage(raw_text)),
1569 Some(LinkType::Collapsed) | Some(LinkType::CollapsedUnknown) => {
1570 elements.push(Element::EmptyReferenceImage(raw_text))
1571 }
1572 _ => elements.push(Element::InlineImage(raw_text)),
1573 }
1574 } else {
1575 match span.link_type {
1576 Some(LinkType::Inline) => {
1577 if raw_text.starts_with('[') && raw_text.contains("![") {
1578 elements.push(Element::LinkedImage(raw_text));
1579 } else {
1580 elements.push(Element::Link(raw_text));
1581 }
1582 }
1583 Some(LinkType::Reference) | Some(LinkType::ReferenceUnknown) => {
1586 elements.push(Element::ReferenceLink(raw_text))
1587 }
1588 Some(LinkType::Collapsed) | Some(LinkType::CollapsedUnknown) => {
1589 elements.push(Element::EmptyReferenceLink(raw_text))
1590 }
1591 Some(LinkType::Shortcut) | Some(LinkType::ShortcutUnknown) => {
1592 elements.push(Element::ShortcutReference(raw_text))
1593 }
1594 Some(LinkType::Autolink) | Some(LinkType::Email) => {
1595 elements.push(Element::Autolink(raw_text))
1596 }
1597 _ => elements.push(Element::Link(raw_text)),
1598 }
1599 }
1600 remaining = &remaining[match_end..];
1601 }
1602 "wiki_link" => {
1603 if let Some(caps) = WIKI_LINK_REGEX.captures(remaining) {
1604 let content = caps.get(1).map_or("", |m| m.as_str());
1605 elements.push(Element::WikiLink(content.to_string()));
1606 remaining = &remaining[match_end..];
1607 } else {
1608 elements.push(Element::Text("[[".to_string()));
1609 remaining = &remaining[2..];
1610 }
1611 }
1612 "display_math" => {
1613 if let Some(caps) = DISPLAY_MATH_REGEX.captures(remaining) {
1614 let math = caps.get(1).map_or("", |m| m.as_str());
1615 elements.push(Element::DisplayMath(math.to_string()));
1616 remaining = &remaining[match_end..];
1617 } else {
1618 elements.push(Element::Text("$$".to_string()));
1619 remaining = &remaining[2..];
1620 }
1621 }
1622 "inline_math" => {
1623 if let Ok(Some(caps)) = INLINE_MATH_REGEX.captures(remaining) {
1624 let math = caps.get(1).map_or("", |m| m.as_str());
1625 elements.push(Element::InlineMath(math.to_string()));
1626 remaining = &remaining[match_end..];
1627 } else {
1628 elements.push(Element::Text("$".to_string()));
1629 remaining = &remaining[1..];
1630 }
1631 }
1632 "emoji" => {
1633 if let Some(caps) = EMOJI_SHORTCODE_REGEX.captures(remaining) {
1634 let emoji = caps.get(1).map_or("", |m| m.as_str());
1635 elements.push(Element::EmojiShortcode(emoji.to_string()));
1636 remaining = &remaining[match_end..];
1637 } else {
1638 elements.push(Element::Text(":".to_string()));
1639 remaining = &remaining[1..];
1640 }
1641 }
1642 "html_entity" => {
1643 elements.push(Element::HtmlEntity(remaining[pos..match_end].to_string()));
1645 remaining = &remaining[match_end..];
1646 }
1647 "hugo_shortcode" => {
1648 elements.push(Element::HugoShortcode(remaining[pos..match_end].to_string()));
1650 remaining = &remaining[match_end..];
1651 }
1652 "html_tag" => {
1653 elements.push(Element::HtmlTag(remaining[pos..match_end].to_string()));
1655 remaining = &remaining[match_end..];
1656 }
1657 _ => unreachable!("unknown pattern type: {}", pattern_type),
1658 }
1659 } else {
1660 if next_special > 0 && next_special < remaining.len() {
1664 elements.push(Element::Text(remaining[..next_special].to_string()));
1665 remaining = &remaining[next_special..];
1666 }
1667
1668 match special_type {
1670 "pulldown_code" => {
1671 let span = next_code_span.unwrap();
1672 let span_len = span.end - span.start;
1673 let code_raw = &remaining[..span_len];
1674 if let Some((content, marker)) = decompose_code_span(code_raw) {
1675 elements.push(Element::Code {
1676 content: content.to_string(),
1677 marker: marker.to_string(),
1678 });
1679 } else {
1680 elements.push(Element::Text(code_raw.to_string()));
1681 }
1682 remaining = &remaining[span_len..];
1683 }
1684 "attr_list" => {
1685 elements.push(Element::AttrList(remaining[..attr_list_len].to_string()));
1686 remaining = &remaining[attr_list_len..];
1687 }
1688 "myst_role" => {
1689 elements.push(Element::MystRole(remaining[..myst_role_len].to_string()));
1690 remaining = &remaining[myst_role_len..];
1691 }
1692 "pulldown_emphasis" => {
1693 let span = pulldown_emphasis.expect("pulldown_emphasis must be set");
1695 let span_len = span.end - span.start;
1696 if span.is_strikethrough {
1697 elements.push(Element::Strikethrough {
1698 content: span.content.clone(),
1699 double: span.strikethrough_double,
1700 });
1701 } else if span.is_strong {
1702 elements.push(Element::Bold {
1703 content: span.content.clone(),
1704 underscore: span.uses_underscore,
1705 });
1706 } else {
1707 elements.push(Element::Italic {
1708 content: span.content.clone(),
1709 underscore: span.uses_underscore,
1710 });
1711 }
1712 remaining = &remaining[span_len..];
1713 }
1714 _ => {
1715 elements.push(Element::Text(remaining.to_string()));
1717 break;
1718 }
1719 }
1720 }
1721 }
1722
1723 let mut merged_elements = Vec::new();
1725 for el in elements {
1726 match el {
1727 Element::Text(s) => {
1728 if let Some(Element::Text(last_s)) = merged_elements.last_mut() {
1729 last_s.push_str(&s);
1730 } else {
1731 merged_elements.push(Element::Text(s));
1732 }
1733 }
1734 other => merged_elements.push(other),
1735 }
1736 }
1737 merged_elements
1738}
1739
1740fn should_insert_space_before_join(current: &str) -> bool {
1741 !current.is_empty()
1742 && !current.ends_with(' ')
1743 && !current.ends_with('(')
1744 && !current.ends_with('[')
1745 && !current.ends_with('-')
1746}
1747
1748fn is_setext_or_thematic(text: &str) -> bool {
1754 let mut marker = 0u8;
1755 let mut count = 0usize;
1756 let mut has_space = false;
1757 for &b in text.as_bytes() {
1758 match b {
1759 b' ' | b'\t' => has_space = true,
1760 b'-' | b'=' | b'*' | b'_' => {
1761 if marker == 0 {
1762 marker = b;
1763 } else if b != marker {
1764 return false;
1765 }
1766 count += 1;
1767 }
1768 _ => return false,
1769 }
1770 }
1771 match marker {
1772 b'=' => !has_space,
1773 b'-' => !has_space || count >= 3,
1774 b'*' | b'_' => count >= 3,
1775 _ => false,
1776 }
1777}
1778
1779fn starts_block_construct(text: &str) -> bool {
1791 let text = text.trim_start();
1792 let bytes = text.as_bytes();
1793 let Some(&first) = bytes.first() else {
1794 return false;
1795 };
1796 let marker_then_boundary = |len: usize| bytes.len() == len || bytes[len] == b' ' || bytes[len] == b'\t';
1797 match first {
1798 b'>' => true,
1800 b'-' | b'*' | b'+' => marker_then_boundary(1) || is_setext_or_thematic(text),
1801 b'_' | b'=' => is_setext_or_thematic(text),
1802 b':' => is_definition_list_item(text) || text.starts_with(":::"),
1803 b'|' => true,
1804 b'#' => {
1805 let hashes = bytes.iter().take_while(|&&b| b == b'#').count();
1806 hashes <= 6 && marker_then_boundary(hashes)
1807 }
1808 b'`' => bytes.iter().take_while(|&&b| b == b'`').count() >= 3,
1809 b'~' => bytes.iter().take_while(|&&b| b == b'~').count() >= 3,
1810 b'0'..=b'9' => {
1811 let digits = bytes.iter().take_while(|b| b.is_ascii_digit()).count();
1812 digits <= 9
1813 && bytes.len() > digits
1814 && (bytes[digits] == b'.' || bytes[digits] == b')')
1815 && marker_then_boundary(digits + 1)
1816 }
1817 b'[' => {
1825 let mut escaped = false;
1826 let mut label_close = None;
1827 for (i, &b) in bytes.iter().enumerate().skip(1) {
1828 if escaped {
1829 escaped = false;
1830 } else if b == b'\\' {
1831 escaped = true;
1832 } else if b == b']' {
1833 label_close = Some(i);
1834 break;
1835 }
1836 }
1837 label_close.is_some_and(|i| bytes.get(i + 1) == Some(&b':'))
1838 }
1839 b'<' => crate::utils::html_block::parse_html_block_start(text).is_some(),
1842 _ => false,
1843 }
1844}
1845
1846fn merge_block_construct_continuations(lines: Vec<String>) -> Vec<String> {
1855 let mut merged: Vec<String> = Vec::with_capacity(lines.len());
1856 for line in lines {
1857 match merged.last_mut() {
1858 Some(prev) if starts_block_construct(&line) => {
1859 prev.push(' ');
1860 prev.push_str(line.trim_start());
1861 }
1862 _ => merged.push(line),
1863 }
1864 }
1865 merged
1866}
1867
1868fn reflow_elements_sentence_per_line(
1870 elements: &[Element],
1871 custom_abbreviations: &Option<Vec<String>>,
1872 require_sentence_capital: bool,
1873) -> Vec<String> {
1874 let abbreviations = get_abbreviations(custom_abbreviations);
1875 let mut lines = Vec::new();
1876 let mut current_line = String::new();
1877
1878 for (idx, element) in elements.iter().enumerate() {
1879 if let Element::Text(text) = element {
1881 let combined = format!("{current_line}{text}");
1883 let sentences = split_into_sentences_with_set(&combined, &abbreviations, require_sentence_capital);
1885
1886 if sentences.len() > 1 {
1887 for (i, sentence) in sentences.iter().enumerate() {
1889 if i == 0 {
1890 let trimmed = sentence.trim();
1893
1894 if text_ends_with_abbreviation(trimmed, &abbreviations) {
1895 current_line.clone_from(sentence);
1897 } else {
1898 lines.push(sentence.clone());
1900 current_line.clear();
1901 }
1902 } else if i == sentences.len() - 1 {
1903 let trimmed = sentence.trim();
1905 let ends_with_sentence_punct = ends_with_sentence_punct(trimmed);
1906
1907 if ends_with_sentence_punct && !text_ends_with_abbreviation(trimmed, &abbreviations) {
1908 lines.push(sentence.clone());
1910 current_line.clear();
1911 } else {
1912 current_line.clone_from(sentence);
1914 }
1915 } else {
1916 lines.push(sentence.clone());
1918 }
1919 }
1920 } else {
1921 let trimmed = combined.trim();
1923
1924 if trimmed.is_empty() {
1928 continue;
1929 }
1930
1931 let ends_with_sentence_punct = ends_with_sentence_punct(trimmed);
1932
1933 if ends_with_sentence_punct && !text_ends_with_abbreviation(trimmed, &abbreviations) {
1934 lines.push(combined.trim_matches(is_breakable_whitespace).to_string());
1937 current_line.clear();
1938 } else {
1939 current_line = combined;
1941 }
1942 }
1943 } else if let Element::Italic { content, underscore } = element {
1944 let marker = if *underscore { "_" } else { "*" };
1946 handle_emphasis_sentence_split(
1947 content,
1948 marker,
1949 &abbreviations,
1950 require_sentence_capital,
1951 &mut current_line,
1952 &mut lines,
1953 );
1954 } else if let Element::Bold { content, underscore } = element {
1955 let marker = if *underscore { "__" } else { "**" };
1957 handle_emphasis_sentence_split(
1958 content,
1959 marker,
1960 &abbreviations,
1961 require_sentence_capital,
1962 &mut current_line,
1963 &mut lines,
1964 );
1965 } else if let Element::Strikethrough { content, double } = element {
1966 handle_emphasis_sentence_split(
1968 content,
1969 if *double { "~~" } else { "~" },
1970 &abbreviations,
1971 require_sentence_capital,
1972 &mut current_line,
1973 &mut lines,
1974 );
1975 } else {
1976 let element_str = format!("{element}");
1978 let is_adjacent = if idx > 0 {
1982 match &elements[idx - 1] {
1983 Element::Text(t) => !t.is_empty() && !t.ends_with(is_breakable_whitespace),
1984 _ => true,
1985 }
1986 } else {
1987 false
1988 };
1989
1990 if !is_adjacent && should_insert_space_before_join(¤t_line) {
1992 current_line.push(' ');
1993 }
1994 current_line.push_str(&element_str);
1995 }
1996 }
1997
1998 if !current_line.is_empty() {
2000 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
2001 }
2002 lines
2003}
2004
2005fn handle_emphasis_sentence_split(
2007 content: &str,
2008 marker: &str,
2009 abbreviations: &HashSet<String>,
2010 require_sentence_capital: bool,
2011 current_line: &mut String,
2012 lines: &mut Vec<String>,
2013) {
2014 let sentences = split_into_sentences_with_set(content, abbreviations, require_sentence_capital);
2016
2017 if sentences.len() <= 1 {
2018 if should_insert_space_before_join(current_line) {
2020 current_line.push(' ');
2021 }
2022 current_line.push_str(marker);
2023 current_line.push_str(content);
2024 current_line.push_str(marker);
2025
2026 let trimmed = content.trim();
2028 let ends_with_punct = ends_with_sentence_punct(trimmed);
2029 if ends_with_punct && !text_ends_with_abbreviation(trimmed, abbreviations) {
2030 lines.push(current_line.clone());
2031 current_line.clear();
2032 }
2033 } else {
2034 for (i, sentence) in sentences.iter().enumerate() {
2036 let trimmed = sentence.trim();
2037 if trimmed.is_empty() {
2038 continue;
2039 }
2040
2041 if i == 0 {
2042 if should_insert_space_before_join(current_line) {
2044 current_line.push(' ');
2045 }
2046 current_line.push_str(marker);
2047 current_line.push_str(trimmed);
2048 current_line.push_str(marker);
2049
2050 let ends_with_punct = ends_with_sentence_punct(trimmed);
2052 if ends_with_punct && !text_ends_with_abbreviation(trimmed, abbreviations) {
2053 lines.push(current_line.clone());
2054 current_line.clear();
2055 }
2056 } else if i == sentences.len() - 1 {
2057 let ends_with_punct = ends_with_sentence_punct(trimmed);
2059
2060 let mut line = String::new();
2061 line.push_str(marker);
2062 line.push_str(trimmed);
2063 line.push_str(marker);
2064
2065 if ends_with_punct && !text_ends_with_abbreviation(trimmed, abbreviations) {
2066 lines.push(line);
2067 } else {
2068 *current_line = line;
2070 }
2071 } else {
2072 let mut line = String::new();
2074 line.push_str(marker);
2075 line.push_str(trimmed);
2076 line.push_str(marker);
2077 lines.push(line);
2078 }
2079 }
2080 }
2081}
2082
2083const BREAK_WORDS: &[&str] = &[
2087 "and",
2088 "or",
2089 "but",
2090 "nor",
2091 "yet",
2092 "so",
2093 "for",
2094 "which",
2095 "that",
2096 "because",
2097 "when",
2098 "if",
2099 "while",
2100 "where",
2101 "although",
2102 "though",
2103 "unless",
2104 "since",
2105 "after",
2106 "before",
2107 "until",
2108 "as",
2109 "once",
2110 "whether",
2111 "however",
2112 "therefore",
2113 "moreover",
2114 "furthermore",
2115 "nevertheless",
2116 "whereas",
2117];
2118
2119fn is_clause_punctuation(c: char) -> bool {
2121 matches!(c, ',' | ';' | ':' | '\u{2014}') }
2123
2124fn clause_break_allowed_after(chars: &[char], i: usize) -> bool {
2132 if chars[i] == '\u{2014}' {
2133 return true;
2134 }
2135 match chars.get(i + 1) {
2136 None => true,
2137 Some(next) => next.is_whitespace(),
2138 }
2139}
2140
2141fn paren_group_end<'a>(slice: &'a str, element_spans: &[(usize, usize)], offset: usize) -> Option<(usize, &'a str)> {
2155 debug_assert!(slice.starts_with('('));
2156 let mut depth: i32 = 0;
2157 for (local_byte, c) in slice.char_indices() {
2158 let global_byte = offset + local_byte;
2159 if depth > 0 && is_inside_element(global_byte, element_spans) {
2164 continue;
2165 }
2166 match c {
2167 '(' => depth += 1,
2168 ')' => {
2169 depth -= 1;
2170 if depth == 0 {
2171 let end = local_byte + 1;
2172 let inner = &slice[1..local_byte];
2173 return Some((end, inner));
2174 }
2175 }
2176 _ => {}
2177 }
2178 }
2179 None
2180}
2181
2182fn split_at_parenthetical(
2199 text: &str,
2200 line_length: usize,
2201 element_spans: &[(usize, usize)],
2202 length_mode: ReflowLengthMode,
2203) -> Option<(String, String)> {
2204 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
2205
2206 if text.starts_with('(')
2208 && let Some((end_local, inner)) = paren_group_end(text, element_spans, 0)
2209 && inner.contains(' ')
2210 {
2211 let tail = &text[end_local..];
2215 let attached_len = tail
2216 .char_indices()
2217 .take_while(|(_, c)| is_closing_quote(*c) || is_clause_punctuation(*c))
2218 .last()
2219 .map_or(0, |(idx, c)| idx + c.len_utf8());
2220 let first_end = end_local + attached_len;
2221 let rest_start = first_end;
2222 let first = &text[..first_end];
2223 let first_len = display_len(first, length_mode);
2224 if first_len <= line_length {
2227 let rest = text[rest_start..].trim_start();
2228 if !rest.is_empty() {
2229 return Some((first.to_string(), rest.to_string()));
2230 }
2231 }
2232 }
2233
2234 let mut best_open_byte: Option<usize> = None;
2236 let mut pos = 0usize;
2237 while pos < text.len() {
2238 if text.as_bytes()[pos] != b'(' {
2240 let c = text[pos..].chars().next().unwrap();
2241 pos += c.len_utf8();
2242 continue;
2243 }
2244 if is_inside_element(pos, element_spans) {
2246 pos += 1;
2247 continue;
2248 }
2249 if let Some((end_local, inner)) = paren_group_end(&text[pos..], element_spans, pos) {
2250 let first = text[..pos].trim_end();
2251 let first_len = display_len(first, length_mode);
2252 if !first.is_empty()
2253 && first_len >= min_first_len
2254 && first_len <= line_length
2255 && inner.contains(' ')
2256 && best_open_byte.is_none_or(|prev| pos > prev)
2257 {
2258 best_open_byte = Some(pos);
2259 }
2260 pos += end_local;
2261 } else {
2262 pos += 1;
2263 }
2264 }
2265
2266 let open_byte = best_open_byte?;
2267 let first = text[..open_byte].trim_end().to_string();
2268 let rest = text[open_byte..].to_string();
2269 if first.is_empty() || rest.trim().is_empty() {
2270 return None;
2271 }
2272 Some((first, rest))
2273}
2274
2275fn compute_element_spans(elements: &[Element]) -> Vec<(usize, usize)> {
2279 let mut spans = Vec::new();
2280 let mut offset = 0;
2281 for element in elements {
2282 let len = element.display_len(ReflowLengthMode::Bytes);
2283 if !matches!(element, Element::Text(_)) {
2284 spans.push((offset, offset + len));
2285 }
2286 offset += len;
2287 }
2288 spans
2289}
2290
2291fn is_inside_element(pos: usize, spans: &[(usize, usize)]) -> bool {
2293 spans.iter().any(|(start, end)| pos > *start && pos < *end)
2294}
2295
2296const MIN_SPLIT_RATIO: f64 = 0.3;
2299
2300fn split_at_clause_punctuation(
2304 text: &str,
2305 line_length: usize,
2306 element_spans: &[(usize, usize)],
2307 length_mode: ReflowLengthMode,
2308) -> Option<(String, String)> {
2309 let chars: Vec<char> = text.chars().collect();
2310 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
2311
2312 let mut width_acc = 0;
2314 let mut search_end_char = 0;
2315 for (idx, &c) in chars.iter().enumerate() {
2316 let c_width = display_len(&c.to_string(), length_mode);
2317 if width_acc + c_width > line_length {
2318 break;
2319 }
2320 width_acc += c_width;
2321 search_end_char = idx + 1;
2322 }
2323
2324 let mut paren_depth: i32 = 0;
2331 let mut best_pos = None;
2332 for i in (0..search_end_char).rev() {
2333 let byte_start: usize = chars[..i].iter().map(|c| c.len_utf8()).sum();
2335 let byte_after: usize = byte_start + chars[i].len_utf8();
2337
2338 if !is_inside_element(byte_start, element_spans) {
2339 match chars[i] {
2340 ')' => paren_depth += 1,
2341 '(' => paren_depth = paren_depth.saturating_sub(1),
2342 _ => {}
2343 }
2344 }
2345
2346 if paren_depth == 0
2347 && is_clause_punctuation(chars[i])
2348 && clause_break_allowed_after(&chars, i)
2349 && !is_inside_element(byte_after, element_spans)
2350 {
2351 best_pos = Some(i);
2352 break;
2353 }
2354 }
2355
2356 let pos = best_pos?;
2357
2358 let first: String = chars[..=pos].iter().collect();
2360 let first_display_len = display_len(&first, length_mode);
2361 if first_display_len < min_first_len {
2362 return None;
2363 }
2364
2365 let rest: String = chars[pos + 1..].iter().collect();
2367 let rest = rest.trim_start().to_string();
2368
2369 if rest.is_empty() {
2370 return None;
2371 }
2372
2373 Some((first, rest))
2374}
2375
2376fn paren_depth_map(text: &str, element_spans: &[(usize, usize)]) -> Vec<i32> {
2383 let mut map = vec![0i32; text.len()];
2384 let mut depth = 0i32;
2385 for (byte, c) in text.char_indices() {
2386 if !is_inside_element(byte, element_spans) {
2387 match c {
2388 '(' => depth += 1,
2389 ')' => depth = depth.saturating_sub(1),
2390 _ => {}
2391 }
2392 }
2393 let end = (byte + c.len_utf8()).min(map.len());
2395 for slot in &mut map[byte..end] {
2396 *slot = depth;
2397 }
2398 }
2399 map
2400}
2401
2402fn is_standalone_parenthetical(line: &str) -> bool {
2411 let trimmed = line.trim();
2412 if !trimmed.starts_with('(') {
2413 return false;
2414 }
2415 let core = trimmed.trim_end_matches(|c: char| is_clause_punctuation(c));
2417 if !core.ends_with(')') {
2418 return false;
2419 }
2420 let inner = &core[1..core.len() - 1];
2422 if !inner.contains(' ') {
2423 return false;
2424 }
2425 let mut depth = 0i32;
2427 for c in core.chars() {
2428 match c {
2429 '(' => depth += 1,
2430 ')' => depth -= 1,
2431 _ => {}
2432 }
2433 if depth < 0 {
2434 return false;
2435 }
2436 }
2437 depth == 0
2438}
2439
2440fn split_at_break_word(
2444 text: &str,
2445 line_length: usize,
2446 element_spans: &[(usize, usize)],
2447 length_mode: ReflowLengthMode,
2448) -> Option<(String, String)> {
2449 let lower = text.to_lowercase();
2450 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
2451 let mut best_split: Option<(usize, usize)> = None; let depth_map = paren_depth_map(text, element_spans);
2456
2457 for &word in BREAK_WORDS {
2458 let mut search_start = 0;
2459 while let Some(pos) = lower[search_start..].find(word) {
2460 let abs_pos = search_start + pos;
2461
2462 let preceded_by_space = abs_pos == 0 || text.as_bytes().get(abs_pos - 1) == Some(&b' ');
2464 let followed_by_space = text.as_bytes().get(abs_pos + word.len()) == Some(&b' ');
2465
2466 if preceded_by_space && followed_by_space {
2467 let first_part = text[..abs_pos].trim_end();
2469 let first_part_len = display_len(first_part, length_mode);
2470
2471 let inside_paren = depth_map.get(abs_pos).is_some_and(|&d| d > 0);
2473
2474 if first_part_len >= min_first_len
2475 && first_part_len <= line_length
2476 && !is_inside_element(abs_pos, element_spans)
2477 && !inside_paren
2478 {
2479 if best_split.is_none_or(|(prev_pos, _)| abs_pos > prev_pos) {
2481 best_split = Some((abs_pos, word.len()));
2482 }
2483 }
2484 }
2485
2486 search_start = abs_pos + word.len();
2487 }
2488 }
2489
2490 let (byte_start, _word_len) = best_split?;
2491
2492 let first = text[..byte_start].trim_end().to_string();
2493 let rest = text[byte_start..].to_string();
2494
2495 if first.is_empty() || rest.trim().is_empty() {
2496 return None;
2497 }
2498
2499 Some((first, rest))
2500}
2501
2502fn cascade_split_line(text: &str, options: &ReflowOptions) -> Vec<String> {
2513 let line_length = options.line_length;
2514 let length_mode = options.length_mode;
2515 let attr_lists = options.attr_lists;
2516 let myst_roles = options.myst_roles;
2517 let defined_references = options.defined_references.as_ref();
2518 if line_length == 0 || display_len(text, length_mode) <= line_length {
2519 return vec![text.to_string()];
2520 }
2521
2522 let elements = parse_markdown_elements_inner(text, attr_lists, myst_roles, defined_references);
2523 let element_spans = compute_element_spans(&elements);
2524
2525 let rebased_spans = |start: usize| -> Vec<(usize, usize)> {
2529 if start == 0 {
2530 return element_spans.clone();
2531 }
2532 element_spans
2533 .iter()
2534 .filter(|&&(_, end)| end > start)
2535 .map(|&(s, e)| (s.saturating_sub(start), e.saturating_sub(start)))
2536 .collect()
2537 };
2538
2539 let mut result = Vec::new();
2540 let mut start = 0usize;
2541
2542 loop {
2543 let remaining = &text[start..];
2544 if display_len(remaining, length_mode) <= line_length {
2545 result.push(remaining.to_string());
2546 return result;
2547 }
2548
2549 let spans = rebased_spans(start);
2550
2551 let split = split_at_parenthetical(remaining, line_length, &spans, length_mode)
2555 .or_else(|| split_at_clause_punctuation(remaining, line_length, &spans, length_mode))
2556 .or_else(|| split_at_break_word(remaining, line_length, &spans, length_mode));
2557
2558 if let Some((first, rest)) = split {
2559 let consumed = remaining.len().saturating_sub(rest.len());
2560 if consumed == 0 {
2563 break;
2564 }
2565 result.push(first);
2566 start += consumed;
2567 continue;
2568 }
2569
2570 break;
2572 }
2573
2574 let mut fallback_options = options.clone();
2576 fallback_options.break_on_sentences = false;
2577 fallback_options.preserve_breaks = false;
2578 fallback_options.sentence_per_line = false;
2579 fallback_options.semantic_line_breaks = false;
2580 fallback_options.require_sentence_capital = true;
2581 fallback_options.max_list_continuation_indent = None;
2582 fallback_options.defined_references = None;
2583 let remaining = &text[start..];
2584 let tail_elements = if start == 0 {
2585 elements
2586 } else {
2587 parse_markdown_elements_inner(remaining, attr_lists, myst_roles, defined_references)
2588 };
2589 result.extend(reflow_elements(&tail_elements, &fallback_options));
2590 result
2591}
2592
2593fn reflow_elements_semantic(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
2597 let sentence_lines =
2599 reflow_elements_sentence_per_line(elements, &options.abbreviations, options.require_sentence_capital);
2600
2601 if options.line_length == 0 {
2604 return sentence_lines;
2605 }
2606
2607 let length_mode = options.length_mode;
2608 let mut result = Vec::new();
2609 for line in sentence_lines {
2610 if display_len(&line, length_mode) <= options.line_length {
2611 result.push(line);
2612 } else {
2613 result.extend(cascade_split_line(&line, options));
2614 }
2615 }
2616
2617 let min_line_len = ((options.line_length as f64) * MIN_SPLIT_RATIO) as usize;
2620 let mut merged: Vec<String> = Vec::with_capacity(result.len());
2621 for line in result {
2622 if !merged.is_empty() && display_len(&line, length_mode) < min_line_len && !line.trim().is_empty() {
2623 if is_standalone_parenthetical(&line) {
2626 merged.push(line);
2627 continue;
2628 }
2629
2630 let prev_ends_at_sentence = {
2632 let trimmed = merged.last().unwrap().trim_end();
2633 trimmed
2634 .chars()
2635 .rev()
2636 .find(|c| !matches!(c, '"' | '\'' | '\u{201D}' | '\u{2019}' | ')' | ']'))
2637 .is_some_and(|c| matches!(c, '.' | '!' | '?'))
2638 };
2639
2640 if !prev_ends_at_sentence {
2641 let prev = merged.last_mut().unwrap();
2642 let combined = format!("{prev} {line}");
2643 if display_len(&combined, length_mode) <= options.line_length {
2645 *prev = combined;
2646 continue;
2647 }
2648 }
2649 }
2650 merged.push(line);
2651 }
2652 merged
2653}
2654
2655fn rfind_safe_space(line: &str, element_spans: &[(usize, usize)]) -> Option<usize> {
2665 line.char_indices().rev().map(|(pos, _)| pos).find(|&pos| {
2666 line.as_bytes()[pos] == b' '
2667 && !element_spans.iter().any(|(s, e)| pos > *s && pos < *e)
2668 && !starts_block_construct(&line[pos + 1..])
2669 })
2670}
2671
2672fn break_before_attached(
2679 lines: &mut Vec<String>,
2680 current_line: &mut String,
2681 current_length: &mut usize,
2682 element_spans: &mut Vec<(usize, usize)>,
2683 attach: &str,
2684 separator: &str,
2685 length_mode: ReflowLengthMode,
2686) -> Option<usize> {
2687 let last_space = rfind_safe_space(current_line, element_spans)?;
2688 let before = current_line[..last_space]
2689 .trim_end_matches(is_breakable_whitespace)
2690 .to_string();
2691 let after = current_line[last_space + 1..].to_string();
2692 lines.push(before);
2693 let carried = after.len();
2694 *current_line = format!("{after}{separator}{attach}");
2695 *current_length = display_len(current_line, length_mode);
2696 element_spans.clear();
2697 Some(carried)
2698}
2699
2700fn reflow_elements(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
2702 let mut lines = Vec::new();
2703 let mut current_line = String::new();
2704 let mut current_length = 0;
2705 let mut current_line_element_spans: Vec<(usize, usize)> = Vec::new();
2707 let length_mode = options.length_mode;
2708
2709 for (idx, element) in elements.iter().enumerate() {
2710 let element_len = element.display_len(length_mode);
2711
2712 let is_adjacent_to_prev = if idx > 0 {
2721 match (&elements[idx - 1], element) {
2722 (Element::Text(t), _) => !t.is_empty() && !t.ends_with(is_breakable_whitespace),
2723 (_, Element::Text(t)) => !t.is_empty() && !t.starts_with(is_breakable_whitespace),
2724 _ => true,
2725 }
2726 } else {
2727 false
2728 };
2729
2730 if let Element::Text(text) = element {
2732 let has_leading_space = text.starts_with(is_breakable_whitespace);
2734 let words: Vec<&str> = split_breakable_words(text).collect();
2736
2737 for (i, word) in words.iter().enumerate() {
2738 let word_len = display_len(word, length_mode);
2739 let is_trailing_punct = word.chars().all(|c| {
2745 matches!(c, ',' | '.' | ':' | ';' | '!' | '?' | ')' | ']' | '}') || is_non_breaking_space(c)
2746 });
2747
2748 let is_first_adjacent = i == 0 && is_adjacent_to_prev;
2751
2752 if is_first_adjacent {
2753 if current_length + word_len > options.line_length
2755 && current_length > 0
2756 && break_before_attached(
2757 &mut lines,
2758 &mut current_line,
2759 &mut current_length,
2760 &mut current_line_element_spans,
2761 word,
2762 "",
2763 length_mode,
2764 )
2765 .is_some()
2766 {
2767 } else {
2772 current_line.push_str(word);
2773 current_length += word_len;
2774 }
2775 } else if current_length > 0 && current_length + 1 + word_len > options.line_length {
2776 if is_trailing_punct {
2777 if break_before_attached(
2784 &mut lines,
2785 &mut current_line,
2786 &mut current_length,
2787 &mut current_line_element_spans,
2788 word,
2789 " ",
2790 length_mode,
2791 )
2792 .is_none()
2793 {
2794 current_line.push(' ');
2795 current_line.push_str(word);
2796 current_length += 1 + word_len;
2797 }
2798 } else if !starts_block_construct(word) {
2799 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
2801 current_line = word.to_string();
2802 current_length = word_len;
2803 current_line_element_spans.clear();
2804 } else if break_before_attached(
2805 &mut lines,
2806 &mut current_line,
2807 &mut current_length,
2808 &mut current_line_element_spans,
2809 word,
2810 " ",
2811 length_mode,
2812 )
2813 .is_some()
2814 {
2815 } else {
2820 if i > 0 || has_leading_space {
2823 current_line.push(' ');
2824 current_length += 1;
2825 }
2826 current_line.push_str(word);
2827 current_length += word_len;
2828 }
2829 } else {
2830 let add_space = current_length > 0 && (i > 0 || has_leading_space);
2842 if add_space {
2843 current_line.push(' ');
2844 current_length += 1;
2845 }
2846 current_line.push_str(word);
2847 current_length += word_len;
2848 }
2849 }
2850 } else {
2851 let span_info = match element {
2852 Element::Italic { content, underscore } => {
2853 Some((content.as_str(), if *underscore { "_" } else { "*" }, false))
2854 }
2855 Element::Bold { content, underscore } => {
2856 Some((content.as_str(), if *underscore { "__" } else { "**" }, false))
2857 }
2858 Element::Strikethrough { content, double } => {
2859 Some((content.as_str(), if *double { "~~" } else { "~" }, false))
2860 }
2861 Element::Code { content, marker } => Some((content.as_str(), marker.as_str(), true)),
2862 _ => None,
2863 };
2864
2865 let breakable: Option<Vec<&str>> = match span_info {
2869 Some((content, _, is_code)) => {
2870 if is_code {
2871 (!options.atomic_spans && code_span_wraps_losslessly(content))
2872 .then(|| split_breakable_words(content).collect())
2873 } else {
2874 (!options.atomic_spans || element_len > options.line_length)
2875 .then(|| breakable_units(content))
2876 .flatten()
2877 }
2878 }
2879 None => None,
2880 };
2881
2882 if let Some(words) = breakable {
2883 let (_, marker, is_code) = span_info.expect("breakable implies a span");
2884 let n = words.len();
2885 if n == 0 {
2886 let full = format!("{marker}{marker}");
2888 let full_len = display_len(&full, length_mode);
2889 if !is_adjacent_to_prev && current_length > 0 {
2890 current_line.push(' ');
2891 current_length += 1;
2892 }
2893 current_line.push_str(&full);
2894 current_length += full_len;
2895 } else {
2896 for (i, word) in words.iter().enumerate() {
2897 let is_first = i == 0;
2898 let is_last = i == n - 1;
2899
2900 let space_start = if is_first && is_code && word.starts_with('`') {
2901 " "
2902 } else {
2903 ""
2904 };
2905 let space_end = if is_last && is_code && word.ends_with('`') {
2906 " "
2907 } else {
2908 ""
2909 };
2910
2911 let word_str: String = match (is_first, is_last) {
2912 (true, true) => format!("{marker}{space_start}{word}{space_end}{marker}"),
2913 (true, false) => format!("{marker}{space_start}{word}"),
2914 (false, true) => format!("{word}{space_end}{marker}"),
2915 (false, false) => word.to_string(),
2916 };
2917 let word_len = display_len(&word_str, length_mode);
2918
2919 let needs_space = if is_first {
2920 !is_adjacent_to_prev && current_length > 0
2921 } else {
2922 current_length > 0
2923 };
2924
2925 if needs_space
2926 && current_length + 1 + word_len > options.line_length
2927 && !starts_block_construct(&word_str)
2928 {
2929 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
2930 current_line = word_str;
2931 current_length = word_len;
2932 current_line_element_spans.clear();
2933 } else {
2934 if needs_space {
2935 current_line.push(' ');
2936 current_length += 1;
2937 }
2938 current_line.push_str(&word_str);
2939 current_length += word_len;
2940 }
2941 }
2942 }
2943 } else {
2944 let element_str = format!("{element}");
2947
2948 if is_adjacent_to_prev {
2949 if current_length + element_len > options.line_length
2951 && let Some(carried) = break_before_attached(
2952 &mut lines,
2953 &mut current_line,
2954 &mut current_length,
2955 &mut current_line_element_spans,
2956 &element_str,
2957 "",
2958 length_mode,
2959 )
2960 {
2961 current_line_element_spans.push((carried, carried + element_str.len()));
2965 } else {
2966 let start = current_line.len();
2967 current_line.push_str(&element_str);
2968 current_length += element_len;
2969 current_line_element_spans.push((start, current_line.len()));
2970 }
2971 } else if current_length > 0 && current_length + 1 + element_len > options.line_length {
2972 if !starts_block_construct(&element_str) {
2973 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
2975 current_line.clone_from(&element_str);
2976 current_length = element_len;
2977 current_line_element_spans.clear();
2978 current_line_element_spans.push((0, element_str.len()));
2979 } else if let Some(carried) = break_before_attached(
2980 &mut lines,
2981 &mut current_line,
2982 &mut current_length,
2983 &mut current_line_element_spans,
2984 &element_str,
2985 " ",
2986 length_mode,
2987 ) {
2988 let start = carried + 1;
2992 current_line_element_spans.push((start, start + element_str.len()));
2993 } else {
2994 let ends_with_opener =
2997 current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
2998 if !ends_with_opener {
2999 current_line.push(' ');
3000 current_length += 1;
3001 }
3002 let start = current_line.len();
3003 current_line.push_str(&element_str);
3004 current_length += element_len;
3005 current_line_element_spans.push((start, current_line.len()));
3006 }
3007 } else {
3008 let ends_with_opener =
3010 current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
3011 if current_length > 0 && !ends_with_opener {
3012 current_line.push(' ');
3013 current_length += 1;
3014 }
3015 let start = current_line.len();
3016 current_line.push_str(&element_str);
3017 current_length += element_len;
3018 current_line_element_spans.push((start, current_line.len()));
3019 }
3020 }
3021 }
3022 }
3023
3024 if !current_line.is_empty() {
3026 lines.push(current_line.trim_end_matches(is_breakable_whitespace).to_string());
3027 }
3028
3029 lines
3030}
3031
3032pub fn reflow_markdown(content: &str, options: &ReflowOptions) -> String {
3034 let lines: Vec<&str> = content.lines().collect();
3035 let mut result = Vec::new();
3036 let mut i = 0;
3037
3038 while i < lines.len() {
3039 let line = lines[i];
3040 let trimmed = line.trim();
3041
3042 if trimmed.is_empty() {
3044 result.push(String::new());
3045 i += 1;
3046 continue;
3047 }
3048
3049 if trimmed.starts_with('#') {
3051 result.push(line.to_string());
3052 i += 1;
3053 continue;
3054 }
3055
3056 if trimmed.starts_with(":::") {
3058 result.push(line.to_string());
3059 i += 1;
3060 continue;
3061 }
3062
3063 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
3065 result.push(line.to_string());
3066 i += 1;
3067 while i < lines.len() {
3069 result.push(lines[i].to_string());
3070 if lines[i].trim().starts_with("```") || lines[i].trim().starts_with("~~~") {
3071 i += 1;
3072 break;
3073 }
3074 i += 1;
3075 }
3076 continue;
3077 }
3078
3079 if calculate_indentation_width_default(line) >= 4 {
3081 result.push(line.to_string());
3083 i += 1;
3084 while i < lines.len() {
3085 let next_line = lines[i];
3086 if calculate_indentation_width_default(next_line) >= 4 || next_line.trim().is_empty() {
3088 result.push(next_line.to_string());
3089 i += 1;
3090 } else {
3091 break;
3092 }
3093 }
3094 continue;
3095 }
3096
3097 if trimmed.starts_with('>') {
3099 let gt_pos = line.find('>').expect("'>' must exist since trimmed.starts_with('>')");
3102 let quote_prefix = line[0..=gt_pos].to_string();
3103 let quote_content = &line[quote_prefix.len()..].trim_start();
3104
3105 let reflowed = reflow_line(quote_content, options);
3106 for reflowed_line in &reflowed {
3107 result.push(format!("{quote_prefix} {reflowed_line}"));
3108 }
3109 i += 1;
3110 continue;
3111 }
3112
3113 if is_horizontal_rule(trimmed) {
3115 result.push(line.to_string());
3116 i += 1;
3117 continue;
3118 }
3119
3120 if is_unordered_list_marker(trimmed) || is_numbered_list_item(trimmed) {
3122 let indent = line.len() - line.trim_start().len();
3124 let indent_str = " ".repeat(indent);
3125
3126 let mut marker_end = indent;
3129 let mut content_start = indent;
3130
3131 if trimmed.chars().next().is_some_and(char::is_numeric) {
3132 if let Some(period_pos) = line[indent..].find('.') {
3134 marker_end = indent + period_pos + 1; content_start = marker_end;
3136 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
3140 content_start += 1;
3141 }
3142 }
3143 } else {
3144 marker_end = indent + 1; content_start = marker_end;
3147 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
3151 content_start += 1;
3152 }
3153 }
3154
3155 let min_continuation_indent = content_start;
3157
3158 let rest = &line[content_start..];
3161 if rest.starts_with("[ ] ") || rest.starts_with("[x] ") || rest.starts_with("[X] ") {
3162 marker_end = content_start + 3; content_start += 4; }
3165
3166 let marker = &line[indent..marker_end];
3167
3168 let mut list_content = vec![trim_preserving_hard_break(&line[content_start..])];
3171 i += 1;
3172
3173 while i < lines.len() {
3177 let next_line = lines[i];
3178 let next_trimmed = next_line.trim();
3179
3180 if is_block_boundary(next_trimmed) {
3182 break;
3183 }
3184
3185 let next_indent = next_line.len() - next_line.trim_start().len();
3187 if next_indent >= min_continuation_indent {
3188 let trimmed_start = next_line.trim_start();
3191 list_content.push(trim_preserving_hard_break(trimmed_start));
3192 i += 1;
3193 } else {
3194 break;
3196 }
3197 }
3198
3199 let combined_content = if options.preserve_breaks {
3202 list_content[0].clone()
3203 } else {
3204 let has_hard_breaks = list_content.iter().any(|line| has_hard_break(line));
3206 if has_hard_breaks {
3207 list_content.join("\n")
3209 } else {
3210 list_content.join(" ")
3212 }
3213 };
3214
3215 let trimmed_marker = marker;
3217 let continuation_spaces = if let Some(max_indent) = options.max_list_continuation_indent {
3218 indent + (content_start - indent).min(max_indent)
3221 } else {
3222 content_start
3223 };
3224
3225 let prefix_length = indent + trimmed_marker.len() + 1;
3227
3228 let adjusted_options = ReflowOptions {
3230 line_length: options.line_length.saturating_sub(prefix_length),
3231 ..options.clone()
3232 };
3233
3234 let reflowed = reflow_line(&combined_content, &adjusted_options);
3235 for (j, reflowed_line) in reflowed.iter().enumerate() {
3236 if j == 0 {
3237 result.push(format!("{indent_str}{trimmed_marker} {reflowed_line}"));
3238 } else {
3239 let continuation_indent = " ".repeat(continuation_spaces);
3241 result.push(format!("{continuation_indent}{reflowed_line}"));
3242 }
3243 }
3244 continue;
3245 }
3246
3247 if crate::utils::table_utils::TableUtils::is_potential_table_row(line) {
3249 result.push(line.to_string());
3250 i += 1;
3251 continue;
3252 }
3253
3254 if trimmed.starts_with('[') && line.contains("]:") {
3256 result.push(line.to_string());
3257 i += 1;
3258 continue;
3259 }
3260
3261 if is_definition_list_item(trimmed) {
3263 result.push(line.to_string());
3264 i += 1;
3265 continue;
3266 }
3267
3268 let mut is_single_line_paragraph = true;
3270 if i + 1 < lines.len() {
3271 let next_trimmed = lines[i + 1].trim();
3272 if !is_block_boundary(next_trimmed) {
3274 is_single_line_paragraph = false;
3275 }
3276 }
3277
3278 if is_single_line_paragraph && display_len(line, options.length_mode) <= options.line_length {
3280 result.push(line.to_string());
3281 i += 1;
3282 continue;
3283 }
3284
3285 let mut paragraph_parts = Vec::new();
3287 let mut current_part = vec![line];
3288 i += 1;
3289
3290 if options.preserve_breaks {
3292 let hard_break_type = if line.strip_suffix('\r').unwrap_or(line).ends_with('\\') {
3294 Some("\\")
3295 } else if line.ends_with(" ") {
3296 Some(" ")
3297 } else {
3298 None
3299 };
3300 let reflowed = reflow_line(line, options);
3301
3302 if let Some(break_marker) = hard_break_type {
3304 if !reflowed.is_empty() {
3305 let mut reflowed_with_break = reflowed;
3306 let last_idx = reflowed_with_break.len() - 1;
3307 if !has_hard_break(&reflowed_with_break[last_idx]) {
3308 reflowed_with_break[last_idx].push_str(break_marker);
3309 }
3310 result.extend(reflowed_with_break);
3311 }
3312 } else {
3313 result.extend(reflowed);
3314 }
3315 } else {
3316 while i < lines.len() {
3318 let prev_line = if !current_part.is_empty() {
3319 current_part.last().unwrap()
3320 } else {
3321 ""
3322 };
3323 let next_line = lines[i];
3324 let next_trimmed = next_line.trim();
3325
3326 if is_block_boundary(next_trimmed) {
3328 break;
3329 }
3330
3331 let prev_trimmed = prev_line.trim();
3334 let abbreviations = get_abbreviations(&options.abbreviations);
3335 let ends_with_sentence = (prev_trimmed.ends_with('.')
3336 || prev_trimmed.ends_with('!')
3337 || prev_trimmed.ends_with('?')
3338 || prev_trimmed.ends_with(".*")
3339 || prev_trimmed.ends_with("!*")
3340 || prev_trimmed.ends_with("?*")
3341 || prev_trimmed.ends_with("._")
3342 || prev_trimmed.ends_with("!_")
3343 || prev_trimmed.ends_with("?_")
3344 || prev_trimmed.ends_with(".\"")
3346 || prev_trimmed.ends_with("!\"")
3347 || prev_trimmed.ends_with("?\"")
3348 || prev_trimmed.ends_with(".'")
3349 || prev_trimmed.ends_with("!'")
3350 || prev_trimmed.ends_with("?'")
3351 || prev_trimmed.ends_with(".\u{201D}")
3352 || prev_trimmed.ends_with("!\u{201D}")
3353 || prev_trimmed.ends_with("?\u{201D}")
3354 || prev_trimmed.ends_with(".\u{2019}")
3355 || prev_trimmed.ends_with("!\u{2019}")
3356 || prev_trimmed.ends_with("?\u{2019}"))
3357 && !text_ends_with_abbreviation(
3358 prev_trimmed.trim_end_matches(['*', '_', '"', '\'', '\u{201D}', '\u{2019}']),
3359 &abbreviations,
3360 );
3361
3362 if has_hard_break(prev_line) || (options.sentence_per_line && ends_with_sentence) {
3363 paragraph_parts.push(current_part.join(" "));
3365 current_part = vec![next_line];
3366 } else {
3367 current_part.push(next_line);
3368 }
3369 i += 1;
3370 }
3371
3372 if !current_part.is_empty() {
3374 if current_part.len() == 1 {
3375 paragraph_parts.push(current_part[0].to_string());
3377 } else {
3378 paragraph_parts.push(current_part.join(" "));
3379 }
3380 }
3381
3382 for (j, part) in paragraph_parts.iter().enumerate() {
3384 let reflowed = reflow_line(part, options);
3385 result.extend(reflowed);
3386
3387 if j < paragraph_parts.len() - 1 && !result.is_empty() && !options.sentence_per_line {
3391 let last_idx = result.len() - 1;
3392 if !has_hard_break(&result[last_idx]) {
3393 result[last_idx].push_str(" ");
3394 }
3395 }
3396 }
3397 }
3398 }
3399
3400 let result_text = result.join("\n");
3402 if content.ends_with('\n') && !result_text.ends_with('\n') {
3403 format!("{result_text}\n")
3404 } else {
3405 result_text
3406 }
3407}
3408
3409#[derive(Debug, Clone)]
3411pub struct ParagraphReflow {
3412 pub start_byte: usize,
3414 pub end_byte: usize,
3416 pub reflowed_text: String,
3418}
3419
3420#[derive(Debug, Clone)]
3426pub struct BlockquoteLineData {
3427 pub(crate) content: String,
3429 pub(crate) is_explicit: bool,
3431 pub(crate) prefix: Option<String>,
3433}
3434
3435impl BlockquoteLineData {
3436 pub fn explicit(content: String, prefix: String) -> Self {
3438 Self {
3439 content,
3440 is_explicit: true,
3441 prefix: Some(prefix),
3442 }
3443 }
3444
3445 pub fn lazy(content: String) -> Self {
3447 Self {
3448 content,
3449 is_explicit: false,
3450 prefix: None,
3451 }
3452 }
3453}
3454
3455#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3457pub enum BlockquoteContinuationStyle {
3458 Explicit,
3459 Lazy,
3460}
3461
3462pub fn blockquote_continuation_style(lines: &[BlockquoteLineData]) -> BlockquoteContinuationStyle {
3470 let mut explicit_count = 0usize;
3471 let mut lazy_count = 0usize;
3472
3473 for line in lines.iter().skip(1) {
3474 if line.is_explicit {
3475 explicit_count += 1;
3476 } else {
3477 lazy_count += 1;
3478 }
3479 }
3480
3481 if explicit_count > 0 && lazy_count == 0 {
3482 BlockquoteContinuationStyle::Explicit
3483 } else if lazy_count > 0 && explicit_count == 0 {
3484 BlockquoteContinuationStyle::Lazy
3485 } else if explicit_count >= lazy_count {
3486 BlockquoteContinuationStyle::Explicit
3487 } else {
3488 BlockquoteContinuationStyle::Lazy
3489 }
3490}
3491
3492pub fn dominant_blockquote_prefix(lines: &[BlockquoteLineData], fallback: &str) -> String {
3497 let mut counts: std::collections::HashMap<String, (usize, usize)> = std::collections::HashMap::new();
3498
3499 for (idx, line) in lines.iter().enumerate() {
3500 let Some(prefix) = line.prefix.as_ref() else {
3501 continue;
3502 };
3503 counts
3504 .entry(prefix.clone())
3505 .and_modify(|entry| entry.0 += 1)
3506 .or_insert((1, idx));
3507 }
3508
3509 counts
3510 .into_iter()
3511 .max_by(|(_, (count_a, first_idx_a)), (_, (count_b, first_idx_b))| {
3512 count_a.cmp(count_b).then_with(|| first_idx_b.cmp(first_idx_a))
3513 })
3514 .map_or_else(|| fallback.to_string(), |(prefix, _)| prefix)
3515}
3516
3517pub(crate) fn should_force_explicit_blockquote_line(content_line: &str) -> bool {
3522 let trimmed = content_line.trim_start();
3523 trimmed.starts_with('>')
3524 || trimmed.starts_with('#')
3525 || trimmed.starts_with("```")
3526 || trimmed.starts_with("~~~")
3527 || is_unordered_list_marker(trimmed)
3528 || is_numbered_list_item(trimmed)
3529 || is_horizontal_rule(trimmed)
3530 || is_definition_list_item(trimmed)
3531 || (trimmed.starts_with('[') && trimmed.contains("]:"))
3532 || trimmed.starts_with(":::")
3533 || (trimmed.starts_with('<')
3534 && !trimmed.starts_with("<http")
3535 && !trimmed.starts_with("<https")
3536 && !trimmed.starts_with("<mailto:"))
3537}
3538
3539pub fn reflow_blockquote_content(
3548 lines: &[BlockquoteLineData],
3549 explicit_prefix: &str,
3550 continuation_style: BlockquoteContinuationStyle,
3551 options: &ReflowOptions,
3552) -> Vec<String> {
3553 let content_strs: Vec<&str> = lines.iter().map(|l| l.content.as_str()).collect();
3554 let segments = split_into_segments_strs(&content_strs);
3555 let mut reflowed_content_lines: Vec<String> = Vec::new();
3556
3557 for segment in segments {
3558 let hard_break_type = segment.last().and_then(|&line| {
3559 let line = line.strip_suffix('\r').unwrap_or(line);
3560 if line.ends_with('\\') {
3561 Some("\\")
3562 } else if line.ends_with(" ") {
3563 Some(" ")
3564 } else {
3565 None
3566 }
3567 });
3568
3569 let pieces: Vec<&str> = segment
3570 .iter()
3571 .map(|&line| {
3572 if let Some(l) = line.strip_suffix('\\') {
3573 l.trim_end()
3574 } else if let Some(l) = line.strip_suffix(" ") {
3575 l.trim_end()
3576 } else {
3577 line.trim_end()
3578 }
3579 })
3580 .collect();
3581
3582 let segment_text = pieces.join(" ");
3583 let segment_text = segment_text.trim();
3584 if segment_text.is_empty() {
3585 continue;
3586 }
3587
3588 let mut reflowed = reflow_line(segment_text, options);
3589 if let Some(break_marker) = hard_break_type
3590 && !reflowed.is_empty()
3591 {
3592 let last_idx = reflowed.len() - 1;
3593 if !has_hard_break(&reflowed[last_idx]) {
3594 reflowed[last_idx].push_str(break_marker);
3595 }
3596 }
3597 reflowed_content_lines.extend(reflowed);
3598 }
3599
3600 let mut styled_lines: Vec<String> = Vec::new();
3601 for (idx, line) in reflowed_content_lines.iter().enumerate() {
3602 let force_explicit = idx == 0
3603 || continuation_style == BlockquoteContinuationStyle::Explicit
3604 || should_force_explicit_blockquote_line(line);
3605 if force_explicit {
3606 styled_lines.push(format!("{explicit_prefix}{line}"));
3607 } else {
3608 styled_lines.push(line.clone());
3609 }
3610 }
3611
3612 styled_lines
3613}
3614
3615fn is_blockquote_content_boundary(content: &str) -> bool {
3616 let trimmed = content.trim();
3617 trimmed.is_empty()
3618 || is_block_boundary(trimmed)
3619 || crate::utils::table_utils::TableUtils::is_potential_table_row(content)
3620 || trimmed.starts_with(":::")
3621 || crate::utils::is_template_directive_only(content)
3622 || is_standalone_attr_list(content)
3623 || is_snippet_block_delimiter(content)
3624}
3625
3626fn split_into_segments_strs<'a>(lines: &[&'a str]) -> Vec<Vec<&'a str>> {
3627 let mut segments = Vec::new();
3628 let mut current = Vec::new();
3629
3630 for &line in lines {
3631 current.push(line);
3632 if has_hard_break(line) {
3633 segments.push(current);
3634 current = Vec::new();
3635 }
3636 }
3637
3638 if !current.is_empty() {
3639 segments.push(current);
3640 }
3641
3642 segments
3643}
3644
3645fn reflow_blockquote_paragraph_at_line(
3646 content: &str,
3647 lines: &[&str],
3648 target_idx: usize,
3649 options: &ReflowOptions,
3650) -> Option<ParagraphReflow> {
3651 let mut anchor_idx = target_idx;
3652 let mut target_level = if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[target_idx]) {
3653 parsed.nesting_level
3654 } else {
3655 let mut found = None;
3656 let mut idx = target_idx;
3657 loop {
3658 if lines[idx].trim().is_empty() {
3659 break;
3660 }
3661 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[idx]) {
3662 found = Some((idx, parsed.nesting_level));
3663 break;
3664 }
3665 if idx == 0 {
3666 break;
3667 }
3668 idx -= 1;
3669 }
3670 let (idx, level) = found?;
3671 anchor_idx = idx;
3672 level
3673 };
3674
3675 let mut para_start = anchor_idx;
3677 while para_start > 0 {
3678 let prev_idx = para_start - 1;
3679 let prev_line = lines[prev_idx];
3680
3681 if prev_line.trim().is_empty() {
3682 break;
3683 }
3684
3685 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(prev_line) {
3686 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
3687 break;
3688 }
3689 para_start = prev_idx;
3690 continue;
3691 }
3692
3693 let prev_lazy = prev_line.trim_start();
3694 if is_blockquote_content_boundary(prev_lazy) {
3695 break;
3696 }
3697 para_start = prev_idx;
3698 }
3699
3700 while para_start < lines.len() {
3702 let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[para_start]) else {
3703 para_start += 1;
3704 continue;
3705 };
3706 target_level = parsed.nesting_level;
3707 break;
3708 }
3709
3710 if para_start >= lines.len() || para_start > target_idx {
3711 return None;
3712 }
3713
3714 let mut collected: Vec<(usize, BlockquoteLineData)> = Vec::new();
3717 let mut idx = para_start;
3718 while idx < lines.len() {
3719 if !collected.is_empty() && has_hard_break(&collected[collected.len() - 1].1.content) {
3720 break;
3721 }
3722
3723 let line = lines[idx];
3724 if line.trim().is_empty() {
3725 break;
3726 }
3727
3728 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(line) {
3729 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
3730 break;
3731 }
3732 collected.push((
3733 idx,
3734 BlockquoteLineData::explicit(trim_preserving_hard_break(parsed.content), parsed.prefix.to_string()),
3735 ));
3736 idx += 1;
3737 continue;
3738 }
3739
3740 let lazy_content = line.trim_start();
3741 if is_blockquote_content_boundary(lazy_content) {
3742 break;
3743 }
3744
3745 collected.push((idx, BlockquoteLineData::lazy(trim_preserving_hard_break(lazy_content))));
3746 idx += 1;
3747 }
3748
3749 if collected.is_empty() {
3750 return None;
3751 }
3752
3753 let para_end = collected[collected.len() - 1].0;
3754 if target_idx < para_start || target_idx > para_end {
3755 return None;
3756 }
3757
3758 let line_data: Vec<BlockquoteLineData> = collected.iter().map(|(_, d)| d.clone()).collect();
3759
3760 let fallback_prefix = line_data
3761 .iter()
3762 .find_map(|d| d.prefix.clone())
3763 .unwrap_or_else(|| "> ".to_string());
3764 let explicit_prefix = dominant_blockquote_prefix(&line_data, &fallback_prefix);
3765 let continuation_style = blockquote_continuation_style(&line_data);
3766
3767 let adjusted_line_length = options
3768 .line_length
3769 .saturating_sub(display_len(&explicit_prefix, options.length_mode))
3770 .max(1);
3771
3772 let adjusted_options = ReflowOptions {
3773 line_length: adjusted_line_length,
3774 ..options.clone()
3775 };
3776
3777 let styled_lines = reflow_blockquote_content(&line_data, &explicit_prefix, continuation_style, &adjusted_options);
3778
3779 if styled_lines.is_empty() {
3780 return None;
3781 }
3782
3783 let mut start_byte = 0;
3785 for line in lines.iter().take(para_start) {
3786 start_byte += line.len() + 1;
3787 }
3788
3789 let mut end_byte = start_byte;
3790 for line in lines.iter().take(para_end + 1).skip(para_start) {
3791 end_byte += line.len() + 1;
3792 }
3793
3794 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
3795 if !includes_trailing_newline {
3796 end_byte -= 1;
3797 }
3798
3799 let reflowed_joined = styled_lines.join("\n");
3800 let reflowed_text = if includes_trailing_newline {
3801 if reflowed_joined.ends_with('\n') {
3802 reflowed_joined
3803 } else {
3804 format!("{reflowed_joined}\n")
3805 }
3806 } else if reflowed_joined.ends_with('\n') {
3807 reflowed_joined.trim_end_matches('\n').to_string()
3808 } else {
3809 reflowed_joined
3810 };
3811
3812 Some(ParagraphReflow {
3813 start_byte,
3814 end_byte,
3815 reflowed_text,
3816 })
3817}
3818
3819pub fn reflow_paragraph_at_line(content: &str, line_number: usize, line_length: usize) -> Option<ParagraphReflow> {
3837 reflow_paragraph_at_line_with_mode(content, line_number, line_length, ReflowLengthMode::default())
3838}
3839
3840pub fn reflow_paragraph_at_line_with_mode(
3842 content: &str,
3843 line_number: usize,
3844 line_length: usize,
3845 length_mode: ReflowLengthMode,
3846) -> Option<ParagraphReflow> {
3847 let options = ReflowOptions {
3848 line_length,
3849 length_mode,
3850 ..Default::default()
3851 };
3852 reflow_paragraph_at_line_with_options(content, line_number, &options)
3853}
3854
3855pub fn reflow_paragraph_at_line_with_options(
3866 content: &str,
3867 line_number: usize,
3868 options: &ReflowOptions,
3869) -> Option<ParagraphReflow> {
3870 if line_number == 0 {
3871 return None;
3872 }
3873
3874 let lines: Vec<&str> = content.lines().collect();
3875
3876 if line_number > lines.len() {
3878 return None;
3879 }
3880
3881 let target_idx = line_number - 1; let target_line = lines[target_idx];
3883 let trimmed = target_line.trim();
3884
3885 if let Some(blockquote_reflow) = reflow_blockquote_paragraph_at_line(content, &lines, target_idx, options) {
3888 return Some(blockquote_reflow);
3889 }
3890
3891 if is_paragraph_boundary(trimmed, target_line) {
3893 return None;
3894 }
3895
3896 let mut para_start = target_idx;
3898 while para_start > 0 {
3899 let prev_idx = para_start - 1;
3900 let prev_line = lines[prev_idx];
3901 let prev_trimmed = prev_line.trim();
3902
3903 if is_paragraph_boundary(prev_trimmed, prev_line) {
3905 break;
3906 }
3907
3908 para_start = prev_idx;
3909 }
3910
3911 let mut para_end = target_idx;
3913 while para_end + 1 < lines.len() {
3914 let next_idx = para_end + 1;
3915 let next_line = lines[next_idx];
3916 let next_trimmed = next_line.trim();
3917
3918 if is_paragraph_boundary(next_trimmed, next_line) {
3920 break;
3921 }
3922
3923 para_end = next_idx;
3924 }
3925
3926 let paragraph_lines = &lines[para_start..=para_end];
3928
3929 let mut start_byte = 0;
3931 for line in lines.iter().take(para_start) {
3932 start_byte += line.len() + 1; }
3934
3935 let mut end_byte = start_byte;
3936 for line in paragraph_lines {
3937 end_byte += line.len() + 1; }
3939
3940 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
3943
3944 if !includes_trailing_newline {
3946 end_byte -= 1;
3947 }
3948
3949 let paragraph_text = paragraph_lines.join("\n");
3951
3952 let reflowed = reflow_markdown(¶graph_text, options);
3954
3955 let reflowed_text = if includes_trailing_newline {
3959 if reflowed.ends_with('\n') {
3961 reflowed
3962 } else {
3963 format!("{reflowed}\n")
3964 }
3965 } else {
3966 if reflowed.ends_with('\n') {
3968 reflowed.trim_end_matches('\n').to_string()
3969 } else {
3970 reflowed
3971 }
3972 };
3973
3974 Some(ParagraphReflow {
3975 start_byte,
3976 end_byte,
3977 reflowed_text,
3978 })
3979}
3980fn decompose_code_span(raw: &str) -> Option<(&str, &str)> {
3986 let marker_len = raw.bytes().take_while(|&b| b == b'`').count();
3987 if marker_len == 0 {
3988 return None;
3989 }
3990 let marker = &raw[..marker_len];
3991 if raw.len() < marker_len * 2 {
3992 return None;
3993 }
3994 let content = &raw[marker_len..raw.len() - marker_len];
3995 Some((content, marker))
3996}
3997
3998#[cfg(test)]
3999mod tests {
4000 use super::*;
4001
4002 #[test]
4003 fn cascade_split_line_handles_a_very_long_line_without_overflowing() {
4004 let words: Vec<String> = (0..4000).map(|i| format!("word{i}")).collect();
4010 let line = words.join(" ");
4011
4012 let options = ReflowOptions {
4013 line_length: 80,
4014 length_mode: ReflowLengthMode::Chars,
4015 ..Default::default()
4016 };
4017 let out = cascade_split_line(&line, &options);
4018
4019 assert!(out.len() > 1, "a very long line should split into many lines");
4020 for segment in &out {
4021 assert!(
4022 display_len(segment, ReflowLengthMode::Chars) <= 80 || !segment.contains(' '),
4023 "each wrapped line should fit the width (or be a single unbreakable token)"
4024 );
4025 }
4026 let rejoined = out.join(" ");
4028 let original_words: Vec<&str> = line.split(' ').collect();
4029 let result_words: Vec<&str> = rejoined.split_whitespace().collect();
4030 assert_eq!(original_words, result_words, "reflow must preserve all words in order");
4031 }
4032
4033 #[test]
4038 fn test_helper_function_text_ends_with_abbreviation() {
4039 let abbreviations = get_abbreviations(&None);
4041
4042 assert!(text_ends_with_abbreviation("Dr.", &abbreviations));
4044 assert!(text_ends_with_abbreviation("word Dr.", &abbreviations));
4045 assert!(text_ends_with_abbreviation("e.g.", &abbreviations));
4046 assert!(text_ends_with_abbreviation("i.e.", &abbreviations));
4047 assert!(text_ends_with_abbreviation("Mr.", &abbreviations));
4048 assert!(text_ends_with_abbreviation("Mrs.", &abbreviations));
4049 assert!(text_ends_with_abbreviation("Ms.", &abbreviations));
4050 assert!(text_ends_with_abbreviation("Prof.", &abbreviations));
4051
4052 assert!(!text_ends_with_abbreviation("etc.", &abbreviations));
4054 assert!(!text_ends_with_abbreviation("paradigms.", &abbreviations));
4055 assert!(!text_ends_with_abbreviation("programs.", &abbreviations));
4056 assert!(!text_ends_with_abbreviation("items.", &abbreviations));
4057 assert!(!text_ends_with_abbreviation("systems.", &abbreviations));
4058 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)); }
4064
4065 #[test]
4066 fn test_footnote_after_period_splits_sentence() {
4067 let text = "First sentence.[^1] Second sentence.";
4071 let sentences = split_into_sentences(text);
4072 assert_eq!(
4073 sentences,
4074 vec!["First sentence.[^1]".to_string(), "Second sentence.".to_string()],
4075 "footnote glued to the period should keep the boundary and stay attached to the first sentence"
4076 );
4077 }
4078
4079 #[test]
4080 fn test_multiple_consecutive_footnotes_after_period_splits_sentence() {
4081 let text = "Notes here.[^1][^2] Second sentence.";
4083 let sentences = split_into_sentences(text);
4084 assert_eq!(
4085 sentences,
4086 vec!["Notes here.[^1][^2]".to_string(), "Second sentence.".to_string()]
4087 );
4088 }
4089
4090 #[test]
4091 fn test_footnote_before_period_still_splits_sentence() {
4092 let text = "Annotation here[^1]. Second sentence.";
4096 let sentences = split_into_sentences(text);
4097 assert_eq!(
4098 sentences,
4099 vec!["Annotation here[^1].".to_string(), "Second sentence.".to_string()]
4100 );
4101 }
4102
4103 #[test]
4104 fn test_mid_sentence_footnote_does_not_split() {
4105 let text = "The system word[^1] more words. Next sentence.";
4108 let sentences = split_into_sentences(text);
4109 assert_eq!(
4110 sentences,
4111 vec![
4112 "The system word[^1] more words.".to_string(),
4113 "Next sentence.".to_string()
4114 ]
4115 );
4116 }
4117
4118 #[test]
4119 fn test_bare_numeric_bracket_after_period_does_not_split() {
4120 let text = "Citation here.[1] Second sentence.";
4123 let sentences = split_into_sentences(text);
4124 assert_eq!(
4125 sentences,
4126 vec![text.to_string()],
4127 "a bare numeric bracket must not be treated as a sentence boundary"
4128 );
4129 }
4130
4131 #[test]
4132 fn test_footnote_glued_to_following_word_does_not_split() {
4133 let text = "First sentence.[^1]Continued glued text.";
4136 let sentences = split_into_sentences(text);
4137 assert_eq!(sentences, vec![text.to_string()]);
4138 }
4139
4140 #[test]
4141 fn test_footnote_at_end_of_text_is_preserved() {
4142 let text = "Sentence.[^1]";
4145 let sentences = split_into_sentences(text);
4146 assert_eq!(sentences, vec![text.to_string()]);
4147 }
4148
4149 #[test]
4150 fn test_abbreviation_before_footnote_does_not_split() {
4151 let text = "See the notes, e.g.[^1] this one.";
4154 let sentences = split_into_sentences(text);
4155 assert_eq!(
4156 sentences,
4157 vec![text.to_string()],
4158 "e.g. is an abbreviation, not a sentence boundary"
4159 );
4160 }
4161
4162 #[test]
4163 fn test_is_unordered_list_marker() {
4164 assert!(is_unordered_list_marker("- item"));
4166 assert!(is_unordered_list_marker("* item"));
4167 assert!(is_unordered_list_marker("+ item"));
4168 assert!(is_unordered_list_marker("-")); assert!(is_unordered_list_marker("*"));
4170 assert!(is_unordered_list_marker("+"));
4171
4172 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")); }
4183
4184 #[test]
4185 fn test_is_block_boundary() {
4186 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"));
4208 assert!(!is_block_boundary("*emphasis*")); assert!(!is_block_boundary("[link](url)")); assert!(!is_block_boundary("some words here"));
4211 }
4212
4213 #[test]
4214 fn test_definition_list_boundary_in_single_line_paragraph() {
4215 let options = ReflowOptions {
4218 line_length: 80,
4219 ..Default::default()
4220 };
4221 let input = "Term\n: Definition of the term";
4222 let result = reflow_markdown(input, &options);
4223 assert!(
4225 result.contains(": Definition"),
4226 "Definition list item should not be merged into previous line. Got: {result:?}"
4227 );
4228 let lines: Vec<&str> = result.lines().collect();
4229 assert_eq!(lines.len(), 2, "Should remain two separate lines. Got: {lines:?}");
4230 assert_eq!(lines[0], "Term");
4231 assert_eq!(lines[1], ": Definition of the term");
4232 }
4233
4234 #[test]
4235 fn test_is_paragraph_boundary() {
4236 assert!(is_paragraph_boundary("# Heading", "# Heading"));
4238 assert!(is_paragraph_boundary("- item", "- item"));
4239 assert!(is_paragraph_boundary(":::", ":::"));
4240 assert!(is_paragraph_boundary(": definition", ": definition"));
4241
4242 assert!(is_paragraph_boundary("code", " code"));
4244 assert!(is_paragraph_boundary("code", "\tcode"));
4245
4246 assert!(is_paragraph_boundary("| a | b |", "| a | b |"));
4248 assert!(is_paragraph_boundary("a | b", "a | b")); assert!(!is_paragraph_boundary("regular text", "regular text"));
4252 assert!(!is_paragraph_boundary("text", " text")); }
4254
4255 #[test]
4256 fn test_div_marker_boundary_in_reflow_paragraph_at_line() {
4257 let content = "Some paragraph text here.\n\n::: {.callout-note}\nThis is a callout.\n:::\n";
4260 let result = reflow_paragraph_at_line(content, 3, 80);
4262 assert!(result.is_none(), "Div marker line should not be reflowed");
4263 }
4264
4265 #[test]
4266 fn starts_block_construct_detects_block_openers() {
4267 for case in ["- item", "-", "* item", "*", "+ item", "+", "-\titem"] {
4269 assert!(starts_block_construct(case), "bullet: {case:?}");
4270 }
4271 for case in ["1. item", "1) item", "9. x", "123456789. x", "1.", "42) x"] {
4273 assert!(starts_block_construct(case), "ordered: {case:?}");
4274 }
4275 for case in ["> quote", ">quote", ">"] {
4277 assert!(starts_block_construct(case), "blockquote: {case:?}");
4278 }
4279 for case in ["# heading", "###### h6", "#", "##"] {
4281 assert!(starts_block_construct(case), "heading: {case:?}");
4282 }
4283 for case in ["```", "```rust", "````", "~~~", "~~~text"] {
4285 assert!(starts_block_construct(case), "fence: {case:?}");
4286 }
4287 for case in ["---", "--", "===", "=", "***", "___", "_ _ _", "- - -"] {
4289 assert!(starts_block_construct(case), "setext/thematic: {case:?}");
4290 }
4291 for case in [
4294 "[^1]: text",
4295 "[^note]:",
4296 "[ref]: http://example.com",
4297 "[wat]: url follows",
4298 ] {
4299 assert!(starts_block_construct(case), "definition: {case:?}");
4300 }
4301 for case in ["<div>content", "</div>", "<p>text", "<table>", "<pre>code", "<h1>x"] {
4303 assert!(starts_block_construct(case), "html block: {case:?}");
4304 }
4305 }
4306
4307 #[test]
4308 fn starts_block_construct_allows_ordinary_prose() {
4309 for case in [
4310 "",
4311 "word",
4312 "-5 degrees",
4313 "--flag",
4314 "-item",
4315 "#hashtag",
4316 "####### seven hashes is not a heading",
4317 "1.5 million",
4318 "1234567890. ten digits is not a list marker",
4319 "1:30 pm",
4320 "*emphasis*",
4321 "**bold** text",
4322 "__bold__ text",
4323 "_emphasis_ text",
4324 "`code` span",
4325 "`` double backtick span ``",
4326 "~~strikethrough~~",
4327 "=x",
4328 "== ==",
4329 "(parenthetical)",
4330 "[link](url)",
4331 "[text][ref] more",
4332 "[bracketed] aside",
4333 "[a](b) [ref]: first bracket is a link, not a label",
4334 "[esc\\]: not a close] text",
4335 "<span>inline</span>",
4336 "<b>bold</b>",
4337 "<https://example.com> autolink",
4338 "<mailto:a@b.com>",
4339 "<notarealtag>",
4340 ] {
4341 assert!(!starts_block_construct(case), "prose: {case:?}");
4342 }
4343 }
4344
4345 #[test]
4346 fn merge_block_construct_continuations_merges_marker_led_lines() {
4347 let lines = vec![
4348 "First sentence?".to_string(),
4349 "- looks like a list item".to_string(),
4350 "Second sentence.".to_string(),
4351 ];
4352 assert_eq!(
4353 merge_block_construct_continuations(lines),
4354 vec![
4355 "First sentence? - looks like a list item".to_string(),
4356 "Second sentence.".to_string(),
4357 ]
4358 );
4359
4360 let lines = vec!["- real list content".to_string(), "continuation".to_string()];
4363 assert_eq!(
4364 merge_block_construct_continuations(lines.clone()),
4365 lines,
4366 "first line must never be merged"
4367 );
4368 }
4369
4370 #[test]
4371 fn wrap_never_starts_a_line_with_a_block_marker() {
4372 let options = ReflowOptions {
4373 line_length: 25,
4374 ..Default::default()
4375 };
4376 let lines = reflow_line(
4379 "Some words here and then - a dash clause that wraps around the limit.",
4380 &options,
4381 );
4382 assert_eq!(
4383 lines,
4384 vec![
4385 "Some words here and",
4386 "then - a dash clause that",
4387 "wraps around the limit."
4388 ]
4389 );
4390
4391 for input in [
4393 "Alpha beta gamma delta epsilon - dash clause here to wrap",
4394 "Alpha beta gamma delta epsilon > quote lookalike here to wrap",
4395 "Alpha beta gamma delta epsilon # heading lookalike here to wrap",
4396 "Alpha beta gamma delta epsilon 1. ordered lookalike here to wrap",
4397 "Alpha beta gamma delta epsilon * star clause here to wrap",
4398 "Alpha beta gamma delta epsilon + plus clause here to wrap",
4399 ] {
4400 for width in 10..40 {
4401 let options = ReflowOptions {
4402 line_length: width,
4403 ..Default::default()
4404 };
4405 for line in reflow_line(input, &options) {
4406 assert!(
4407 !starts_block_construct(&line),
4408 "width {width}: wrapped line opens a block construct: {line:?} (input {input:?})"
4409 );
4410 }
4411 }
4412 }
4413 }
4414
4415 #[test]
4416 fn sentence_per_line_keeps_block_markers_mid_line() {
4417 let options = ReflowOptions {
4418 line_length: 80,
4419 sentence_per_line: true,
4420 ..Default::default()
4421 };
4422 let lines = reflow_line(
4425 "Google Calendar (Can't we get rid of this dependency? - I don't really see the need)",
4426 &options,
4427 );
4428 assert_eq!(
4429 lines,
4430 vec!["Google Calendar (Can't we get rid of this dependency? - I don't really see the need)".to_string()]
4431 );
4432
4433 let lines = reflow_line("See section 4? # is the marker we use. Fine.", &options);
4435 assert_eq!(lines, vec!["See section 4? # is the marker we use.", "Fine."]);
4436
4437 let lines = reflow_line("Is this a problem? > I quote someone here.", &options);
4438 assert_eq!(lines, vec!["Is this a problem? > I quote someone here."]);
4439
4440 let lines = reflow_line("Another case! 1. Not a list. More text follows here.", &options);
4441 for line in &lines {
4442 assert!(
4443 !starts_block_construct(line),
4444 "sentence-per-line output opens a block construct: {line:?}"
4445 );
4446 }
4447 }
4448
4449 #[test]
4450 fn inline_math_directly_after_display_math_stays_atomic() {
4451 let options = ReflowOptions {
4459 line_length: 8,
4460 ..Default::default()
4461 };
4462 let lines = reflow_line("$$a$$$bb cc dd$ x", &options);
4463 assert_eq!(lines, vec!["$$a$$$bb cc dd$".to_string(), "x".to_string()]);
4464 }
4465
4466 #[test]
4467 fn test_code_span_parsing() {
4468 let elements = parse_markdown_elements_inner("`code`", false, false, None);
4470 assert_eq!(elements.len(), 1);
4471 assert!(matches!(&elements[0], Element::Code { content, marker } if content == "code" && marker == "`"));
4472
4473 let elements = parse_markdown_elements_inner("``code``", false, false, None);
4475 assert_eq!(elements.len(), 1);
4476 assert!(matches!(&elements[0], Element::Code { content, marker } if content == "code" && marker == "``"));
4477
4478 let elements = parse_markdown_elements_inner("``code`inside``", false, false, None);
4480 assert_eq!(elements.len(), 1);
4481 assert!(
4482 matches!(&elements[0], Element::Code { content, marker } if content == "code`inside" && marker == "``")
4483 );
4484
4485 let elements = parse_markdown_elements_inner("`` code ``", false, false, None);
4487 assert_eq!(elements.len(), 1);
4488 assert!(matches!(&elements[0], Element::Code { content, marker } if content == " code " && marker == "``"));
4489
4490 let elements = parse_markdown_elements_inner("`unclosed", false, false, None);
4492 assert_eq!(elements.len(), 1);
4493 assert!(matches!(&elements[0], Element::Text(s) if s == "`unclosed"));
4494
4495 let elements = parse_markdown_elements_inner("`unclosed [link](url)", false, false, None);
4497 assert_eq!(elements.len(), 2);
4499 assert!(matches!(&elements[0], Element::Text(s) if s == "`unclosed "));
4500 assert!(matches!(&elements[1], Element::Link(s) if s == "[link](url)"));
4501 }
4502
4503 #[test]
4504 fn test_reflow_performance_long_input() {
4505 let mut text = String::new();
4508 for i in 1..400 {
4509 let backticks = "`".repeat(i);
4510 text.push_str(&backticks);
4511 text.push(' ');
4512 }
4513
4514 let start = std::time::Instant::now();
4515 let elements = parse_markdown_elements_inner(&text, false, false, None);
4516 let duration = start.elapsed();
4517
4518 assert!(duration.as_millis() < 100, "Parsing took too long: {duration:?}");
4520 assert!(!elements.is_empty());
4521 }
4522
4523 #[test]
4524 fn test_reflow_performance_display_math_heavy() {
4525 let text = "$$a$$".repeat(4000);
4530
4531 let start = std::time::Instant::now();
4532 let elements = parse_markdown_elements_inner(&text, false, false, None);
4533 let duration = start.elapsed();
4534
4535 assert!(duration.as_millis() < 100, "Parsing took too long: {duration:?}");
4536 assert_eq!(elements.len(), 4000);
4537 }
4538
4539 #[test]
4540 fn inline_math_len_at_start_matches_regex_at_slice_start() {
4541 let alphabet = ['$', 'a', ' '];
4546 let mut inputs: Vec<String> = vec![String::new()];
4547 let mut frontier: Vec<String> = vec![String::new()];
4548 for _ in 0..6 {
4549 let mut longer = Vec::new();
4550 for prefix in &frontier {
4551 for ch in alphabet {
4552 let mut s = prefix.clone();
4553 s.push(ch);
4554 longer.push(s);
4555 }
4556 }
4557 inputs.extend(longer.iter().cloned());
4558 frontier = longer;
4559 }
4560 inputs.push("$αβ$x".to_string());
4562 inputs.push("$α$$".to_string());
4563
4564 for s in &inputs {
4565 let expected = INLINE_MATH_REGEX
4566 .find(s)
4567 .ok()
4568 .flatten()
4569 .filter(|m| m.start() == 0)
4570 .map(|m| m.end());
4571 assert_eq!(inline_math_len_at_start(s), expected, "input: {s:?}");
4572 }
4573 }
4574
4575 #[test]
4576 fn inline_math_probe_after_dollar_matches_uncached_parse() {
4577 let cases = [
4583 ("$$a$$$b c$ x", r#"[DisplayMath("a"), InlineMath("b c"), Text(" x")]"#),
4584 (
4585 "$$a$$$b$ $$a$$$b$",
4586 r#"[DisplayMath("a"), InlineMath("b"), Text(" "), DisplayMath("a"), InlineMath("b")]"#,
4587 ),
4588 (
4590 "$$a$$$ x $y z$",
4591 r#"[DisplayMath("a"), InlineMath(" x "), Text("y z$")]"#,
4592 ),
4593 ("$$a$$$$ x", r#"[DisplayMath("a"), Text("$$ x")]"#),
4595 ("$$a$$$$b$$ x", r#"[DisplayMath("a"), DisplayMath("b"), Text(" x")]"#),
4596 (
4598 "$a$$b$$c$$d$ tail",
4599 r#"[Text("$a"), DisplayMath("b"), Text("c$$d$ tail")]"#,
4600 ),
4601 ];
4602 for (input, expected) in cases {
4603 let elements = parse_markdown_elements_inner(input, false, false, None);
4604 assert_eq!(format!("{elements:?}"), expected, "input: {input:?}");
4605 }
4606 }
4607
4608 #[test]
4609 fn test_atomic_spans() {
4610 let text_emphasis = "hello **word1 word2**";
4612
4613 let options_disabled = ReflowOptions {
4614 line_length: 18,
4615 atomic_spans: true,
4616 ..Default::default()
4617 };
4618 let lines_disabled = reflow_line(text_emphasis, &options_disabled);
4619 assert_eq!(lines_disabled, vec!["hello", "**word1 word2**"]);
4620
4621 let options_enabled = ReflowOptions {
4622 line_length: 18,
4623 atomic_spans: false,
4624 ..Default::default()
4625 };
4626 let lines_enabled = reflow_line(text_emphasis, &options_enabled);
4627 assert_eq!(lines_enabled, vec!["hello **word1", "word2**"]);
4628
4629 let text_code = "hello `word1 word2`";
4631
4632 let lines_code_disabled = reflow_line(text_code, &options_disabled);
4633 assert_eq!(lines_code_disabled, vec!["hello", "`word1 word2`"]);
4634
4635 let lines_code_enabled = reflow_line(text_code, &options_enabled);
4636 assert_eq!(lines_code_enabled, vec!["hello `word1", "word2`"]);
4637
4638 let text_code_padding = "hello `` `word1` `word2` ``";
4640 let lines_padding_enabled = reflow_line(text_code_padding, &options_enabled);
4641 assert_eq!(lines_padding_enabled, vec![r#"hello `` `word1`"#, r#"`word2` ``"#]);
4642 }
4643
4644 #[test]
4645 fn test_emphasis_containing_markers_is_not_split() {
4646 let options = ReflowOptions {
4647 line_length: 5,
4648 atomic_spans: false,
4649 ..Default::default()
4650 };
4651 let lines = reflow_line(r#"*foo \*bar*"#, &options);
4653 assert_eq!(lines, vec![r#"*foo \*bar*"#.to_string()]);
4654 }
4655
4656 fn semantic_shape(markdown: &str) -> String {
4661 let mut options = Options::empty();
4662 options.insert(Options::ENABLE_STRIKETHROUGH);
4663 let mut out = String::new();
4664 let push_prose = |out: &mut String, text: &str| {
4665 for c in text.chars() {
4666 if c.is_whitespace() {
4667 if !out.ends_with(char::is_whitespace) {
4668 out.push(' ');
4669 }
4670 } else {
4671 out.push(c);
4672 }
4673 }
4674 };
4675 for event in Parser::new_ext(markdown, options) {
4676 match event {
4677 Event::Text(text) => push_prose(&mut out, &text),
4678 Event::SoftBreak | Event::HardBreak => push_prose(&mut out, " "),
4679 Event::Code(code) => out.push_str(&format!("<code>{code}</code>")),
4681 Event::Start(tag) => out.push_str(&format!("<{tag:?}>")),
4682 Event::End(tag) => out.push_str(&format!("</{tag:?}>")),
4683 other => out.push_str(&format!("{other:?}")),
4684 }
4685 }
4686 out.trim().to_string()
4687 }
4688
4689 #[test]
4690 fn test_wrapping_a_span_never_changes_what_it_parses_to() {
4691 let corpus = [
4695 "_This is a very, very, very, very, very long line with some `code` inside._",
4696 "_alpha beta gamma delta epsilon `a b` zeta eta theta iota kappa lambda_",
4697 "**strong text with `code` and more words than fit on one single line**",
4698 "~~struck text with `code` and more words than fit on one single line~~",
4699 "_emphasis with **nested strong that is quite long** and trailing words_",
4700 "_foo `a` bar `b` baz qux quux corge grault garply waldo fred plugh xyzzy_",
4701 "text before _a long emphasis with `code` inside of it here_ and after",
4702 "(_a parenthesized long emphasis with `code` inside of it right here_)",
4703 r#"*foo \*bar baz qux quux corge grault garply waldo fred plugh xyzzy*"#,
4704 "_tab\tseparated `a\tb` words spread out over quite a long emphasis span_",
4705 "_This has [text](<a b c d e f g h i j k>) and trailing words to wrap._",
4708 "_This has [`code`](<a b c d e f g h i j k>) and trailing words to wrap._",
4709 r#"_See [x](https://example.com "a long link title here") and `code` too._"#,
4710 "_A [link with a long label](https://example.com/path) and `code` here._",
4711 "_An image  plus `code` and more text_",
4712 ];
4713 for text in corpus {
4714 let expected = semantic_shape(text);
4715 for line_length in [20, 30, 40, 80] {
4716 for atomic_spans in [true, false] {
4717 let options = ReflowOptions {
4718 line_length,
4719 atomic_spans,
4720 ..Default::default()
4721 };
4722 let wrapped = reflow_line(text, &options).join("\n");
4723 assert_eq!(
4724 semantic_shape(&wrapped),
4725 expected,
4726 "reflow changed the parse of {text:?} at line_length={line_length} \
4727 atomic_spans={atomic_spans}\n wrapped: {wrapped:?}"
4728 );
4729 }
4730 }
4731 }
4732 }
4733
4734 #[test]
4735 fn test_wrapping_a_span_keeps_whole_the_constructs_pulldown_cannot_see() {
4736 let cases = [
4740 (
4741 "_alpha beta gamma [[a wiki link]] delta epsilon zeta eta_",
4742 "[[a wiki link]]",
4743 ),
4744 (
4745 "_alpha beta gamma {{< foo bar >}} delta epsilon zeta eta_",
4746 "{{< foo bar >}}",
4747 ),
4748 ("_alpha beta gamma $a + b$ delta epsilon zeta eta theta_", "$a + b$"),
4749 ("_alpha beta gamma $$a + b$$ delta epsilon zeta eta theta_", "$$a + b$$"),
4750 ];
4751 for (text, construct) in cases {
4752 for line_length in [12, 20, 30] {
4753 for atomic_spans in [true, false] {
4754 let options = ReflowOptions {
4755 line_length,
4756 atomic_spans,
4757 ..Default::default()
4758 };
4759 let wrapped = reflow_line(text, &options).join("\n");
4760 assert!(
4761 wrapped.contains(construct),
4762 "{construct} was broken at line_length={line_length} \
4763 atomic_spans={atomic_spans}: {wrapped:?}"
4764 );
4765 }
4766 }
4767 }
4768 }
4769
4770 #[test]
4771 fn test_overlong_emphasis_with_nested_code_span_wraps() {
4772 let options = ReflowOptions {
4776 line_length: 80,
4777 atomic_spans: true,
4778 ..Default::default()
4779 };
4780 let text = "_This is a very, very, very, very, very, very, very long line that exceeds 80 characters with some `code` inside._";
4781 let lines = reflow_line(text, &options);
4782 assert_eq!(
4783 lines,
4784 vec![
4785 "_This is a very, very, very, very, very, very, very long line that exceeds 80",
4786 "characters with some `code` inside._",
4787 ]
4788 );
4789 }
4790
4791 #[test]
4792 fn test_overlong_emphasis_with_nested_strong_wraps() {
4793 let options = ReflowOptions {
4795 line_length: 80,
4796 atomic_spans: true,
4797 ..Default::default()
4798 };
4799 let text = "_This is a very, very, very, very, very, very, very long line that exceeds 80 characters with some **bold** inside._";
4800 let lines = reflow_line(text, &options);
4801 assert_eq!(
4802 lines,
4803 vec![
4804 "_This is a very, very, very, very, very, very, very long line that exceeds 80",
4805 "characters with some **bold** inside._",
4806 ]
4807 );
4808 }
4809
4810 #[test]
4811 fn test_overlong_emphasis_never_breaks_inside_nested_code_span() {
4812 let options = ReflowOptions {
4816 line_length: 30,
4817 atomic_spans: true,
4818 ..Default::default()
4819 };
4820 let text = "_alpha beta gamma delta epsilon `a b` zeta eta theta iota kappa_";
4821 let lines = reflow_line(text, &options);
4822 assert!(lines.len() > 1, "over-long emphasis should wrap: {lines:?}");
4823 assert!(
4824 lines.iter().any(|line| line.contains("`a b`")),
4825 "nested code span must stay whole with its interior spaces: {lines:?}"
4826 );
4827 for line in &lines {
4828 assert_eq!(
4829 line.matches('`').count() % 2,
4830 0,
4831 "no line may contain half a code span: {line:?}"
4832 );
4833 }
4834 }
4835
4836 #[test]
4837 fn test_definition_list_marker_does_not_start_line() {
4838 let options = ReflowOptions {
4839 line_length: 20,
4840 ..Default::default()
4841 };
4842 let lines = reflow_line("This is a term and : definition here.", &options);
4844 for line in &lines {
4845 assert!(
4846 !line.trim_start().starts_with(": "),
4847 "Wrapped line should not start with definition marker: {line}"
4848 );
4849 }
4850 }
4851
4852 #[test]
4853 fn test_div_marker_does_not_start_line() {
4854 let options = ReflowOptions {
4855 line_length: 20,
4856 ..Default::default()
4857 };
4858 let lines = reflow_line("This is some text with ::: class marker.", &options);
4860 for line in &lines {
4861 assert!(
4862 !line.trim_start().starts_with(":::"),
4863 "Wrapped line should not start with div marker: {line}"
4864 );
4865 }
4866 }
4867}