1use crate::utils::calculate_indentation_width_default;
7use crate::utils::mkdocs_attr_list::{ATTR_LIST_PATTERN, is_standalone_attr_list};
8use crate::utils::mkdocs_snippets::is_snippet_block_delimiter;
9use crate::utils::regex_cache::{
10 DISPLAY_MATH_REGEX, EMAIL_PATTERN, EMOJI_SHORTCODE_REGEX, HTML_ENTITY_REGEX, HTML_TAG_PATTERN, INLINE_MATH_REGEX,
11 WIKI_LINK_REGEX,
12};
13use crate::utils::sentence_utils::{
14 get_abbreviations, is_after_sentence_ending_with_abbreviations, is_cjk_char, is_cjk_sentence_ending,
15 is_closing_bracket, is_closing_quote, is_opening_quote, text_ends_with_abbreviation,
16 text_ends_with_numbered_abbreviation,
17};
18use crate::utils::unicode::joins_cjk_soft_break;
19use pulldown_cmark::{BrokenLink, CowStr, Event, LinkType, Options, Parser, Tag, TagEnd};
20use std::collections::HashSet;
21use std::sync::LazyLock;
22use unicode_width::UnicodeWidthStr;
23
24#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
26#[serde(rename_all = "kebab-case")]
27pub enum CjkSoftBreak {
28 #[default]
30 Space,
31 Join,
34}
35
36#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
39#[serde(rename_all = "kebab-case")]
40pub enum LinkTextPunctuation {
41 #[default]
43 Ignore,
44 Trailing,
46}
47
48#[derive(Clone, Copy)]
52struct TerminalLink {
53 start: usize,
54 end: usize,
55 punctuation: char,
56}
57
58#[derive(Clone, Copy, Debug, Default, PartialEq)]
60pub enum ReflowLengthMode {
61 Chars,
63 #[default]
65 Visual,
66 Bytes,
68}
69
70pub(crate) fn display_len(s: &str, mode: ReflowLengthMode) -> usize {
72 match mode {
73 ReflowLengthMode::Chars => s.chars().count(),
74 ReflowLengthMode::Visual => s.width(),
75 ReflowLengthMode::Bytes => s.len(),
76 }
77}
78
79fn is_breakable_whitespace(c: char) -> bool {
83 matches!(c, ' ' | '\t' | '\n' | '\r')
84}
85
86fn is_significant_whitespace(c: char) -> bool {
93 c.is_whitespace() && !is_breakable_whitespace(c)
94}
95
96pub(crate) fn trim_breakable_whitespace(text: &str) -> &str {
99 text.trim_matches(is_breakable_whitespace)
100}
101
102fn split_breakable_words(text: &str) -> impl Iterator<Item = &str> {
104 text.split(is_breakable_whitespace).filter(|word| !word.is_empty())
105}
106
107fn split_breakable_words_with_gaps(text: &str) -> Vec<(&str, &str)> {
111 let mut tokens = Vec::new();
112 let mut rest = text;
113 while let Some(start) = rest.find(|c| !is_breakable_whitespace(c)) {
114 let (gap, from_word) = rest.split_at(start);
115 let end = from_word.find(is_breakable_whitespace).unwrap_or(from_word.len());
116 let (word, after) = from_word.split_at(end);
117 tokens.push((word, gap));
118 rest = after;
119 }
120 tokens
121}
122
123fn gap_separator(line: &str, gap: &str, abbreviations: Option<&HashSet<String>>) -> &'static str {
128 match abbreviations {
129 Some(abbreviations)
130 if gap.len() >= 2 && is_after_sentence_ending_with_abbreviations(line, line.len(), abbreviations) =>
131 {
132 " "
133 }
134 _ => " ",
135 }
136}
137
138fn gap_before_element(elements: &[Element], idx: usize) -> &str {
142 match idx.checked_sub(1).map(|previous| &elements[previous]) {
143 Some(Element::Text(previous)) => &previous[previous.trim_end_matches(is_breakable_whitespace).len()..],
144 _ => "",
145 }
146}
147
148fn code_span_wraps_losslessly(content: &str) -> bool {
157 let mut prev_ws = false;
158 for c in content.chars() {
159 let ws = is_breakable_whitespace(c);
160 if ws && (prev_ws || c != ' ') {
161 return false;
162 }
163 prev_ws = ws;
164 }
165 true
166}
167
168struct NestedStructure {
171 parentheticals: Vec<(usize, usize)>,
173 terminal_links: Vec<TerminalLink>,
175 atomic: Vec<(usize, usize)>,
182 markers: Vec<(usize, usize)>,
187 marker_closers: Vec<(usize, usize)>,
193 links: Vec<(usize, usize)>,
198 code_spans: Vec<(usize, usize)>,
201}
202
203struct OpenSpan {
205 span: (usize, usize),
207 content: Option<(usize, usize)>,
210}
211
212fn note_span_content(open: &mut [OpenSpan], start: usize, end: usize) {
215 for open_span in open.iter_mut() {
216 if start >= open_span.span.0 && end <= open_span.span.1 {
217 open_span.content = Some(match open_span.content {
218 Some((known_start, known_end)) => (known_start.min(start), known_end.max(end)),
219 None => (start, end),
220 });
221 }
222 }
223}
224
225fn merge_ranges(mut ranges: Vec<(usize, usize)>) -> Vec<(usize, usize)> {
226 ranges.sort_unstable();
229 let mut merged: Vec<(usize, usize)> = Vec::with_capacity(ranges.len());
230 for (start, end) in ranges {
231 match merged.last_mut() {
232 Some(last) if start <= last.1 => last.1 = last.1.max(end),
233 _ => merged.push((start, end)),
234 }
235 }
236 merged
237}
238
239fn nested_structure(content: &str, defined_references: Option<&HashSet<String>>, attr_lists: bool) -> NestedStructure {
241 let mut options = Options::empty();
242 options.insert(Options::ENABLE_STRIKETHROUGH);
243
244 let mut atomic: Vec<(usize, usize)> = Vec::new();
245 let mut markers: Vec<(usize, usize)> = Vec::new();
246 let mut marker_closers: Vec<(usize, usize)> = Vec::new();
247 let mut links: Vec<(usize, usize)> = Vec::new();
248 let mut code_spans: Vec<(usize, usize)> = Vec::new();
249 let mut open: Vec<OpenSpan> = Vec::new();
252
253 let indent = content.len() - content.trim_start_matches([' ', '\t']).len();
258 for (event, range) in Parser::new_ext(&content[indent..], options).into_offset_iter() {
259 let (start, end) = (range.start + indent, range.end + indent);
260 if !matches!(event, Event::End(_)) {
264 note_span_content(&mut open, start, end);
265 }
266 match event {
267 Event::Start(Tag::Link { .. } | Tag::Image { .. }) => {
268 atomic.push((start, end));
269 links.push((start, end));
270 }
271 Event::Code(_) => {
272 atomic.push((start, end));
273 code_spans.push((start, end));
274 }
275 Event::InlineHtml(_) => {
276 atomic.push((start, end));
277 }
278 Event::Start(Tag::Emphasis | Tag::Strong | Tag::Strikethrough) => {
279 open.push(OpenSpan {
280 span: (start, end),
281 content: None,
282 });
283 }
284 Event::End(TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough) => {
285 if let Some(OpenSpan {
286 span: (span_start, span_end),
287 content,
288 }) = open.pop()
289 {
290 match content {
291 Some((content_start, content_end)) => {
295 markers.push((span_start, content_start));
296 markers.push((content_end, span_end));
297 marker_closers.push((content_end, span_end));
298 }
299 None => atomic.push((span_start, span_end)),
302 }
303 }
304 }
305 _ => {}
306 }
307 }
308
309 for span in all_link_spans(content, defined_references) {
316 atomic.push((span.start, span.end));
317 links.push((span.start, span.end));
318 }
319
320 for found in WIKI_LINK_REGEX.find_iter(content) {
324 atomic.push((found.start(), found.end()));
325 links.push((found.start(), found.end()));
326 }
327 atomic.extend(crate::utils::shortcode_utils::shortcode_ranges(content));
328
329 let held = merge_ranges(atomic.clone());
338 let mut masked = String::with_capacity(content.len());
339 let mut next_held = 0;
340 for (pos, ch) in content.char_indices() {
341 while held.get(next_held).is_some_and(|&(_, end)| end <= pos) {
342 next_held += 1;
343 }
344 if held.get(next_held).is_some_and(|&(start, _)| start <= pos) {
345 masked.extend(std::iter::repeat_n(' ', ch.len_utf8()));
346 } else {
347 masked.push(ch);
348 }
349 }
350 for found in DISPLAY_MATH_REGEX.find_iter(&masked) {
351 atomic.push((found.start(), found.end()));
352 }
353 let mut from = 0;
354 while let Ok(Some(found)) = INLINE_MATH_REGEX.find_from_pos(&masked, from) {
355 atomic.push((found.start(), found.end()));
356 from = found.end();
357 }
358
359 if attr_lists {
365 for found in ATTR_LIST_PATTERN.find_iter(content) {
366 atomic.push((found.start(), found.end()));
367 }
368 }
369
370 links.sort_unstable();
373 links.dedup();
374
375 NestedStructure {
382 parentheticals: Vec::new(),
383 terminal_links: Vec::new(),
384 atomic: merge_ranges(atomic),
385 markers: merge_ranges(markers),
386 marker_closers: merge_ranges(marker_closers),
387 links,
388 code_spans,
389 }
390}
391
392fn breakable_units<'a>(
415 content: &'a str,
416 defined_references: Option<&HashSet<String>>,
417 attr_lists: bool,
418) -> Option<Vec<&'a str>> {
419 if !content.contains(['`', '*', '_', '~', '[', '<', '$', '{']) {
422 return Some(split_breakable_words(content).collect());
423 }
424
425 let NestedStructure { atomic, markers, .. } = nested_structure(content, defined_references, attr_lists);
426
427 let mut units = Vec::new();
428 let mut unit_start = None;
429 let mut next_atomic = 0;
430 let mut next_marker = 0;
431 for (offset, ch) in content.char_indices() {
432 while atomic.get(next_atomic).is_some_and(|&(_, end)| end <= offset) {
433 next_atomic += 1;
434 }
435 if atomic.get(next_atomic).is_some_and(|&(start, _)| offset >= start) {
436 if unit_start.is_none() {
439 unit_start = Some(offset);
440 }
441 continue;
442 }
443 while markers.get(next_marker).is_some_and(|&(_, end)| end <= offset) {
444 next_marker += 1;
445 }
446 if matches!(ch, '`' | '*' | '_' | '~') && markers.get(next_marker).is_none_or(|&(start, _)| offset < start) {
447 return None;
448 }
449 if is_breakable_whitespace(ch) {
450 if let Some(start) = unit_start.take() {
451 units.push(&content[start..offset]);
452 }
453 } else if unit_start.is_none() {
454 unit_start = Some(offset);
455 }
456 }
457 if let Some(start) = unit_start {
458 units.push(&content[start..]);
459 }
460 Some(units)
461}
462
463pub(crate) fn link_text_break_units<'a>(
483 inner: &'a str,
484 suffix: &str,
485 budget: usize,
486 mode: ReflowLengthMode,
487 defined_references: Option<&HashSet<String>>,
488 attr_lists: bool,
489) -> Option<Vec<&'a str>> {
490 if display_len(suffix, mode) > budget && display_len(inner, mode) + 2 <= budget {
491 return None;
492 }
493 let units = breakable_units(inner, defined_references, attr_lists)?;
494 if units.len() < 2 {
495 return None;
496 }
497 let tail = format!("{}{suffix}", units[units.len() - 1]);
498 if display_len(&tail, mode) > budget && !last_token_overflow_only(&tail, budget, mode) {
499 return None;
500 }
501 Some(units)
502}
503
504fn last_token_overflow_only(line: &str, budget: usize, mode: ReflowLengthMode) -> bool {
510 match line.rfind(char::is_whitespace) {
511 None => true,
512 Some(pos) => {
513 let ws_len = line[pos..].chars().next().map_or(1, char::len_utf8);
514 display_len(&line[..pos + ws_len], mode) < budget
515 }
516 }
517}
518
519#[derive(Clone)]
521pub struct ReflowOptions {
522 pub line_length: usize,
524 pub break_on_sentences: bool,
526 pub preserve_breaks: bool,
528 pub sentence_per_line: bool,
530 pub sentence_pack: bool,
532 pub first_line_length: Option<usize>,
534 pub semantic_line_breaks: bool,
536 pub abbreviations: Option<Vec<String>>,
540 pub break_words: Vec<String>,
543 pub length_mode: ReflowLengthMode,
545 pub attr_lists: bool,
548 pub myst_roles: bool,
552 pub require_sentence_capital: bool,
557 pub link_text_punctuation: LinkTextPunctuation,
559 pub max_list_continuation_indent: Option<usize>,
563 pub defined_references: Option<HashSet<String>>,
577 pub atomic_spans: bool,
581 pub wrap_code_spans: bool,
585 pub break_link_text: bool,
594 pub sentence_double_space: bool,
602 pub length_exemptions: LengthExemptions,
605 pub cjk_soft_break: CjkSoftBreak,
607}
608
609#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
616pub struct LengthExemptions {
617 pub link_urls: bool,
620 pub code_spans: bool,
622}
623
624impl LengthExemptions {
625 fn any(&self) -> bool {
628 self.link_urls || self.code_spans
629 }
630}
631
632impl Default for ReflowOptions {
633 fn default() -> Self {
634 Self {
635 line_length: 80,
636 break_on_sentences: true,
637 preserve_breaks: false,
638 sentence_per_line: false,
639 sentence_pack: false,
640 first_line_length: None,
641 semantic_line_breaks: false,
642 abbreviations: None,
643 break_words: Vec::new(),
644 length_mode: ReflowLengthMode::default(),
645 attr_lists: false,
646 myst_roles: false,
647 require_sentence_capital: true,
648 link_text_punctuation: LinkTextPunctuation::default(),
649 max_list_continuation_indent: None,
650 defined_references: None,
651 atomic_spans: true,
652 wrap_code_spans: true,
653 break_link_text: false,
654 sentence_double_space: false,
655 length_exemptions: LengthExemptions::default(),
656 cjk_soft_break: CjkSoftBreak::default(),
657 }
658 }
659}
660
661#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
668struct LineWidth {
669 link_exempt: usize,
671 code_exempt: usize,
673}
674
675impl LineWidth {
676 fn plain(width: usize) -> Self {
679 Self {
680 link_exempt: width,
681 code_exempt: width,
682 }
683 }
684
685 fn effective(self) -> usize {
688 self.link_exempt.min(self.code_exempt)
689 }
690
691 fn fits(self, line_length: usize) -> bool {
692 self.effective() <= line_length
693 }
694
695 fn is_empty(self) -> bool {
699 self.link_exempt == 0 && self.code_exempt == 0
700 }
701}
702
703impl std::ops::Add for LineWidth {
704 type Output = Self;
705
706 fn add(self, other: Self) -> Self {
707 Self {
708 link_exempt: self.link_exempt + other.link_exempt,
709 code_exempt: self.code_exempt + other.code_exempt,
710 }
711 }
712}
713
714impl std::ops::AddAssign for LineWidth {
715 fn add_assign(&mut self, other: Self) {
716 *self = *self + other;
717 }
718}
719
720pub fn normalize_reference_label(label: &str) -> String {
727 label.split_whitespace().collect::<Vec<_>>().join(" ").to_lowercase()
728}
729
730fn footnote_ref_end(chars: &[char], start: usize) -> Option<usize> {
736 if chars.get(start) != Some(&'[') || chars.get(start + 1) != Some(&'^') {
737 return None;
738 }
739 let label_start = start + 2;
740 let mut label_end = label_start;
741 while matches!(chars.get(label_end), Some(c) if c.is_ascii_alphanumeric() || *c == '_' || *c == '-') {
742 label_end += 1;
743 }
744 (label_end > label_start && chars.get(label_end) == Some(&']')).then_some(label_end + 1)
745}
746
747fn footnote_refs_end(chars: &[char], start: usize) -> Option<usize> {
751 let mut end = footnote_ref_end(chars, start)?;
752 while let Some(after_ref) = footnote_ref_end(chars, end) {
753 end = after_ref;
754 }
755 Some(end)
756}
757
758fn char_byte_offsets(chars: &[char]) -> Vec<usize> {
761 let mut offsets = Vec::with_capacity(chars.len() + 1);
762 let mut offset = 0;
763 for c in chars {
764 offsets.push(offset);
765 offset += c.len_utf8();
766 }
767 offsets.push(offset);
768 offsets
769}
770
771struct SentenceText<'a> {
776 text: &'a str,
777 chars: &'a [char],
778 char_offsets: &'a [usize],
779 links: &'a [(usize, usize)],
780 code_spans: &'a [(usize, usize)],
781 markers: &'a [(usize, usize)],
782 marker_closers: &'a [(usize, usize)],
783 paragraph: Option<ParagraphStructure<'a>>,
788 cjk_soft_break: CjkSoftBreak,
791}
792
793impl SentenceText<'_> {
794 fn in_span_delimiter(&self, pos: usize) -> bool {
799 let Some(&offset) = self.char_offsets.get(pos) else {
800 return false;
801 };
802 match self.markers.binary_search_by_key(&offset, |&(start, _)| start) {
803 Ok(_) => true,
804 Err(0) => false,
805 Err(i) => offset < self.markers[i - 1].1,
806 }
807 }
808
809 fn span_closer_end(&self, pos: usize) -> Option<usize> {
815 let &offset = self.char_offsets.get(pos)?;
816 let covering = match self.marker_closers.binary_search_by_key(&offset, |&(start, _)| start) {
817 Ok(i) => i,
818 Err(0) => return None,
819 Err(i) if offset < self.marker_closers[i - 1].1 => i - 1,
820 Err(_) => return None,
821 };
822 let end = self.marker_closers[covering].1;
823 Some(self.char_offsets.binary_search(&end).unwrap_or_else(|i| i))
824 }
825
826 fn opens_code_span(&self, pos: usize) -> bool {
831 self.char_offsets
832 .get(pos)
833 .is_some_and(|&start| self.code_spans.binary_search_by_key(&start, |&(s, _)| s).is_ok())
834 }
835
836 fn link_end_at(&self, pos: usize) -> Option<usize> {
845 let range_start = match self.chars.get(pos) {
846 Some('[') => pos,
847 Some('!') if self.chars.get(pos + 1) == Some(&'[') => match self.link_range_end_at(pos) {
848 Some(end) => return Some(end),
849 None => pos + 1,
850 },
851 _ => return None,
852 };
853 self.link_range_end_at(range_start)
854 }
855
856 fn link_range_end_at(&self, pos: usize) -> Option<usize> {
858 let start = self.char_offsets[pos];
859 let idx = self.links.binary_search_by_key(&start, |&(s, _)| s).ok()?;
860 let end = self.links[idx].1;
861 Some(self.char_offsets.binary_search(&end).unwrap_or_else(|i| i))
862 }
863
864 fn byte_at(&self, pos: usize) -> usize {
866 self.char_offsets.get(pos).copied().unwrap_or(self.text.len())
867 }
868
869 fn cut_keeps_text(&self, cut: usize, resume: usize) -> bool {
878 if cut < resume {
879 return true;
880 }
881 let resume_byte = self.byte_at(resume);
882 let rest = match self.paragraph {
883 Some(paragraph) => ¶graph.text[paragraph.base + resume_byte..],
884 None => &self.text[resume_byte..],
885 };
886 !starts_block_construct(rest)
887 }
888}
889
890#[derive(Clone, Copy)]
903struct ParagraphStructure<'a> {
904 text: &'a str,
905 structure: &'a NestedStructure,
906 base: usize,
907}
908
909impl ParagraphStructure<'_> {
910 fn window(&self, len: usize) -> NestedStructure {
922 let base = self.base;
923 let window_end = base + len;
924 let inside = |ranges: &[(usize, usize)]| -> Vec<(usize, usize)> {
925 let first = ranges.partition_point(|&(_, end)| end <= base);
926 let past = first + ranges[first..].partition_point(|&(start, _)| start < window_end);
927 ranges[first..past]
928 .iter()
929 .map(|&(start, end)| (start.saturating_sub(base), (end - base).min(len)))
930 .collect()
931 };
932 let starting_inside = |ranges: &[(usize, usize)]| -> Vec<(usize, usize)> {
933 let first = ranges.partition_point(|&(start, _)| start < base);
934 let past = first + ranges[first..].partition_point(|&(start, _)| start < window_end);
935 ranges[first..past]
936 .iter()
937 .map(|&(start, end)| (start - base, (end - base).min(len)))
938 .collect()
939 };
940 let first_terminal = self.structure.terminal_links.partition_point(|link| link.start < base);
941 let past_terminal = first_terminal
942 + self.structure.terminal_links[first_terminal..].partition_point(|link| link.start < window_end);
943 NestedStructure {
944 parentheticals: inside(&self.structure.parentheticals),
945 terminal_links: self.structure.terminal_links[first_terminal..past_terminal]
947 .iter()
948 .filter(|link| link.end <= window_end)
949 .map(|link| TerminalLink {
950 start: link.start - base,
951 end: link.end - base,
952 ..*link
953 })
954 .collect(),
955 atomic: inside(&self.structure.atomic),
956 markers: inside(&self.structure.markers),
957 marker_closers: inside(&self.structure.marker_closers),
958 links: starting_inside(&self.structure.links),
959 code_spans: starting_inside(&self.structure.code_spans),
960 }
961 }
962}
963
964fn marker_run_extent(chars: &[char], from: usize) -> usize {
971 let mut end = from;
972 while end < chars.len() && matches!(chars[end], '*' | '_' | '~') {
973 end += 1;
974 }
975 end
976}
977
978fn delimiter_run_extent(chars: &[char], from: usize) -> usize {
984 let Some(&first) = chars.get(from) else {
985 return from;
986 };
987 if !matches!(first, '*' | '_' | '~') {
988 return from;
989 }
990 let mut end = from;
991 while chars.get(end) == Some(&first) {
992 end += 1;
993 }
994 end
995}
996
997fn cjk_sentence_end(st: &SentenceText<'_>, pos: usize) -> Option<usize> {
1035 let mut end = pos + 1;
1036 loop {
1037 if let Some(after_ref) = footnote_ref_end(st.chars, end)
1038 && st.link_range_end_at(end).is_none_or(|link_end| link_end == after_ref)
1039 {
1040 end = after_ref;
1041 continue;
1042 }
1043 let Some(&next) = st.chars.get(end) else {
1044 return Some(end);
1045 };
1046 if is_closing_bracket(next) || is_closing_quote(next) {
1047 end += 1;
1048 continue;
1049 }
1050 if !matches!(next, '*' | '_' | '~') {
1051 return Some(end);
1052 }
1053 if let Some(closer_end) = st.span_closer_end(end) {
1054 if st
1055 .chars
1056 .get(closer_end)
1057 .is_some_and(|after| Some(after) == st.chars.get(closer_end - 1))
1058 {
1059 return None;
1060 }
1061 end = closer_end;
1062 continue;
1063 }
1064 if st.in_span_delimiter(end) {
1065 return Some(end);
1066 }
1067 let run_end = delimiter_run_extent(st.chars, end);
1068 match st.chars.get(run_end) {
1069 None => return Some(run_end),
1070 Some(&after) if is_breakable_whitespace(after) => return Some(run_end),
1071 Some(_) => return None,
1072 }
1073 }
1074}
1075
1076fn sentence_boundary(
1088 st: &SentenceText<'_>,
1089 pos: usize,
1090 abbreviations: &HashSet<String>,
1091 require_sentence_capital: bool,
1092) -> Option<usize> {
1093 let SentenceText { text, chars, .. } = *st;
1094 if pos + 1 >= chars.len() {
1095 return None;
1096 }
1097 let byte_offset_after_punct = st.char_offsets[pos + 1];
1098
1099 let c = chars[pos];
1100 let next_char = chars[pos + 1];
1101
1102 if is_cjk_sentence_ending(c) {
1105 let cut = cjk_sentence_end(st, pos)?;
1110
1111 let mut after_punct_pos = cut;
1113 while after_punct_pos < chars.len() && is_breakable_whitespace(chars[after_punct_pos]) {
1114 after_punct_pos += 1;
1115 }
1116 let resume = after_punct_pos;
1117
1118 if after_punct_pos >= chars.len() {
1121 return None;
1122 }
1123
1124 let break_dropped = st.cjk_soft_break == CjkSoftBreak::Join
1131 && joins_cjk_soft_break(chars[cut - 1])
1132 && joins_cjk_soft_break(chars[resume]);
1133 if (resume > cut) == break_dropped {
1134 return None;
1135 }
1136
1137 if continues_clause(chars[resume]) {
1141 return None;
1142 }
1143
1144 if opens_ordered_list_marker(&chars[after_punct_pos..]) {
1147 return None;
1148 }
1149
1150 while after_punct_pos < chars.len()
1152 && (chars[after_punct_pos] == '*' || chars[after_punct_pos] == '_' || chars[after_punct_pos] == '~')
1153 {
1154 after_punct_pos += 1;
1155 }
1156
1157 if after_punct_pos >= chars.len() {
1158 return None;
1159 }
1160
1161 return st.cut_keeps_text(cut, resume).then_some(cut);
1164 }
1165
1166 if c != '.' && c != '!' && c != '?' {
1168 return None;
1169 }
1170
1171 let inside_quotation = is_closing_quote(next_char);
1174
1175 let (space_pos, after_space_pos) = if matches!(next_char, ' ' | '\n') {
1178 (pos + 1, pos + 2)
1180 } else if is_closing_quote(next_char) && pos + 2 < chars.len() {
1181 match marker_run_end(chars, pos + 2) {
1184 Some(end) => (end, end + 1),
1185 None => return None,
1186 }
1187 } else if matches!(next_char, '*' | '_' | '~') {
1188 match marker_run_end(chars, pos + 1) {
1192 Some(end) => (end, end + 1),
1193 None => return None,
1194 }
1195 } else if next_char == '[' {
1196 match footnote_refs_end(chars, pos + 1) {
1202 Some(end_pos) if matches!(chars.get(end_pos), Some(' ' | '\n')) => (end_pos, end_pos + 1),
1203 _ => return None,
1204 }
1205 } else {
1206 return None;
1207 };
1208
1209 let mut next_char_pos = after_space_pos;
1211 while next_char_pos < chars.len() && is_breakable_whitespace(chars[next_char_pos]) {
1212 next_char_pos += 1;
1213 }
1214
1215 if next_char_pos >= chars.len() {
1217 return None;
1218 }
1219
1220 if opens_ordered_list_marker(&chars[next_char_pos..]) {
1229 return None;
1230 }
1231
1232 let mut first_letter_pos = next_char_pos;
1238 while first_letter_pos < chars.len() {
1239 let ch = chars[first_letter_pos];
1240 if let Some(end) = st.link_end_at(first_letter_pos) {
1241 first_letter_pos += link_opener_len(chars, first_letter_pos, end);
1242 } else if matches!(ch, '*' | '_' | '~') || is_opening_quote(ch) {
1243 first_letter_pos += 1;
1244 } else {
1245 break;
1246 }
1247 }
1248
1249 if first_letter_pos >= chars.len() {
1251 return None;
1252 }
1253
1254 let first_char = chars[first_letter_pos];
1255
1256 if c == '!' || c == '?' {
1262 if inside_quotation && require_sentence_capital && !opens_sentence_in_strict_mode(first_char) {
1263 return None;
1264 }
1265 return Some(space_pos);
1266 }
1267
1268 if pos > 0 {
1274 if matches!(next_char, ' ' | '\n') && chars[pos - 1].is_ascii_digit() {
1278 let mut digit_start = pos - 1;
1279 while digit_start > 0 && chars[digit_start - 1].is_ascii_digit() {
1280 digit_start -= 1;
1281 }
1282 let start = st.byte_at(digit_start);
1283 if st.markers.iter().any(|&(open, end)| {
1284 end == start
1285 && !st
1286 .marker_closers
1287 .iter()
1288 .any(|&(close, end)| close <= open && open < end)
1289 }) {
1290 return None;
1291 }
1292 }
1293
1294 if text_ends_with_abbreviation(&text[..byte_offset_after_punct], abbreviations)
1296 || text_ends_with_numbered_abbreviation(
1297 &text[..byte_offset_after_punct],
1298 &text[st.byte_at(first_letter_pos)..],
1299 )
1300 {
1301 return None;
1302 }
1303
1304 if single_letter_initial(chars, pos, next_char_pos, first_char) {
1309 return None;
1310 }
1311 }
1312
1313 let elision = pos > 0 && chars[pos - 1] == '.';
1324 let digit_run = pos > 0 && chars[pos - 1].is_numeric();
1325 let bare = space_pos == pos + 1;
1326
1327 let numeric_label = digit_run && bare && {
1332 let mut start = pos;
1333 while start > 0 && chars[start - 1].is_ascii_digit() {
1334 start -= 1;
1335 }
1336 start == 0 || chars[start - 1] == '.' || chars[..start].iter().rev().find(|c| !c.is_whitespace()) == Some(&':')
1337 };
1338 static SENTENCE_FINAL_ABBREVIATIONS: LazyLock<HashSet<String>> =
1342 LazyLock::new(|| ["etc", "inc", "ph.d", "u.s"].into_iter().map(String::from).collect());
1343 let ends_with_optional_abbreviation =
1344 text_ends_with_abbreviation(&text[..byte_offset_after_punct], &SENTENCE_FINAL_ABBREVIATIONS);
1345 if chars[space_pos..next_char_pos].contains(&'\n')
1346 && !inside_quotation
1347 && !elision
1348 && !numeric_label
1349 && !ends_with_optional_abbreviation
1350 {
1351 return Some(space_pos);
1352 }
1353
1354 if st.opens_code_span(first_letter_pos) && !elision && !(digit_run && bare) {
1360 return Some(space_pos);
1361 }
1362
1363 if require_sentence_capital && !opens_sentence_in_strict_mode(first_char) {
1366 return None;
1367 }
1368
1369 Some(space_pos)
1370}
1371
1372fn single_letter_initial(chars: &[char], period: usize, next_word: usize, first_char: char) -> bool {
1375 if period == 0 || !chars[period - 1].is_ascii_uppercase() || (period > 1 && !chars[period - 2].is_whitespace()) {
1376 return false;
1377 }
1378
1379 let before = &chars[..period - 1];
1380 let end = before.iter().rposition(|c| !c.is_whitespace()).map_or(0, |i| i + 1);
1381 if end == 0 {
1382 return true;
1383 }
1384 let before = &before[..end];
1385 let start = before.iter().rposition(|c| c.is_whitespace()).map_or(0, |i| i + 1);
1386 let previous_word = &before[start..];
1387 let previous_ender = previous_word
1388 .iter()
1389 .rev()
1390 .copied()
1391 .find(|&c| !matches!(c, '*' | '_' | '~') && !is_closing_quote(c) && !is_closing_bracket(c));
1392 if previous_word
1395 .iter()
1396 .any(|&c| matches!(c, '.' | '!' | '?' | '<' | '>' | '&') || is_cjk_sentence_ending(c))
1397 || previous_ender.is_some_and(continues_clause)
1398 || !previous_word
1399 .iter()
1400 .find(|c| c.is_alphabetic())
1401 .is_some_and(|c| c.is_lowercase())
1402 || !opens_sentence_in_strict_mode(first_char)
1403 {
1404 return true;
1405 }
1406
1407 chars.get(next_word).is_some_and(char::is_ascii_uppercase)
1410 && chars.get(next_word + 1) == Some(&'.')
1411 && chars.get(next_word + 2).is_none_or(|c| c.is_whitespace())
1412}
1413
1414fn marker_run_end(chars: &[char], from: usize) -> Option<usize> {
1422 let end = marker_run_extent(chars, from);
1423 matches!(chars.get(end), Some(' ' | '\n')).then_some(end)
1424}
1425
1426fn opens_sentence_in_strict_mode(first_char: char) -> bool {
1434 first_char.is_uppercase() || first_char.is_numeric() || is_cjk_char(first_char)
1435}
1436
1437fn continues_clause(c: char) -> bool {
1440 matches!(
1441 c,
1442 ',' | ';' | ':' | '.' | '!' | '?' | '、' | ',' | ';' | ':' | '.' | '。' | '、'
1443 ) || is_cjk_sentence_ending(c)
1444}
1445
1446fn opens_ordered_list_marker(chars: &[char]) -> bool {
1452 let digits = chars.iter().take_while(|c| c.is_ascii_digit()).count();
1453 digits > 0
1454 && matches!(chars.get(digits), Some('.' | ')'))
1455 && matches!(chars.get(digits + 1), Some(' ' | '\t' | '\n'))
1456}
1457
1458fn link_opener_len(chars: &[char], pos: usize, end: usize) -> usize {
1464 let open = if chars[pos] == '!' { pos + 1 } else { pos };
1465 let body = open + 1;
1466 if chars.get(body) != Some(&'[') {
1467 return body - pos;
1468 }
1469 let body = body + 1;
1470 let alias = chars[body..end.saturating_sub(2).max(body)]
1471 .iter()
1472 .position(|&c| c == '|')
1473 .map_or(body, |p| body + p + 1);
1474 alias - pos
1475}
1476
1477fn parse_terminal_links(
1484 text: &str,
1485 references: Option<&HashSet<String>>,
1486 abbreviations: &HashSet<String>,
1487) -> Vec<TerminalLink> {
1488 if !text.contains('[') {
1489 return Vec::new();
1490 }
1491 struct Label {
1492 start: usize,
1493 link_type: LinkType,
1494 visible: String,
1495 literal_terminal: bool,
1496 }
1497 let resolve = |link: BrokenLink<'_>| {
1498 references
1499 .filter(|defs| defs.contains(&normalize_reference_label(&link.reference)))
1500 .map(|_| (CowStr::Borrowed(""), CowStr::Borrowed("")))
1501 };
1502 let parser = Parser::new_with_broken_link_callback(
1503 text,
1504 Options::ENABLE_STRIKETHROUGH | Options::ENABLE_FOOTNOTES,
1505 Some(resolve),
1506 )
1507 .into_offset_iter();
1508 let mut labels: Vec<Label> = Vec::new();
1509 let mut result = Vec::new();
1510 for (event, range) in parser {
1511 match event {
1512 Event::Start(Tag::Link { link_type, .. } | Tag::Image { link_type, .. }) => {
1513 labels.push(Label {
1514 start: range.start,
1515 link_type,
1516 visible: String::new(),
1517 literal_terminal: false,
1518 });
1519 }
1520 Event::Text(value) => {
1521 if let Some(label) = labels.last_mut() {
1522 label.visible.push_str(&value);
1523 let visible = value.trim_end_matches(is_closing_quote);
1524 if let Some(last) = visible.chars().next_back() {
1525 let source = text[range].trim_end_matches(is_closing_quote);
1527 label.literal_terminal =
1528 (matches!(last, '.' | '!' | '?') || is_cjk_sentence_ending(last)) && source.ends_with(last);
1529 }
1530 }
1531 }
1532 Event::End(TagEnd::Link | TagEnd::Image) => {
1533 let Some(label) = labels.pop() else { continue };
1534 let mut end = range.end;
1535 if matches!(label.link_type, LinkType::Collapsed | LinkType::CollapsedUnknown)
1536 && text[end..].starts_with("[]")
1537 {
1538 end += 2;
1542 }
1543 if let Some(parent) = labels.last_mut() {
1544 parent.visible.push_str(&label.visible);
1545 parent.literal_terminal =
1546 label.literal_terminal && !matches!(label.link_type, LinkType::Autolink | LinkType::Email);
1547 } else if !matches!(label.link_type, LinkType::Autolink | LinkType::Email)
1548 && label.literal_terminal
1549 && let Some(punctuation) = label_terminator(&label.visible, abbreviations)
1550 {
1551 result.push(TerminalLink {
1552 start: label.start,
1553 end,
1554 punctuation,
1555 });
1556 }
1557 }
1558 Event::Code(_)
1559 | Event::Html(_)
1560 | Event::InlineHtml(_)
1561 | Event::InlineMath(_)
1562 | Event::DisplayMath(_)
1563 | Event::FootnoteReference(_)
1564 | Event::SoftBreak
1565 | Event::HardBreak => {
1566 if let Some(label) = labels.last_mut() {
1567 label.visible.push(' ');
1568 label.literal_terminal = false;
1569 }
1570 }
1571 _ => {}
1572 }
1573 }
1574 result
1575}
1576
1577fn label_terminator(label: &str, abbreviations: &HashSet<String>) -> Option<char> {
1581 let label = label.trim_end_matches(is_closing_quote);
1582 let punctuation = label.chars().next_back()?;
1583 if !matches!(punctuation, '.' | '!' | '?') && !is_cjk_sentence_ending(punctuation) {
1584 return None;
1585 }
1586 let prefix = &label[..label.len() - punctuation.len_utf8()];
1587 if !prefix.chars().any(char::is_alphanumeric) {
1588 return None;
1589 }
1590 if punctuation != '.' {
1593 return Some(punctuation);
1594 }
1595 if punctuation == '.'
1597 && label[..label.len() - 1]
1598 .chars()
1599 .next_back()
1600 .is_some_and(char::is_numeric)
1601 {
1602 return None;
1603 }
1604 const STRICT_OPENER: &str = " Next";
1606 let probe = format!("{label}{STRICT_OPENER}");
1607 let chars: Vec<char> = probe.chars().collect();
1608 let offsets = char_byte_offsets(&chars);
1609 let st = SentenceText {
1610 text: &probe,
1611 chars: &chars,
1612 char_offsets: &offsets,
1613 links: &[],
1614 code_spans: &[],
1615 markers: &[],
1616 marker_closers: &[],
1617 paragraph: None,
1618 cjk_soft_break: CjkSoftBreak::Space,
1619 };
1620 let pos = label.chars().count() - 1;
1621 debug_assert_eq!(chars[pos], punctuation);
1622 sentence_boundary(&st, pos, abbreviations, true).map(|_| punctuation)
1623}
1624
1625fn terminal_link_boundary(st: &SentenceText<'_>, pos: usize, punctuation: char) -> Option<usize> {
1629 let cut = cjk_sentence_end(st, pos)?;
1630 let chars = st.chars;
1631 let mut resume = cut;
1632 while resume < chars.len() && is_breakable_whitespace(chars[resume]) {
1633 resume += 1;
1634 }
1635 if resume == chars.len() || opens_ordered_list_marker(&chars[resume..]) {
1636 return None;
1637 }
1638 if is_cjk_sentence_ending(punctuation) {
1639 let break_dropped = st.cjk_soft_break == CjkSoftBreak::Join
1640 && joins_cjk_soft_break(chars[cut - 1])
1641 && joins_cjk_soft_break(chars[resume]);
1642 if (resume > cut) == break_dropped || continues_clause(chars[resume]) {
1643 return None;
1644 }
1645 } else {
1646 if resume == cut {
1647 return None;
1648 }
1649 let mut first = resume;
1650 while first < chars.len() {
1651 if let Some(end) = st.link_end_at(first) {
1652 first += link_opener_len(chars, first, end);
1653 } else if matches!(chars[first], '*' | '_' | '~') || is_opening_quote(chars[first]) {
1654 first += 1;
1655 } else {
1656 break;
1657 }
1658 }
1659 if !chars.get(first).is_some_and(|&c| opens_sentence_in_strict_mode(c)) {
1660 return None;
1661 }
1662 }
1663 st.cut_keeps_text(cut, resume).then_some(cut)
1664}
1665
1666fn sentence_structure_with_links(
1667 text: &str,
1668 references: Option<&HashSet<String>>,
1669 abbreviations: &HashSet<String>,
1670 policy: LinkTextPunctuation,
1671 semantic: bool,
1672) -> NestedStructure {
1673 let mut structure = if semantic {
1674 semantic_sentence_structure(text, references)
1675 } else {
1676 sentence_structure(text, references)
1677 };
1678 if policy == LinkTextPunctuation::Trailing {
1679 structure.terminal_links = parse_terminal_links(text, references, abbreviations);
1680 }
1681 structure
1682}
1683
1684pub fn split_into_sentences(
1697 text: &str,
1698 defined_references: Option<&HashSet<String>>,
1699 require_sentence_capital: bool,
1700 cjk_soft_break: CjkSoftBreak,
1701) -> Vec<String> {
1702 let abbreviations = get_abbreviations(&None);
1703 split_into_sentences_with_set(
1704 text,
1705 &abbreviations,
1706 require_sentence_capital,
1707 cjk_soft_break,
1708 defined_references,
1709 None,
1710 LinkTextPunctuation::Ignore,
1711 )
1712}
1713
1714pub(crate) fn split_into_sentences_with_link_text(
1716 text: &str,
1717 references: Option<&HashSet<String>>,
1718 require_sentence_capital: bool,
1719 cjk: CjkSoftBreak,
1720 policy: LinkTextPunctuation,
1721 abbreviations: &Option<Vec<String>>,
1722) -> Vec<String> {
1723 let abbreviations = get_abbreviations(if policy == LinkTextPunctuation::Trailing {
1726 abbreviations
1727 } else {
1728 &None
1729 });
1730 split_into_sentences_with_set(
1731 text,
1732 &abbreviations,
1733 require_sentence_capital,
1734 cjk,
1735 references,
1736 None,
1737 policy,
1738 )
1739}
1740
1741pub(crate) fn semantic_sentence_count(
1744 text: &str,
1745 defined_references: Option<&HashSet<String>>,
1746 require_sentence_capital: bool,
1747 cjk_soft_break: CjkSoftBreak,
1748 abbreviations: &Option<Vec<String>>,
1749 link_text_punctuation: LinkTextPunctuation,
1750) -> usize {
1751 let abbreviations = get_abbreviations(abbreviations);
1752 let structure =
1753 sentence_structure_with_links(text, defined_references, &abbreviations, link_text_punctuation, true);
1754 split_into_sentence_ranges(
1755 text,
1756 &abbreviations,
1757 require_sentence_capital,
1758 cjk_soft_break,
1759 defined_references,
1760 Some(ParagraphStructure {
1761 text,
1762 structure: &structure,
1763 base: 0,
1764 }),
1765 link_text_punctuation,
1766 )
1767 .len()
1768}
1769
1770fn split_into_sentences_with_set(
1778 text: &str,
1779 abbreviations: &HashSet<String>,
1780 require_sentence_capital: bool,
1781 cjk_soft_break: CjkSoftBreak,
1782 defined_references: Option<&HashSet<String>>,
1783 paragraph: Option<ParagraphStructure<'_>>,
1784 link_text_punctuation: LinkTextPunctuation,
1785) -> Vec<String> {
1786 split_into_sentence_ranges(
1787 text,
1788 abbreviations,
1789 require_sentence_capital,
1790 cjk_soft_break,
1791 defined_references,
1792 paragraph,
1793 link_text_punctuation,
1794 )
1795 .into_iter()
1796 .map(|(start, end)| text[start..end].to_string())
1797 .collect()
1798}
1799
1800fn trim_range(text: &str, start: usize, end: usize) -> Option<(usize, usize)> {
1803 let slice = &text[start..end];
1804 let leading = slice.len() - slice.trim_start_matches(is_breakable_whitespace).len();
1805 let trailing = slice.len() - slice.trim_end_matches(is_breakable_whitespace).len();
1806 (leading + trailing < slice.len()).then(|| (start + leading, end - trailing))
1807}
1808
1809fn split_into_sentence_ranges(
1817 text: &str,
1818 abbreviations: &HashSet<String>,
1819 require_sentence_capital: bool,
1820 cjk_soft_break: CjkSoftBreak,
1821 defined_references: Option<&HashSet<String>>,
1822 paragraph: Option<ParagraphStructure<'_>>,
1823 link_text_punctuation: LinkTextPunctuation,
1824) -> Vec<(usize, usize)> {
1825 let char_vec: Vec<char> = text.chars().collect();
1826 let char_offsets = char_byte_offsets(&char_vec);
1827
1828 let NestedStructure {
1832 parentheticals,
1833 terminal_links,
1834 atomic,
1835 links,
1836 code_spans,
1837 markers,
1838 marker_closers,
1839 } = match paragraph {
1840 Some(paragraph) => paragraph.window(text.len()),
1841 None => sentence_structure_with_links(text, defined_references, abbreviations, link_text_punctuation, false),
1842 };
1843 let mut atomic_it = atomic.iter().peekable();
1844 let st = SentenceText {
1845 text,
1846 chars: &char_vec,
1847 char_offsets: &char_offsets,
1848 links: &links,
1849 code_spans: &code_spans,
1850 markers: &markers,
1851 marker_closers: &marker_closers,
1852 paragraph,
1853 cjk_soft_break,
1854 };
1855
1856 let next_sentence_start = |cut: usize| if char_vec.get(cut) == Some(&' ') { cut + 1 } else { cut };
1859
1860 let mut cuts: Vec<usize> = Vec::new();
1864 let mut pos = 0;
1865
1866 while pos < char_vec.len() {
1867 let byte_idx = char_offsets[pos];
1868
1869 while let Some(&&(_, end)) = atomic_it.peek() {
1871 if end <= byte_idx {
1872 atomic_it.next();
1873 } else {
1874 break;
1875 }
1876 }
1877
1878 let in_atomic = atomic_it
1880 .peek()
1881 .is_some_and(|&&(start, end)| byte_idx >= start && byte_idx < end);
1882
1883 let boundary = if in_atomic && !terminal_links.is_empty() {
1884 let end = st.byte_at(pos + 1);
1885 terminal_links
1889 .binary_search_by_key(&end, |link| link.end)
1890 .ok()
1891 .filter(|_| atomic_it.peek().is_some_and(|&&(_, atomic_end)| atomic_end == end))
1892 .and_then(|index| terminal_link_boundary(&st, pos, terminal_links[index].punctuation))
1893 } else if !in_atomic {
1894 sentence_boundary(&st, pos, abbreviations, require_sentence_capital)
1895 } else {
1896 None
1897 };
1898 if let Some(cut) = boundary
1899 && !inside_parenthetical(char_offsets[cut], &parentheticals)
1900 {
1901 pos = next_sentence_start(cut);
1904 cuts.push(cut);
1905 continue;
1906 }
1907
1908 pos += 1;
1909 }
1910
1911 let mut sentences = Vec::new();
1912 let mut sentence_start = 0;
1914 for &cut in &cuts {
1915 if let Some(range) = trim_range(text, sentence_start, char_offsets[cut]) {
1921 sentences.push(range);
1922 }
1923 sentence_start = char_offsets[next_sentence_start(cut)];
1924 }
1925
1926 if let Some(range) = trim_range(text, sentence_start, text.len()) {
1928 sentences.push(range);
1929 }
1930 sentences
1931}
1932
1933fn sentence_structure(text: &str, defined_references: Option<&HashSet<String>>) -> NestedStructure {
1949 let holds_construct = text.contains(['`', '[', '<', '$']);
1954 let holds_emphasis = text.contains(['*', '_', '~']);
1955 if !holds_construct && !holds_emphasis {
1956 return NestedStructure {
1957 parentheticals: Vec::new(),
1958 terminal_links: Vec::new(),
1959 atomic: Vec::new(),
1960 markers: Vec::new(),
1961 marker_closers: Vec::new(),
1962 links: Vec::new(),
1963 code_spans: Vec::new(),
1964 };
1965 }
1966 nested_structure(text, defined_references, false)
1967}
1968
1969fn semantic_sentence_structure(text: &str, defined_references: Option<&HashSet<String>>) -> NestedStructure {
1973 let mut structure = sentence_structure(text, defined_references);
1974 let (Some(open), Some(close)) = (text.find('('), text.rfind(')')) else {
1975 return structure;
1976 };
1977 if open >= close || !text[open..close].contains(['.', '!', '?', '。', '!', '?']) {
1980 return structure;
1981 }
1982 let mut atomic = structure.atomic.iter().peekable();
1983 let mut opens = Vec::new();
1984 let mut groups = Vec::new();
1985 let mut escaped = false;
1986 for (offset, character) in text.char_indices() {
1987 while atomic.peek().is_some_and(|&&(_, end)| end <= offset) {
1988 atomic.next();
1989 }
1990 if atomic
1991 .peek()
1992 .is_some_and(|&&(start, end)| start <= offset && offset < end)
1993 {
1994 escaped = false;
1995 continue;
1996 }
1997 if escaped {
1998 escaped = false;
1999 continue;
2000 }
2001 match character {
2002 '\\' => escaped = true,
2003 '(' => opens.push(offset),
2004 ')' => {
2005 if let Some(start) = opens.pop() {
2006 groups.push((start, offset + 1));
2007 }
2008 }
2009 _ => {}
2010 }
2011 }
2012 structure.parentheticals = merge_ranges(groups);
2013 structure
2014}
2015
2016fn inside_parenthetical(offset: usize, groups: &[(usize, usize)]) -> bool {
2018 let past = groups.partition_point(|&(start, _)| start < offset);
2019 past > 0 && offset < groups[past - 1].1
2020}
2021
2022fn is_horizontal_rule(line: &str) -> bool {
2024 if line.len() < 3 {
2025 return false;
2026 }
2027
2028 let mut chars = line.chars();
2031 let Some(first_char) = chars.next() else {
2032 return false;
2033 };
2034 if first_char != '-' && first_char != '_' && first_char != '*' {
2035 return false;
2036 }
2037
2038 let mut non_space_count = 1usize; for c in chars {
2040 if c == ' ' {
2041 continue;
2042 }
2043 if c != first_char {
2044 return false;
2045 }
2046 non_space_count += 1;
2047 }
2048 non_space_count >= 3
2049}
2050
2051fn is_numbered_list_item(line: &str) -> bool {
2053 let mut chars = line.chars();
2054
2055 if !chars.next().is_some_and(char::is_numeric) {
2057 return false;
2058 }
2059
2060 while let Some(c) = chars.next() {
2062 if c == '.' {
2063 return chars.next() == Some(' ');
2066 }
2067 if !c.is_numeric() {
2068 return false;
2069 }
2070 }
2071
2072 false
2073}
2074
2075fn is_unordered_list_marker(s: &str) -> bool {
2077 matches!(s.as_bytes().first(), Some(b'-' | b'*' | b'+'))
2078 && !is_horizontal_rule(s)
2079 && (s.len() == 1 || s.as_bytes().get(1) == Some(&b' '))
2080}
2081
2082pub(crate) fn is_definition_list_marker(line: &str) -> bool {
2110 let trimmed = line.trim_start();
2111 trimmed.starts_with(':') && calculate_indentation_width_default(&line[..line.len() - trimmed.len()]) <= 3
2112}
2113
2114pub(crate) fn is_self_contained_display_math_line(line: &str) -> bool {
2136 let trimmed = line.trim();
2137 let inner = crate::utils::blockquote::parse_blockquote_prefix(trimmed).map_or(trimmed, |p| p.content.trim());
2138 let inner = inner.strip_suffix('\\').map_or(inner, str::trim_end);
2139 inner.len() >= 4
2140 && inner.starts_with("$$")
2141 && inner.ends_with("$$")
2142 && inner[2..].find("$$") == Some(inner.len() - 4)
2143}
2144
2145pub(crate) fn lines_touching_multiline_code_span(text: &str) -> Vec<bool> {
2168 let line_count = text.lines().count();
2169 if !text.contains('`') || !text.contains("$$") {
2170 return vec![false; line_count];
2171 }
2172 let code_spans = crate::utils::parser_options::code_span_ranges_without_math(text);
2173 let mut spans = code_spans.iter().copied().peekable();
2174 let mut starts_inside = Vec::with_capacity(line_count);
2175 let mut line_start = 0;
2176 for line in text.split_inclusive('\n') {
2177 while spans.next_if(|&(_, end)| end <= line_start).is_some() {}
2178 starts_inside.push(spans.peek().is_some_and(|&(start, _)| start < line_start));
2179 line_start += line.len();
2180 }
2181 (0..line_count)
2182 .map(|i| starts_inside[i] || starts_inside.get(i + 1).is_some_and(|&b| b))
2183 .collect()
2184}
2185
2186pub(crate) fn lines_ending_in_code_span(lines: &[&str]) -> Vec<bool> {
2192 if !lines.iter().any(|line| line.contains('`')) {
2193 return vec![false; lines.len()];
2194 }
2195 let text = lines.join("\n");
2196 let code_spans = nested_structure(&text, None, false).code_spans;
2197 let mut spans = code_spans.iter().copied().peekable();
2198 let mut line_end = 0;
2199 lines
2200 .iter()
2201 .map(|line| {
2202 line_end += line.len();
2203 while spans.next_if(|&(_, end)| end <= line_end).is_some() {}
2204 let inside = spans.peek().is_some_and(|&(start, _)| start <= line_end);
2205 line_end += 1;
2206 inside
2207 })
2208 .collect()
2209}
2210
2211fn has_block_line_above(lines: &[&str], index: usize) -> bool {
2220 let Some(previous) = index.checked_sub(1).map(|above| lines[above].trim()) else {
2221 return false;
2222 };
2223 !(previous.is_empty()
2224 || previous.starts_with('#')
2225 || previous.starts_with("```")
2226 || previous.starts_with("~~~")
2227 || previous.starts_with(":::")
2228 || is_horizontal_rule(previous))
2229}
2230
2231fn is_block_boundary_core(trimmed: &str) -> bool {
2234 trimmed.is_empty()
2235 || trimmed.starts_with('#')
2236 || trimmed.starts_with("```")
2237 || trimmed.starts_with("~~~")
2238 || trimmed.starts_with('>')
2239 || (trimmed.starts_with('[') && trimmed.contains("]:"))
2240 || is_horizontal_rule(trimmed)
2241 || is_unordered_list_marker(trimmed)
2242 || is_numbered_list_item(trimmed)
2243 || is_definition_list_marker(trimmed)
2244 || trimmed.starts_with(":::")
2245}
2246
2247fn is_block_boundary(trimmed: &str) -> bool {
2250 is_block_boundary_core(trimmed) || trimmed.starts_with('|')
2251}
2252
2253fn is_paragraph_boundary(trimmed: &str, line: &str) -> bool {
2257 is_block_boundary_core(trimmed)
2258 || calculate_indentation_width_default(line) >= 4
2259 || crate::utils::table_utils::TableUtils::is_potential_table_row(line)
2260}
2261
2262fn has_hard_break(line: &str) -> bool {
2268 let line = line.strip_suffix('\r').unwrap_or(line);
2269 line.ends_with(" ") || line.ends_with('\\')
2270}
2271
2272struct SoftBreakJoin {
2274 joined: String,
2275 joins: Vec<usize>,
2277 code_spans: Vec<(usize, usize)>,
2279}
2280
2281impl SoftBreakJoin {
2282 fn new<S: AsRef<str>>(lines: &[S]) -> Self {
2283 let mut joined = String::new();
2284 let mut joins = Vec::with_capacity(lines.len().saturating_sub(1));
2285 for (idx, line) in lines.iter().enumerate() {
2286 let line = line.as_ref();
2287 if idx + 1 == lines.len() {
2288 joined.push_str(line);
2289 } else {
2290 joined.push_str(line.strip_suffix('\r').unwrap_or(line));
2291 joins.push(joined.len());
2292 joined.push(' ');
2293 }
2294 }
2295 let code_spans = if !joins.is_empty() && joined.contains('`') {
2296 nested_structure(&joined, None, false).code_spans
2297 } else {
2298 Vec::new()
2299 };
2300 Self {
2301 joined,
2302 joins,
2303 code_spans,
2304 }
2305 }
2306}
2307
2308pub(crate) fn code_span_runs_into_indentation<S: AsRef<str>>(lines: &[S]) -> bool {
2319 let SoftBreakJoin {
2320 joined,
2321 joins,
2322 code_spans,
2323 } = SoftBreakJoin::new(lines);
2324 joins.iter().enumerate().any(|(k, &join)| {
2325 let next_line = &joined[join + 1..joins.get(k + 1).copied().unwrap_or(joined.len())];
2326 next_line.starts_with([' ', '\t']) && code_spans.iter().any(|&(start, end)| start <= join && join < end)
2327 })
2328}
2329
2330pub(crate) fn code_span_crosses_last_break<S: AsRef<str>>(lines: &[S]) -> bool {
2333 let SoftBreakJoin { joins, code_spans, .. } = SoftBreakJoin::new(lines);
2334 joins
2335 .last()
2336 .is_some_and(|&join| code_spans.iter().any(|&(start, end)| start <= join && join < end))
2337}
2338
2339pub(crate) fn join_soft_break_lines<S: AsRef<str>>(lines: &[S], cjk: CjkSoftBreak) -> String {
2364 join_soft_break_lines_with_sentences(lines, cjk, None, LinkTextPunctuation::Ignore, None)
2365}
2366
2367#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2369enum SentenceJoin {
2370 Break,
2372 DoubleSpace,
2375}
2376
2377pub(crate) fn join_sentence_lines<S: AsRef<str>>(
2381 lines: &[S],
2382 cjk: CjkSoftBreak,
2383 abbreviations: &Option<Vec<String>>,
2384 link_text_punctuation: LinkTextPunctuation,
2385 references: Option<&HashSet<String>>,
2386) -> String {
2387 join_soft_break_lines_with_sentences(
2388 lines,
2389 cjk,
2390 Some((abbreviations, SentenceJoin::Break)),
2391 link_text_punctuation,
2392 references.filter(|_| link_text_punctuation == LinkTextPunctuation::Trailing),
2393 )
2394}
2395
2396pub(crate) fn join_double_spaced_sentence_lines<S: AsRef<str>>(
2401 lines: &[S],
2402 cjk: CjkSoftBreak,
2403 abbreviations: &Option<Vec<String>>,
2404 link_text_punctuation: LinkTextPunctuation,
2405 references: Option<&HashSet<String>>,
2406) -> String {
2407 join_soft_break_lines_with_sentences(
2408 lines,
2409 cjk,
2410 Some((abbreviations, SentenceJoin::DoubleSpace)),
2411 link_text_punctuation,
2412 references.filter(|_| link_text_punctuation == LinkTextPunctuation::Trailing),
2413 )
2414}
2415
2416fn join_reflow_lines<S: AsRef<str>>(lines: &[S], options: &ReflowOptions) -> String {
2417 if options.sentence_per_line {
2418 join_sentence_lines(
2419 lines,
2420 options.cjk_soft_break,
2421 &options.abbreviations,
2422 options.link_text_punctuation,
2423 options.defined_references.as_ref(),
2424 )
2425 } else if options.sentence_double_space {
2426 join_double_spaced_sentence_lines(
2427 lines,
2428 options.cjk_soft_break,
2429 &options.abbreviations,
2430 options.link_text_punctuation,
2431 options.defined_references.as_ref(),
2432 )
2433 } else {
2434 join_soft_break_lines(lines, options.cjk_soft_break)
2435 }
2436}
2437
2438fn join_soft_break_lines_with_sentences<S: AsRef<str>>(
2439 lines: &[S],
2440 cjk: CjkSoftBreak,
2441 sentences: Option<(&Option<Vec<String>>, SentenceJoin)>,
2442 link_text_punctuation: LinkTextPunctuation,
2443 references: Option<&HashSet<String>>,
2444) -> String {
2445 let SoftBreakJoin {
2446 mut joined,
2447 joins,
2448 code_spans,
2449 } = SoftBreakJoin::new(lines);
2450 if joins.is_empty() {
2451 return joined;
2452 }
2453 let sentence_ends = sentences.map(|(abbreviations, _)| {
2454 for &join in &joins {
2457 joined.replace_range(join..=join, "\n");
2458 }
2459 split_into_sentence_ranges(
2460 &joined,
2461 &get_abbreviations(abbreviations),
2462 true,
2463 cjk,
2464 references,
2465 None,
2466 link_text_punctuation,
2467 )
2468 .into_iter()
2469 .map(|(_, end)| end)
2470 .collect::<HashSet<_>>()
2471 });
2472 let mut trimmed = String::with_capacity(joined.len());
2478 let mut copied = 0;
2479 let mut space_pending = false;
2480 let mut separator = " ";
2481 let mut spans = code_spans.iter().copied().peekable();
2482 for (k, &join) in joins.iter().enumerate() {
2483 while spans.next_if(|&(_, end)| end <= join).is_some() {}
2484 let inside_code_span = spans.peek().is_some_and(|&(start, _)| start <= join);
2485 let content_end = if inside_code_span {
2486 join
2487 } else {
2488 joined[..join].trim_end_matches([' ', '\t']).len().max(copied)
2489 };
2490 let wrote_text = content_end > copied;
2491 if wrote_text {
2492 if space_pending {
2493 trimmed.push_str(separator);
2494 }
2495 trimmed.push_str(&joined[copied..content_end]);
2496 }
2497 let next_line = &joined[join + 1..joins.get(k + 1).copied().unwrap_or(joined.len())];
2498 let next_content = if inside_code_span {
2499 next_line
2500 } else {
2501 next_line.trim_start_matches([' ', '\t'])
2502 };
2503 copied = join + 1 + (next_line.len() - next_content.len());
2504 let break_removed = !inside_code_span
2505 && cjk == CjkSoftBreak::Join
2506 && joined[..content_end]
2507 .chars()
2508 .next_back()
2509 .is_some_and(joins_cjk_soft_break)
2510 && next_content.chars().next().is_some_and(joins_cjk_soft_break);
2511 space_pending = !break_removed || (space_pending && !wrote_text);
2512 let ends_sentence = sentence_ends.as_ref().is_some_and(|ends| ends.contains(&content_end));
2513 separator = match sentences.map(|(_, join)| join) {
2514 Some(SentenceJoin::Break) if ends_sentence => "\n",
2515 Some(SentenceJoin::DoubleSpace) if ends_sentence && ends_latin_sentence(&joined[..content_end]) => " ",
2516 _ => " ",
2517 };
2518 }
2519 if space_pending {
2520 trimmed.push_str(separator);
2521 }
2522 trimmed.push_str(&joined[copied..]);
2523 trimmed
2524}
2525
2526fn ends_with_sentence_punct(text: &str) -> bool {
2528 text.ends_with('.') || text.ends_with('!') || text.ends_with('?')
2529}
2530
2531fn ends_latin_sentence(text: &str) -> bool {
2534 ends_with_sentence_punct(text.trim_end_matches(|c| is_closing_bracket(c) || is_closing_quote(c)))
2535}
2536
2537fn ends_cjk_sentence_with_closer(text: &str) -> bool {
2544 let stripped = text.trim_end_matches(|c| is_closing_bracket(c) || is_closing_quote(c));
2545 stripped.len() < text.len() && stripped.chars().next_back().is_some_and(is_cjk_sentence_ending)
2546}
2547
2548fn trim_preserving_hard_break(s: &str) -> String {
2554 let s = s.strip_suffix('\r').unwrap_or(s);
2556
2557 if s.ends_with('\\') {
2559 return s.to_string();
2561 }
2562
2563 if s.ends_with(" ") {
2565 let content_end = s.trim_end().len();
2567 if content_end == 0 {
2568 return String::new();
2570 }
2571 format!("{} ", &s[..content_end])
2573 } else {
2574 s.trim_end().to_string()
2576 }
2577}
2578
2579fn parse_elements(text: &str, options: &ReflowOptions) -> Vec<Element> {
2581 parse_markdown_elements_inner(
2582 text,
2583 options.attr_lists,
2584 options.myst_roles,
2585 options.defined_references.as_ref(),
2586 )
2587}
2588
2589pub fn reflow_line(line: &str, options: &ReflowOptions) -> Vec<String> {
2599 let reflowed = reflow_line_unchecked(line, options);
2600 if preserves_content(line, &reflowed) {
2601 reflowed
2602 } else {
2603 vec![line.to_string()]
2604 }
2605}
2606
2607fn preserves_content(original: &str, reflowed: &[String]) -> bool {
2614 let (original_text, original_breaks) = visible_text_and_breaks(original.chars());
2615 let (reflowed_text, reflowed_breaks) =
2616 visible_text_and_breaks(reflowed.iter().flat_map(|line| line.chars().chain(['\n'])));
2617
2618 original_text == reflowed_text && contains_all(&reflowed_breaks, &original_breaks)
2619}
2620
2621fn visible_text_and_breaks(text: impl Iterator<Item = char>) -> (String, Vec<usize>) {
2624 let mut visible = String::new();
2625 let mut breaks = Vec::new();
2626 let mut count = 0usize;
2627 let mut pending_break = false;
2628
2629 for c in text {
2630 if c.is_whitespace() {
2631 pending_break = count > 0;
2632 } else {
2633 if pending_break {
2634 breaks.push(count);
2635 pending_break = false;
2636 }
2637 visible.push(c);
2638 count += 1;
2639 }
2640 }
2641
2642 (visible, breaks)
2643}
2644
2645fn contains_all(superset: &[usize], subset: &[usize]) -> bool {
2647 let mut candidates = superset.iter();
2648 subset
2649 .iter()
2650 .all(|wanted| candidates.by_ref().any(|found| found == wanted))
2651}
2652
2653fn reflow_line_unchecked(line: &str, options: &ReflowOptions) -> Vec<String> {
2654 let lines = keep_first_line_out_of_backtick_fence(line, reflow_lines(line, options));
2655 keep_first_line_out_of_html_block(lines)
2656}
2657
2658fn keep_first_line_out_of_backtick_fence(source: &str, mut lines: Vec<String>) -> Vec<String> {
2663 let first_source = source.lines().next().unwrap_or("").trim_start_matches([' ', '\t']);
2664 let ticks = first_source.bytes().take_while(|&b| b == b'`').count();
2665 if ticks < 3 || !first_source[ticks..].contains('`') {
2666 return lines;
2667 }
2668 while lines.len() > 1 {
2669 let first = lines[0].trim_start_matches([' ', '\t']);
2670 let ticks = first.bytes().take_while(|&b| b == b'`').count();
2671 if ticks < 3 || first[ticks..].contains('`') {
2672 break;
2673 }
2674 let next = lines.remove(1);
2675 lines[0].push(' ');
2676 lines[0].push_str(next.trim_start_matches([' ', '\t']));
2677 }
2678 lines
2679}
2680
2681fn reflow_lines(line: &str, options: &ReflowOptions) -> Vec<String> {
2682 if options.sentence_pack {
2683 let elements = parse_elements(line, options);
2684 return reflow_elements_sentence_pack(&elements, options);
2685 }
2686
2687 if options.sentence_per_line {
2689 let elements = parse_elements(line, options);
2690 return merge_block_construct_continuations(reflow_elements_sentence_per_line(&elements, options));
2691 }
2692
2693 if options.semantic_line_breaks {
2695 let elements = parse_elements(line, options);
2696 return merge_block_construct_continuations(reflow_elements_semantic(&elements, options));
2697 }
2698
2699 if options.line_length == 0 || line_fits(line, options) {
2702 return vec![line.to_string()];
2703 }
2704
2705 let elements = parse_elements(line, options);
2707
2708 merge_block_construct_continuations(reflow_elements(&elements, options))
2710}
2711
2712#[derive(Debug, Clone)]
2714enum Element {
2715 Text(String),
2717 Link(String),
2719 ReferenceLink(String),
2721 EmptyReferenceLink(String),
2723 ShortcutReference(String),
2725 InlineImage(String),
2727 ReferenceImage(String),
2729 EmptyReferenceImage(String),
2731 LinkedImage(String),
2733 FootnoteReference(String),
2735 Strikethrough {
2737 content: String,
2738 double: bool,
2740 },
2741 WikiLink(String),
2743 InlineMath(String),
2745 DisplayMath(String),
2747 EmojiShortcode(String),
2749 Autolink(String),
2751 HtmlTag(String),
2753 HtmlEntity(String),
2755 HugoShortcode(String),
2757 AttrList(String),
2759 MystRole(String),
2763 Code { content: String, marker: String },
2765 Bold {
2767 content: String,
2768 underscore: bool,
2770 },
2771 Italic {
2773 content: String,
2774 underscore: bool,
2776 },
2777}
2778
2779impl Element {
2780 fn opens_with_bracket(&self) -> bool {
2785 matches!(
2786 self,
2787 Element::Link(_)
2788 | Element::ReferenceLink(_)
2789 | Element::EmptyReferenceLink(_)
2790 | Element::ShortcutReference(_)
2791 | Element::FootnoteReference(_)
2792 | Element::InlineImage(_)
2793 | Element::ReferenceImage(_)
2794 | Element::EmptyReferenceImage(_)
2795 | Element::LinkedImage(_)
2796 | Element::WikiLink(_)
2797 )
2798 }
2799}
2800
2801impl std::fmt::Display for Element {
2802 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2803 match self {
2804 Element::Text(s) => write!(f, "{s}"),
2805 Element::Link(s) => write!(f, "{s}"),
2806 Element::ReferenceLink(s) => write!(f, "{s}"),
2807 Element::EmptyReferenceLink(s) => write!(f, "{s}"),
2808 Element::ShortcutReference(s) => write!(f, "{s}"),
2809 Element::InlineImage(s) => write!(f, "{s}"),
2810 Element::ReferenceImage(s) => write!(f, "{s}"),
2811 Element::EmptyReferenceImage(s) => write!(f, "{s}"),
2812 Element::LinkedImage(s) => write!(f, "{s}"),
2813 Element::FootnoteReference(s) => write!(f, "{s}"),
2814 Element::Strikethrough { content, double } => {
2815 let marker = if *double { "~~" } else { "~" };
2816 write!(f, "{marker}{content}{marker}")
2817 }
2818 Element::WikiLink(s) => write!(f, "[[{s}]]"),
2819 Element::InlineMath(s) => write!(f, "${s}$"),
2820 Element::DisplayMath(s) => write!(f, "$${s}$$"),
2821 Element::EmojiShortcode(s) => write!(f, ":{s}:"),
2822 Element::Autolink(s) => write!(f, "{s}"),
2823 Element::HtmlTag(s) => write!(f, "{s}"),
2824 Element::HtmlEntity(s) => write!(f, "{s}"),
2825 Element::HugoShortcode(s) => write!(f, "{s}"),
2826 Element::AttrList(s) => write!(f, "{s}"),
2827 Element::MystRole(s) => write!(f, "{s}"),
2828 Element::Code { content, marker } => write!(f, "{marker}{content}{marker}"),
2829 Element::Bold { content, underscore } => {
2830 if *underscore {
2831 write!(f, "__{content}__")
2832 } else {
2833 write!(f, "**{content}**")
2834 }
2835 }
2836 Element::Italic { content, underscore } => {
2837 if *underscore {
2838 write!(f, "_{content}_")
2839 } else {
2840 write!(f, "*{content}*")
2841 }
2842 }
2843 }
2844 }
2845}
2846
2847impl Element {
2848 fn display_len(&self, mode: ReflowLengthMode) -> usize {
2849 match self {
2850 Element::Text(s)
2851 | Element::Link(s)
2852 | Element::ReferenceLink(s)
2853 | Element::EmptyReferenceLink(s)
2854 | Element::ShortcutReference(s)
2855 | Element::InlineImage(s)
2856 | Element::ReferenceImage(s)
2857 | Element::EmptyReferenceImage(s)
2858 | Element::LinkedImage(s)
2859 | Element::FootnoteReference(s)
2860 | Element::Autolink(s)
2861 | Element::HtmlTag(s)
2862 | Element::HtmlEntity(s)
2863 | Element::HugoShortcode(s)
2864 | Element::AttrList(s)
2865 | Element::MystRole(s) => display_len(s, mode),
2866 Element::WikiLink(s) => display_len(s, mode) + 4,
2867 Element::InlineMath(s) => display_len(s, mode) + 2,
2868 Element::DisplayMath(s) => display_len(s, mode) + 4,
2869 Element::EmojiShortcode(s) => display_len(s, mode) + 2,
2870 Element::Strikethrough { content, double } => display_len(content, mode) + if *double { 4 } else { 2 },
2871 Element::Code { content, marker } => display_len(content, mode) + display_len(marker, mode) * 2,
2872 Element::Bold { content, .. } => display_len(content, mode) + 4,
2873 Element::Italic { content, .. } => display_len(content, mode) + 2,
2874 }
2875 }
2876
2877 fn exempt_width(&self, mode: ReflowLengthMode, exemptions: LengthExemptions) -> LineWidth {
2888 let full = self.display_len(mode);
2889 let mut width = LineWidth::plain(full);
2890 match self {
2891 Element::Link(s) | Element::LinkedImage(s) if exemptions.link_urls => {
2892 if let Some(text) = bracketed_text(s, 0) {
2893 width.link_exempt = (2 + display_len(text, mode)).min(full);
2894 }
2895 }
2896 Element::InlineImage(s) if exemptions.link_urls => {
2897 if let Some(alt) = bracketed_text(s, 1) {
2898 width.link_exempt = (3 + display_len(alt, mode)).min(full);
2899 }
2900 }
2901 Element::Code { .. } if exemptions.code_spans => width.code_exempt = 0,
2902 _ => {}
2903 }
2904 width
2905 }
2906}
2907
2908pub(crate) fn bracketed_text(s: &str, open: usize) -> Option<&str> {
2915 let bytes = s.as_bytes();
2916 if bytes.get(open) != Some(&b'[') {
2917 return None;
2918 }
2919 let mut depth = 0usize;
2920 let mut in_code_span = false;
2921 let mut escaped = false;
2922 for (i, &byte) in bytes.iter().enumerate().skip(open + 1) {
2923 if escaped {
2924 escaped = false;
2925 continue;
2926 }
2927 match byte {
2928 b'\\' => escaped = true,
2929 b'`' => in_code_span = !in_code_span,
2930 b'[' if !in_code_span => depth += 1,
2931 b']' if !in_code_span => match depth.checked_sub(1) {
2932 Some(next) => depth = next,
2933 None => return s.get(open + 1..i),
2934 },
2935 _ => {}
2936 }
2937 }
2938 None
2939}
2940
2941#[derive(Debug, Clone)]
2943struct EmphasisSpan {
2944 start: usize,
2946 end: usize,
2948 content: String,
2950 is_strong: bool,
2952 is_strikethrough: bool,
2954 uses_underscore: bool,
2956 strikethrough_double: bool,
2959}
2960
2961fn extract_emphasis_and_code_spans(text: &str) -> (Vec<EmphasisSpan>, Vec<CodeSpan>) {
2971 let has_emphasis = text.contains(['*', '_', '~']);
2973 let has_code = text.contains('`');
2974 if !has_emphasis && !has_code {
2975 return (Vec::new(), Vec::new());
2976 }
2977
2978 let mut emphasis_spans = Vec::new();
2979 let mut code_spans = Vec::new();
2980
2981 let mut options = Options::empty();
2982 if has_emphasis {
2983 options.insert(Options::ENABLE_STRIKETHROUGH);
2984 }
2985
2986 let mut emphasis_stack: Vec<(usize, bool)> = Vec::new(); let mut strong_stack: Vec<(usize, bool)> = Vec::new();
2989 let mut strikethrough_stack: Vec<usize> = Vec::new();
2990
2991 let parser = Parser::new_ext(text, options).into_offset_iter();
2992
2993 for (event, range) in parser {
2994 match event {
2995 Event::Code(_) => {
2996 code_spans.push(CodeSpan {
2997 start: range.start,
2998 end: range.end,
2999 });
3000 }
3001 Event::Start(Tag::Emphasis) => {
3002 let uses_underscore = text.get(range.start..range.start + 1) == Some("_");
3004 emphasis_stack.push((range.start, uses_underscore));
3005 }
3006 Event::End(TagEnd::Emphasis) => {
3007 if let Some((start_byte, uses_underscore)) = emphasis_stack.pop() {
3008 let content_start = start_byte + 1;
3009 let content_end = range.end - 1;
3010 if content_end > content_start
3011 && let Some(content) = text.get(content_start..content_end)
3012 {
3013 emphasis_spans.push(EmphasisSpan {
3014 start: start_byte,
3015 end: range.end,
3016 content: content.to_string(),
3017 is_strong: false,
3018 is_strikethrough: false,
3019 uses_underscore,
3020 strikethrough_double: false,
3021 });
3022 }
3023 }
3024 }
3025 Event::Start(Tag::Strong) => {
3026 let uses_underscore = text.get(range.start..range.start + 2) == Some("__");
3027 strong_stack.push((range.start, uses_underscore));
3028 }
3029 Event::End(TagEnd::Strong) => {
3030 if let Some((start_byte, uses_underscore)) = strong_stack.pop() {
3031 let content_start = start_byte + 2;
3032 let content_end = range.end - 2;
3033 if content_end > content_start
3034 && let Some(content) = text.get(content_start..content_end)
3035 {
3036 emphasis_spans.push(EmphasisSpan {
3037 start: start_byte,
3038 end: range.end,
3039 content: content.to_string(),
3040 is_strong: true,
3041 is_strikethrough: false,
3042 uses_underscore,
3043 strikethrough_double: false,
3044 });
3045 }
3046 }
3047 }
3048 Event::Start(Tag::Strikethrough) => {
3049 strikethrough_stack.push(range.start);
3050 }
3051 Event::End(TagEnd::Strikethrough) => {
3052 if let Some(start_byte) = strikethrough_stack.pop() {
3053 let double = text.get(start_byte..start_byte + 2) == Some("~~");
3054 let marker_len = if double { 2 } else { 1 };
3055 let content_start = start_byte + marker_len;
3056 let content_end = range.end - marker_len;
3057 if content_end > content_start
3058 && let Some(content) = text.get(content_start..content_end)
3059 {
3060 emphasis_spans.push(EmphasisSpan {
3061 start: start_byte,
3062 end: range.end,
3063 content: content.to_string(),
3064 is_strong: false,
3065 is_strikethrough: true,
3066 uses_underscore: false,
3067 strikethrough_double: double,
3068 });
3069 }
3070 }
3071 }
3072 _ => {}
3073 }
3074 }
3075
3076 emphasis_spans.sort_by_key(|s| s.start);
3077 (emphasis_spans, code_spans)
3078}
3079
3080#[derive(Debug, Clone)]
3081struct CodeSpan {
3082 start: usize,
3083 end: usize,
3084}
3085
3086#[derive(Debug, Clone)]
3087struct LinkSpan {
3088 start: usize,
3089 end: usize,
3090 link_type: Option<LinkType>,
3091 is_image: bool,
3092 is_footnote: bool,
3093 depth: usize,
3096}
3097
3098fn extract_link_spans(text: &str, defined_references: Option<&HashSet<String>>) -> Vec<LinkSpan> {
3102 let mut spans = all_link_spans(text, defined_references);
3103 spans.retain(|span| span.depth == 0);
3104 spans
3105}
3106
3107fn all_link_spans(text: &str, defined_references: Option<&HashSet<String>>) -> Vec<LinkSpan> {
3110 if !text.contains('[') {
3113 return Vec::new();
3114 }
3115
3116 let mut spans = Vec::new();
3117 let mut options = Options::empty();
3118 options.insert(Options::ENABLE_FOOTNOTES);
3119
3120 let resolve = move |link: BrokenLink<'_>| -> Option<(CowStr<'_>, CowStr<'_>)> {
3137 let atomic = match link.link_type {
3142 LinkType::Shortcut | LinkType::ShortcutUnknown => match defined_references {
3143 Some(defs) => defs.contains(&normalize_reference_label(link.reference.as_ref())),
3144 None => true,
3145 },
3146 _ => true,
3147 };
3148 atomic.then_some((CowStr::Borrowed(""), CowStr::Borrowed("")))
3149 };
3150 let parser = Parser::new_with_broken_link_callback(text, options, Some(resolve)).into_offset_iter();
3151 let mut stack = Vec::new();
3152
3153 for (event, range) in parser {
3154 match event {
3155 Event::Start(Tag::Link { link_type, .. }) => {
3156 stack.push((range.start, Some(link_type), false));
3157 }
3158 Event::Start(Tag::Image { link_type, .. }) => {
3159 stack.push((range.start, Some(link_type), true));
3160 }
3161 Event::End(TagEnd::Link | TagEnd::Image) => {
3162 if let Some((start_byte, link_type, is_image)) = stack.pop() {
3163 let mut end = range.end;
3164 if matches!(link_type, Some(LinkType::Collapsed) | Some(LinkType::CollapsedUnknown))
3165 && text[end..].starts_with("[]")
3166 {
3167 end += 2;
3168 }
3169 spans.push(LinkSpan {
3170 start: start_byte,
3171 end,
3172 link_type,
3173 is_image,
3174 is_footnote: false,
3175 depth: stack.len(),
3176 });
3177 }
3178 }
3179 Event::FootnoteReference(_) => {
3180 spans.push(LinkSpan {
3181 start: range.start,
3182 end: range.end,
3183 link_type: None,
3184 is_image: false,
3185 is_footnote: true,
3186 depth: stack.len(),
3187 });
3188 }
3189 _ => {}
3190 }
3191 }
3192
3193 spans.sort_by_key(|s| s.start);
3194 spans
3195}
3196
3197fn myst_role_len_at(text: &str, absolute_pos: usize, code_spans: &[CodeSpan]) -> Option<usize> {
3205 let bytes = text.as_bytes();
3206 if bytes.first() != Some(&b'{') {
3207 return None;
3208 }
3209
3210 let mut j = 1;
3212 match bytes.get(j) {
3213 Some(&b) if b.is_ascii_alphabetic() || b == b'_' => {}
3214 _ => return None,
3215 }
3216 while let Some(&b) = bytes.get(j) {
3217 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b':' | b'.') {
3218 j += 1;
3219 } else {
3220 break;
3221 }
3222 }
3223 if bytes.get(j) != Some(&b'}') {
3224 return None;
3225 }
3226 j += 1; let code_span_start = absolute_pos + j;
3230 if let Ok(idx) = code_spans.binary_search_by_key(&code_span_start, |span| span.start) {
3231 let span = &code_spans[idx];
3232 let code_span_len = span.end - span.start;
3233 return Some(j + code_span_len);
3234 }
3235
3236 None
3237}
3238
3239fn inline_math_len_at_start(s: &str) -> Option<usize> {
3246 let bytes = s.as_bytes();
3247 if bytes.first() != Some(&b'$') || bytes.get(1) == Some(&b'$') {
3249 return None;
3250 }
3251 let close = 1 + s[1..].find('$')?;
3254 if bytes.get(close + 1) == Some(&b'$') {
3256 return None;
3257 }
3258 Some(close + 1)
3259}
3260
3261#[derive(Clone, Copy, Debug)]
3263struct PatternMatch {
3264 start: usize,
3265 end: usize,
3266}
3267
3268fn inline_html_comment(text: &str) -> Option<(usize, usize)> {
3274 let start = text.find("<!--")?;
3275 let body = &text[start + 4..];
3276 let end = if body.starts_with('>') {
3277 start + 5
3278 } else if body.starts_with("->") {
3279 start + 6
3280 } else {
3281 start + 4 + body.find("-->")? + 3
3282 };
3283 Some((start, end))
3284}
3285
3286#[derive(Clone, Copy)]
3300enum PatternCache {
3301 Unsearched,
3302 NotFound,
3303 Found(PatternMatch),
3304}
3305
3306impl PatternCache {
3307 fn earliest_in(
3311 &mut self,
3312 remaining: &str,
3313 cursor: usize,
3314 find: impl FnOnce(&str) -> Option<(usize, usize)>,
3315 ) -> Option<(usize, usize)> {
3316 let stale = match self {
3317 PatternCache::Found(pm) => pm.start < cursor,
3318 PatternCache::NotFound => false,
3319 PatternCache::Unsearched => true,
3320 };
3321 if stale {
3322 *self = match find(remaining) {
3323 Some((start, end)) => PatternCache::Found(PatternMatch {
3324 start: cursor + start,
3325 end: cursor + end,
3326 }),
3327 None => PatternCache::NotFound,
3328 };
3329 }
3330 match self {
3331 PatternCache::Found(pm) => Some((pm.start - cursor, pm.end - cursor)),
3332 _ => None,
3333 }
3334 }
3335}
3336
3337fn parse_markdown_elements_inner(
3348 text: &str,
3349 attr_lists: bool,
3350 myst_roles: bool,
3351 defined_references: Option<&HashSet<String>>,
3352) -> Vec<Element> {
3353 let mut elements = Vec::new();
3354 let mut remaining = text;
3355
3356 let (emphasis_spans, code_spans) = extract_emphasis_and_code_spans(text);
3361 let link_spans = extract_link_spans(text, defined_references);
3362
3363 let mut cached_wiki_link = PatternCache::Unsearched;
3366 let mut cached_display_math = PatternCache::Unsearched;
3367 let mut cached_inline_math = PatternCache::Unsearched;
3368 let mut cached_emoji = PatternCache::Unsearched;
3369 let mut cached_html_entity = PatternCache::Unsearched;
3370 let mut cached_hugo_shortcode = PatternCache::Unsearched;
3371 let mut cached_html_tag = PatternCache::Unsearched;
3372 let mut cached_next_curly = PatternCache::Unsearched;
3373
3374 let mut link_span_idx = 0usize;
3378 let mut emphasis_span_idx = 0usize;
3379 let mut code_span_idx = 0usize;
3380
3381 while !remaining.is_empty() {
3382 let current_offset = text.len() - remaining.len();
3384 let mut earliest_match: Option<(usize, usize, &str)> = None;
3387
3388 while link_span_idx < link_spans.len() && link_spans[link_span_idx].start < current_offset {
3390 link_span_idx += 1;
3391 }
3392 let next_link: Option<&LinkSpan> = link_spans.get(link_span_idx);
3393
3394 if let Some(span) = next_link {
3395 let pos_in_remaining = span.start - current_offset;
3396 if earliest_match
3397 .as_ref()
3398 .is_none_or(|(start, _, _)| pos_in_remaining < *start)
3399 {
3400 let match_end = span.end - current_offset;
3401 earliest_match = Some((pos_in_remaining, match_end, "link_span"));
3402 }
3403 }
3404
3405 if let Some((start, end)) = cached_wiki_link.earliest_in(remaining, current_offset, |suffix| {
3407 WIKI_LINK_REGEX.find(suffix).map(|m| (m.start(), m.end()))
3408 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
3409 {
3410 earliest_match = Some((start, end, "wiki_link"));
3411 }
3412
3413 if let Some((start, end)) = cached_display_math.earliest_in(remaining, current_offset, |suffix| {
3415 DISPLAY_MATH_REGEX.find(suffix).map(|m| (m.start(), m.end()))
3416 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
3417 {
3418 earliest_match = Some((start, end, "display_math"));
3419 }
3420
3421 let inline_math_probe = if current_offset > 0 && text.as_bytes()[current_offset - 1] == b'$' {
3435 inline_math_len_at_start(remaining).map(|len| (0, len))
3436 } else {
3437 None
3438 };
3439 if let Some((start, end)) = inline_math_probe.or_else(|| {
3440 cached_inline_math.earliest_in(remaining, current_offset, |suffix| {
3441 INLINE_MATH_REGEX
3442 .find(suffix)
3443 .ok()
3444 .flatten()
3445 .map(|m| (m.start(), m.end()))
3446 })
3447 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
3448 {
3449 earliest_match = Some((start, end, "inline_math"));
3450 }
3451
3452 if let Some((start, end)) = cached_emoji.earliest_in(remaining, current_offset, |suffix| {
3454 EMOJI_SHORTCODE_REGEX.find(suffix).map(|m| (m.start(), m.end()))
3455 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
3456 {
3457 earliest_match = Some((start, end, "emoji"));
3458 }
3459
3460 if let Some((start, end)) = cached_html_entity.earliest_in(remaining, current_offset, |suffix| {
3462 HTML_ENTITY_REGEX.find(suffix).map(|m| (m.start(), m.end()))
3463 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
3464 {
3465 earliest_match = Some((start, end, "html_entity"));
3466 }
3467
3468 if let Some((start, end)) = cached_hugo_shortcode.earliest_in(remaining, current_offset, |suffix| {
3471 crate::utils::shortcode_utils::shortcode_ranges(suffix).next()
3472 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
3473 {
3474 earliest_match = Some((start, end, "hugo_shortcode"));
3475 }
3476
3477 if let Some((start, end)) = cached_html_tag.earliest_in(remaining, current_offset, |suffix| {
3485 let comment = inline_html_comment(suffix);
3486 let mut from = 0;
3487 while let Some(m) = HTML_TAG_PATTERN.find(&suffix[from..]) {
3488 let (tag_start, tag_end) = (from + m.start(), from + m.end());
3489 let tag = &suffix[tag_start..tag_end];
3490 let is_url_autolink = tag.starts_with("<http://")
3492 || tag.starts_with("<https://")
3493 || tag.starts_with("<mailto:")
3494 || tag.starts_with("<ftp://")
3495 || tag.starts_with("<ftps://");
3496 let is_email_autolink = {
3499 let content = tag.trim_start_matches('<').trim_end_matches('>');
3500 EMAIL_PATTERN.is_match(content)
3501 };
3502 if is_url_autolink || is_email_autolink {
3503 from = tag_end;
3504 } else {
3505 return match comment {
3506 Some(comment) if comment.0 < tag_start => Some(comment),
3507 _ => Some((tag_start, tag_end)),
3508 };
3509 }
3510 }
3511 comment
3512 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
3513 {
3514 earliest_match = Some((start, end, "html_tag"));
3515 }
3516
3517 let mut next_special = remaining.len();
3519 let mut special_type = "";
3520 let mut pulldown_emphasis: Option<&EmphasisSpan> = None;
3521 let mut attr_list_len: usize = 0;
3522 let mut myst_role_len: usize = 0;
3523
3524 while code_span_idx < code_spans.len() && code_spans[code_span_idx].start < current_offset {
3526 code_span_idx += 1;
3527 }
3528 let next_code_span: Option<&CodeSpan> = code_spans.get(code_span_idx);
3529 if let Some(span) = next_code_span {
3530 let pos_in_remaining = span.start - current_offset;
3531 if pos_in_remaining < next_special {
3532 next_special = pos_in_remaining;
3533 special_type = "pulldown_code";
3534 }
3535 }
3536
3537 let next_curly_pos = cached_next_curly
3540 .earliest_in(remaining, current_offset, |suffix| {
3541 suffix.find('{').map(|pos| (pos, pos + 1))
3542 })
3543 .map(|(start, _)| start);
3544
3545 if myst_roles
3550 && let Some(pos) = next_curly_pos
3551 && pos < next_special
3552 && let Some(role_len) = myst_role_len_at(&remaining[pos..], current_offset + pos, &code_spans)
3553 {
3554 next_special = pos;
3555 special_type = "myst_role";
3556 myst_role_len = role_len;
3557 }
3558
3559 if attr_lists
3561 && let Some(pos) = next_curly_pos
3562 && pos < next_special
3563 && let Some(m) = ATTR_LIST_PATTERN.find(&remaining[pos..])
3564 && m.start() == 0
3565 {
3566 next_special = pos;
3567 special_type = "attr_list";
3568 attr_list_len = m.end();
3569 }
3570
3571 while emphasis_span_idx < emphasis_spans.len() && emphasis_spans[emphasis_span_idx].start < current_offset {
3573 emphasis_span_idx += 1;
3574 }
3575 if let Some(span) = emphasis_spans.get(emphasis_span_idx) {
3576 let pos_in_remaining = span.start - current_offset;
3577 if pos_in_remaining < next_special {
3578 next_special = pos_in_remaining;
3579 special_type = "pulldown_emphasis";
3580 pulldown_emphasis = Some(span);
3581 }
3582 }
3583
3584 let should_process_markdown_link = if let Some((pos, _, _)) = earliest_match {
3586 pos < next_special
3587 } else {
3588 false
3589 };
3590
3591 if should_process_markdown_link {
3592 let (pos, match_end, pattern_type) = earliest_match.unwrap();
3593
3594 if pos > 0 {
3596 elements.push(Element::Text(remaining[..pos].to_string()));
3597 }
3598
3599 match pattern_type {
3601 "link_span" => {
3602 let span = next_link.unwrap();
3603 let raw_text = remaining[pos..match_end].to_string();
3604 if span.is_footnote {
3605 elements.push(Element::FootnoteReference(raw_text));
3606 } else if span.is_image {
3607 match span.link_type {
3608 Some(LinkType::Inline) => elements.push(Element::InlineImage(raw_text)),
3609 Some(LinkType::Reference)
3612 | Some(LinkType::ReferenceUnknown)
3613 | Some(LinkType::Shortcut)
3614 | Some(LinkType::ShortcutUnknown) => elements.push(Element::ReferenceImage(raw_text)),
3615 Some(LinkType::Collapsed) | Some(LinkType::CollapsedUnknown) => {
3616 elements.push(Element::EmptyReferenceImage(raw_text))
3617 }
3618 _ => elements.push(Element::InlineImage(raw_text)),
3619 }
3620 } else {
3621 match span.link_type {
3622 Some(LinkType::Inline) => {
3623 if raw_text.starts_with('[') && raw_text.contains("![") {
3624 elements.push(Element::LinkedImage(raw_text));
3625 } else {
3626 elements.push(Element::Link(raw_text));
3627 }
3628 }
3629 Some(LinkType::Reference) | Some(LinkType::ReferenceUnknown) => {
3632 elements.push(Element::ReferenceLink(raw_text))
3633 }
3634 Some(LinkType::Collapsed) | Some(LinkType::CollapsedUnknown) => {
3635 elements.push(Element::EmptyReferenceLink(raw_text))
3636 }
3637 Some(LinkType::Shortcut) | Some(LinkType::ShortcutUnknown) => {
3638 elements.push(Element::ShortcutReference(raw_text))
3639 }
3640 Some(LinkType::Autolink) | Some(LinkType::Email) => {
3641 elements.push(Element::Autolink(raw_text))
3642 }
3643 _ => elements.push(Element::Link(raw_text)),
3644 }
3645 }
3646 remaining = &remaining[match_end..];
3647 }
3648 "wiki_link" => {
3649 if let Some(caps) = WIKI_LINK_REGEX.captures(remaining) {
3650 let content = caps.get(1).map_or("", |m| m.as_str());
3651 elements.push(Element::WikiLink(content.to_string()));
3652 remaining = &remaining[match_end..];
3653 } else {
3654 elements.push(Element::Text("[[".to_string()));
3655 remaining = &remaining[2..];
3656 }
3657 }
3658 "display_math" => {
3659 if let Some(caps) = DISPLAY_MATH_REGEX.captures(remaining) {
3660 let math = caps.get(1).map_or("", |m| m.as_str());
3661 elements.push(Element::DisplayMath(math.to_string()));
3662 remaining = &remaining[match_end..];
3663 } else {
3664 elements.push(Element::Text("$$".to_string()));
3665 remaining = &remaining[2..];
3666 }
3667 }
3668 "inline_math" => {
3669 if let Ok(Some(caps)) = INLINE_MATH_REGEX.captures(remaining) {
3670 let math = caps.get(1).map_or("", |m| m.as_str());
3671 elements.push(Element::InlineMath(math.to_string()));
3672 remaining = &remaining[match_end..];
3673 } else {
3674 elements.push(Element::Text("$".to_string()));
3675 remaining = &remaining[1..];
3676 }
3677 }
3678 "emoji" => {
3679 if let Some(caps) = EMOJI_SHORTCODE_REGEX.captures(remaining) {
3680 let emoji = caps.get(1).map_or("", |m| m.as_str());
3681 elements.push(Element::EmojiShortcode(emoji.to_string()));
3682 remaining = &remaining[match_end..];
3683 } else {
3684 elements.push(Element::Text(":".to_string()));
3685 remaining = &remaining[1..];
3686 }
3687 }
3688 "html_entity" => {
3689 elements.push(Element::HtmlEntity(remaining[pos..match_end].to_string()));
3691 remaining = &remaining[match_end..];
3692 }
3693 "hugo_shortcode" => {
3694 elements.push(Element::HugoShortcode(remaining[pos..match_end].to_string()));
3696 remaining = &remaining[match_end..];
3697 }
3698 "html_tag" => {
3699 elements.push(Element::HtmlTag(remaining[pos..match_end].to_string()));
3701 remaining = &remaining[match_end..];
3702 }
3703 _ => unreachable!("unknown pattern type: {}", pattern_type),
3704 }
3705 } else {
3706 if next_special > 0 && next_special < remaining.len() {
3710 elements.push(Element::Text(remaining[..next_special].to_string()));
3711 remaining = &remaining[next_special..];
3712 }
3713
3714 match special_type {
3716 "pulldown_code" => {
3717 let span = next_code_span.unwrap();
3718 let span_len = span.end - span.start;
3719 let code_raw = &remaining[..span_len];
3720 if let Some((content, marker)) = decompose_code_span(code_raw) {
3721 elements.push(Element::Code {
3722 content: content.to_string(),
3723 marker: marker.to_string(),
3724 });
3725 } else {
3726 elements.push(Element::Text(code_raw.to_string()));
3727 }
3728 remaining = &remaining[span_len..];
3729 }
3730 "attr_list" => {
3731 elements.push(Element::AttrList(remaining[..attr_list_len].to_string()));
3732 remaining = &remaining[attr_list_len..];
3733 }
3734 "myst_role" => {
3735 elements.push(Element::MystRole(remaining[..myst_role_len].to_string()));
3736 remaining = &remaining[myst_role_len..];
3737 }
3738 "pulldown_emphasis" => {
3739 let span = pulldown_emphasis.expect("pulldown_emphasis must be set");
3741 let span_len = span.end - span.start;
3742 if span.is_strikethrough {
3743 elements.push(Element::Strikethrough {
3744 content: span.content.clone(),
3745 double: span.strikethrough_double,
3746 });
3747 } else if span.is_strong {
3748 elements.push(Element::Bold {
3749 content: span.content.clone(),
3750 underscore: span.uses_underscore,
3751 });
3752 } else {
3753 elements.push(Element::Italic {
3754 content: span.content.clone(),
3755 underscore: span.uses_underscore,
3756 });
3757 }
3758 remaining = &remaining[span_len..];
3759 }
3760 _ => {
3761 elements.push(Element::Text(remaining.to_string()));
3763 break;
3764 }
3765 }
3766 }
3767 }
3768
3769 let mut merged_elements = Vec::new();
3771 for el in elements {
3772 match el {
3773 Element::Text(s) => {
3774 if let Some(Element::Text(last_s)) = merged_elements.last_mut() {
3775 last_s.push_str(&s);
3776 } else {
3777 merged_elements.push(Element::Text(s));
3778 }
3779 }
3780 other => merged_elements.push(other),
3781 }
3782 }
3783 merged_elements
3784}
3785
3786fn source_gap_before(elements: &[Element], idx: usize) -> &str {
3801 let Some(Element::Text(previous)) = idx.checked_sub(1).map(|prev| &elements[prev]) else {
3802 return "";
3803 };
3804
3805 let gap = &previous[previous.trim_end_matches(char::is_whitespace).len()..];
3806 if gap.is_empty() {
3807 ""
3808 } else if gap.contains(is_significant_whitespace) {
3809 gap
3810 } else {
3811 " "
3812 }
3813}
3814
3815fn push_source_gap(current_line: &mut String, gap: &str) {
3818 if !gap.is_empty() && !current_line.is_empty() && !current_line.ends_with(char::is_whitespace) {
3819 current_line.push_str(gap);
3820 }
3821}
3822
3823fn is_setext_or_thematic(text: &str) -> bool {
3829 let mut marker = 0u8;
3830 let mut count = 0usize;
3831 let mut has_space = false;
3832 for &b in text.as_bytes() {
3833 match b {
3834 b' ' | b'\t' => has_space = true,
3835 b'-' | b'=' | b'*' | b'_' => {
3836 if marker == 0 {
3837 marker = b;
3838 } else if b != marker {
3839 return false;
3840 }
3841 count += 1;
3842 }
3843 _ => return false,
3844 }
3845 }
3846 match marker {
3847 b'=' => !has_space,
3848 b'-' => !has_space || count >= 3,
3849 b'*' | b'_' => count >= 3,
3850 _ => false,
3851 }
3852}
3853
3854pub(crate) fn is_table_delimiter_row(text: &str) -> bool {
3861 let text = text.trim_matches([' ', '\t']);
3862 if !text.contains(['|', ':']) {
3863 return false;
3864 }
3865 let text = text.strip_prefix('|').unwrap_or(text);
3866 let text = text.strip_suffix('|').unwrap_or(text);
3867 text.split('|').all(|cell| {
3868 let cell = cell.trim_matches([' ', '\t']);
3869 let cell = cell.strip_prefix(':').unwrap_or(cell);
3870 let cell = cell.strip_suffix(':').unwrap_or(cell);
3871 !cell.is_empty() && cell.bytes().all(|b| b == b'-')
3872 })
3873}
3874
3875fn starts_block_construct(text: &str) -> bool {
3891 let text = text.trim_start();
3892 let bytes = text.as_bytes();
3893 let Some(&first) = bytes.first() else {
3894 return false;
3895 };
3896 let marker_then_boundary = |len: usize| bytes.len() == len || bytes[len] == b' ' || bytes[len] == b'\t';
3897 match first {
3898 b'>' => true,
3900 b'-' => marker_then_boundary(1) || is_setext_or_thematic(text) || is_table_delimiter_row(text),
3901 b'*' | b'+' => marker_then_boundary(1) || is_setext_or_thematic(text),
3902 b'_' | b'=' => is_setext_or_thematic(text),
3903 b':' => true,
3905 b'|' => true,
3906 b'#' => {
3907 let hashes = bytes.iter().take_while(|&&b| b == b'#').count();
3912 hashes <= 6
3913 }
3914 b'`' => bytes.iter().take_while(|&&b| b == b'`').count() >= 3,
3915 b'~' => bytes.iter().take_while(|&&b| b == b'~').count() >= 3,
3916 b'0'..=b'9' => {
3923 let digits = bytes.iter().take_while(|b| b.is_ascii_digit()).count();
3924 digits <= 9
3925 && text[..digits].trim_start_matches('0') == "1"
3926 && bytes.len() > digits + 1
3927 && (bytes[digits] == b'.' || bytes[digits] == b')')
3928 && (bytes[digits + 1] == b' ' || bytes[digits + 1] == b'\t')
3929 }
3930 b'[' => {
3938 let mut escaped = false;
3939 let mut label_close = None;
3940 for (i, &b) in bytes.iter().enumerate().skip(1) {
3941 if escaped {
3942 escaped = false;
3943 } else if b == b'\\' {
3944 escaped = true;
3945 } else if b == b']' {
3946 label_close = Some(i);
3947 break;
3948 }
3949 }
3950 label_close.is_some_and(|i| bytes.get(i + 1) == Some(&b':'))
3951 }
3952 b'<' => {
3956 crate::utils::html_block::parse_html_block_start(text).is_some()
3957 || crate::utils::html_block::opens_untagged_html_block(text)
3958 }
3959 _ => false,
3960 }
3961}
3962
3963fn ends_with_unescaped_backslash(line: &str) -> bool {
3966 line.bytes().rev().take_while(|&b| b == b'\\').count() % 2 == 1
3967}
3968
3969fn merge_block_construct_continuations(lines: Vec<String>) -> Vec<String> {
3982 let mut merged: Vec<String> = Vec::with_capacity(lines.len());
3983 for line in lines {
3984 merged.push(line);
3985 while merged.len() > 1
3989 && (starts_block_construct(merged.last().expect("non-empty"))
3990 || ends_with_unescaped_backslash(&merged[merged.len() - 2]))
3991 {
3992 let last = merged.pop().expect("non-empty");
3993 let prev = merged.last_mut().expect("len > 1");
3994 prev.push(' ');
3995 prev.push_str(last.trim_start());
3996 }
3997 }
3998 merged
3999}
4000
4001fn keep_first_line_out_of_html_block(mut lines: Vec<String>) -> Vec<String> {
4011 let Some(first) = lines.first() else {
4012 return lines;
4013 };
4014 if !crate::utils::html_block::opens_tag_line_html_block(first.trim_start()) {
4015 return lines;
4016 }
4017 if lines.len() > 1 {
4018 let next = lines.remove(1);
4019 lines[0].push(' ');
4020 lines[0].push_str(next.trim_start());
4021 return lines;
4022 }
4023 let tag = lines.remove(0);
4024 let indent = tag.len() - tag.trim_start().len();
4025 let tag_len = crate::utils::html_block::complete_tag_len(&tag[indent..]).unwrap_or(0);
4026 let mut quote = None;
4027 let mut split = None;
4028 let mut quoted_split = None;
4029 for (offset, byte) in tag.bytes().enumerate().skip(indent).take(tag_len) {
4030 let breakable = matches!(byte, b' ' | b'\t') && !starts_block_construct(&tag[offset..]);
4031 match (quote, byte) {
4032 (None, b'"' | b'\'') => quote = Some(byte),
4033 (Some(open), _) if open == byte => quote = None,
4034 (None, _) if breakable => split = Some(offset),
4035 (Some(_), _) if breakable => quoted_split = Some(offset),
4036 _ => {}
4037 }
4038 }
4039 match split.or(quoted_split) {
4040 Some(offset) => vec![
4041 tag[..offset].trim_end().to_string(),
4042 tag[offset..].trim_start().to_string(),
4043 ],
4044 None => vec![tag],
4045 }
4046}
4047
4048fn elements_source_text(elements: &[Element]) -> (String, Vec<(usize, usize)>) {
4056 let mut text = String::new();
4057 let mut ranges = Vec::with_capacity(elements.len());
4058 for (idx, element) in elements.iter().enumerate() {
4059 let gap = source_gap_before(elements, idx);
4060 let piece = match element {
4061 Element::Text(content) => content.clone(),
4063 Element::Italic { content, underscore } => {
4064 wrap_emphasis(content, if *underscore { "_" } else { "*" }, &mut text, gap)
4065 }
4066 Element::Bold { content, underscore } => {
4067 wrap_emphasis(content, if *underscore { "__" } else { "**" }, &mut text, gap)
4068 }
4069 Element::Strikethrough { content, double } => {
4070 wrap_emphasis(content, if *double { "~~" } else { "~" }, &mut text, gap)
4071 }
4072 _ => {
4073 push_source_gap(&mut text, gap);
4074 element.to_string()
4075 }
4076 };
4077 let start = text.len();
4078 text.push_str(&piece);
4079 ranges.push((start, text.len()));
4080 }
4081 (text, ranges)
4082}
4083
4084fn reflow_elements_sentence_per_line(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
4086 let abbreviations = get_abbreviations(&options.abbreviations);
4087 let require_sentence_capital = options.require_sentence_capital;
4088 let mut lines = Vec::new();
4089
4090 let (paragraph_text, piece_ranges) = elements_source_text(elements);
4098 let paragraph = sentence_structure_with_links(
4099 ¶graph_text,
4100 options.defined_references.as_ref(),
4101 &abbreviations,
4102 options.link_text_punctuation,
4103 options.semantic_line_breaks,
4104 );
4105 let structure_from = |base: usize| ParagraphStructure {
4106 text: ¶graph_text,
4107 structure: ¶graph,
4108 base,
4109 };
4110 let mut line: Option<usize> = None;
4118 let absorb = |line_start: usize, (piece_start, piece_end): (usize, usize)| {
4126 let in_front = line_start <= piece_start;
4127 debug_assert!(
4128 in_front,
4129 "line under construction begins at {line_start}, past the element at {piece_start} of {paragraph_text:?}"
4130 );
4131 if in_front {
4132 (line_start, piece_end, Some(structure_from(line_start)))
4133 } else {
4134 (piece_start, piece_end, None)
4135 }
4136 };
4137
4138 for (idx, element) in elements.iter().enumerate() {
4139 let (line_start, line_end, structure) = absorb(line.unwrap_or(piece_ranges[idx].0), piece_ranges[idx]);
4140 if !paragraph.parentheticals.is_empty() && line.is_some() && idx > 0 {
4145 let previous_end = piece_ranges[idx - 1].1;
4146 let past = paragraph
4147 .parentheticals
4148 .partition_point(|&(start, _)| start < previous_end);
4149 if past > 0 {
4150 let group_end = paragraph.parentheticals[past - 1].1;
4151 if previous_end < group_end && line_end < group_end {
4152 line = Some(line_start);
4153 continue;
4154 }
4155 }
4156 }
4157 let combined = ¶graph_text[line_start..line_end];
4158
4159 let splits = matches!(
4165 element,
4166 Element::Text(_) | Element::Italic { .. } | Element::Bold { .. } | Element::Strikethrough { .. }
4167 );
4168
4169 if splits {
4170 let sentences = match structure {
4173 Some(structure) => split_into_sentence_ranges(
4174 combined,
4175 &abbreviations,
4176 require_sentence_capital,
4177 options.cjk_soft_break,
4178 options.defined_references.as_ref(),
4179 Some(structure),
4180 options.link_text_punctuation,
4181 ),
4182 None => trim_range(combined, 0, combined.len()).into_iter().collect(),
4183 };
4184
4185 let glued_to_next = piece_ranges
4196 .get(idx + 1)
4197 .is_some_and(|&(next_start, _)| !paragraph_text[..next_start].ends_with(is_breakable_whitespace));
4198 let next_bracketed = elements
4199 .get(idx + 1)
4200 .filter(|next| next.opens_with_bracket())
4201 .map(|_| piece_ranges[idx + 1]);
4202 let closes_before_next = |sentence: &str| -> bool {
4203 let sentence_end = line_start + combined.trim_end_matches(is_breakable_whitespace).len();
4204 if glued_to_next
4205 || inside_parenthetical(sentence_end, ¶graph.parentheticals)
4206 || text_ends_with_numbered_abbreviation(sentence, ¶graph_text[sentence_end..])
4207 {
4208 return false;
4209 }
4210 let Some((next_start, next_end)) = next_bracketed else {
4211 return true;
4212 };
4213 let probe_start = sentence_end - sentence.len();
4216 let probe = ¶graph_text[probe_start..next_end];
4217 let next_str = ¶graph_text[next_start..next_end];
4218 let probe_sentences = split_into_sentences_with_set(
4219 probe,
4220 &abbreviations,
4221 require_sentence_capital,
4222 options.cjk_soft_break,
4223 options.defined_references.as_ref(),
4224 Some(structure_from(probe_start)),
4225 options.link_text_punctuation,
4226 );
4227 probe_sentences.last().is_some_and(|last| last == next_str)
4228 };
4229
4230 if sentences.len() > 1 {
4231 let mut pending_start = None;
4237 let last = sentences.len() - 1;
4238 for (i, &(start, end)) in sentences.iter().enumerate() {
4239 let pending = &combined[*pending_start.get_or_insert(start)..end];
4240
4241 let closed = i < last || (ends_with_sentence_punct(pending) && closes_before_next(pending));
4246 if closed && !text_ends_with_abbreviation(pending, &abbreviations) {
4247 lines.push(pending.to_string());
4248 pending_start = None;
4249 }
4250 }
4251 line = pending_start.map(|held| line_start + held);
4252 } else {
4253 let trimmed = combined.trim_matches(is_breakable_whitespace);
4255
4256 if trimmed.is_empty() {
4260 continue;
4261 }
4262
4263 let ends_with_sentence_punct = ends_with_sentence_punct(trimmed);
4264
4265 if ends_with_sentence_punct
4266 && !text_ends_with_abbreviation(trimmed, &abbreviations)
4267 && closes_before_next(trimmed)
4268 {
4269 lines.push(combined.trim_matches(is_breakable_whitespace).to_string());
4272 line = None;
4273 } else {
4274 line = Some(line_start);
4276 }
4277 }
4278 } else {
4279 line = Some(line_start);
4282 }
4283 }
4284
4285 if let Some(line_start) = line {
4293 let tail = ¶graph_text[line_start..];
4297 lines.extend(split_into_sentences_with_set(
4298 tail,
4299 &abbreviations,
4300 require_sentence_capital,
4301 options.cjk_soft_break,
4302 options.defined_references.as_ref(),
4303 Some(structure_from(line_start)),
4304 options.link_text_punctuation,
4305 ));
4306 }
4307 lines
4308}
4309
4310#[derive(Clone)]
4311struct SentencePackSeparator {
4312 text: String,
4313 preserve_inline: bool,
4314}
4315
4316struct SentencePackUnit {
4317 text: String,
4318 separator_before: SentencePackSeparator,
4319}
4320
4321fn sentence_pack_separators(
4332 elements: &[Element],
4333 sentences: &[String],
4334 options: &ReflowOptions,
4335) -> Vec<SentencePackSeparator> {
4336 let mut separators: Vec<SentencePackSeparator> = sentences
4337 .windows(2)
4338 .map(|pair| SentencePackSeparator {
4339 text: if options.sentence_double_space && ends_latin_sentence(&pair[0]) {
4340 " "
4341 } else {
4342 " "
4343 }
4344 .to_string(),
4345 preserve_inline: false,
4346 })
4347 .collect();
4348 if options.cjk_soft_break != CjkSoftBreak::Join {
4349 return separators;
4350 }
4351
4352 let (source, _) = elements_source_text(elements);
4353 let mut cursor = 0;
4354 for (idx, pair) in sentences.windows(2).enumerate() {
4355 let Some(current_offset) = source[cursor..].find(&pair[0]) else {
4356 continue;
4357 };
4358 let current_end = cursor + current_offset + pair[0].len();
4359 let Some(next_offset) = source[current_end..].find(&pair[1]) else {
4360 cursor = current_end;
4361 continue;
4362 };
4363 let next_start = current_end + next_offset;
4364 cursor = next_start;
4365
4366 if pair[0].chars().next_back().is_some_and(joins_cjk_soft_break)
4367 && pair[1].chars().next().is_some_and(joins_cjk_soft_break)
4368 {
4369 let text = source[current_end..next_start].to_string();
4370 separators[idx] = SentencePackSeparator {
4371 preserve_inline: !text.is_empty(),
4372 text,
4373 };
4374 }
4375 }
4376 separators
4377}
4378
4379fn sentence_pack_units(elements: &[Element], options: &ReflowOptions) -> Vec<SentencePackUnit> {
4381 let sentences = reflow_elements_sentence_per_line(elements, options);
4382 let separators = sentence_pack_separators(elements, &sentences, options);
4383 let mut units: Vec<SentencePackUnit> = Vec::with_capacity(sentences.len());
4384
4385 for (idx, text) in sentences.into_iter().enumerate() {
4386 let separator_before = idx.checked_sub(1).map_or(
4387 SentencePackSeparator {
4388 text: String::new(),
4389 preserve_inline: false,
4390 },
4391 |separator| separators[separator].clone(),
4392 );
4393 units.push(SentencePackUnit { text, separator_before });
4394
4395 while units.len() > 1 && starts_block_construct(&units.last().expect("non-empty").text) {
4396 let last = units.pop().expect("non-empty");
4397 let previous = &mut units.last_mut().expect("len > 1").text;
4398 previous.push(' ');
4399 previous.push_str(last.text.trim_start());
4400 }
4401 }
4402 units
4403}
4404
4405fn sentence_pack_groups(elements: &[Element], options: &ReflowOptions) -> Vec<SentencePackUnit> {
4407 let units = sentence_pack_units(elements, options);
4408 let mut groups: Vec<SentencePackUnit> = Vec::with_capacity(units.len());
4409
4410 for unit in units {
4411 if unit.separator_before.preserve_inline
4412 && let Some(previous) = groups.last_mut()
4413 {
4414 previous.text.push_str(&unit.separator_before.text);
4415 previous.text.push_str(&unit.text);
4416 continue;
4417 }
4418 groups.push(unit);
4419 }
4420 groups
4421}
4422
4423fn reflow_elements_sentence_pack(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
4425 let mut lines: Vec<String> = Vec::new();
4426 for unit in sentence_pack_groups(elements, options) {
4427 let budget = if lines.len() == 1 {
4428 options.first_line_length.unwrap_or(options.line_length)
4429 } else {
4430 options.line_length
4431 };
4432 if let Some(current) = lines.last_mut() {
4433 let previous_len = current.len();
4434 current.push_str(&unit.separator_before.text);
4435 current.push_str(&unit.text);
4436 if budget == 0 || budget == usize::MAX || line_width(current, options) <= budget {
4437 continue;
4438 }
4439 current.truncate(previous_len);
4440 }
4441 lines.push(unit.text);
4442 }
4443 lines
4444}
4445
4446fn wrap_emphasis(content: &str, marker: &str, current_line: &mut String, gap: &str) -> String {
4450 push_source_gap(current_line, gap);
4451 format!("{marker}{content}{marker}")
4452}
4453
4454const BREAK_WORDS: &[&str] = &[
4458 "and",
4459 "or",
4460 "but",
4461 "nor",
4462 "yet",
4463 "so",
4464 "for",
4465 "which",
4466 "that",
4467 "because",
4468 "when",
4469 "if",
4470 "while",
4471 "where",
4472 "although",
4473 "though",
4474 "unless",
4475 "since",
4476 "after",
4477 "before",
4478 "until",
4479 "as",
4480 "once",
4481 "whether",
4482 "however",
4483 "therefore",
4484 "moreover",
4485 "furthermore",
4486 "nevertheless",
4487 "whereas",
4488];
4489
4490fn is_clause_punctuation(c: char) -> bool {
4492 matches!(c, ',' | ';' | ':' | '\u{2014}') }
4494
4495fn clause_break_allowed_after(chars: &[char], i: usize) -> bool {
4506 match chars.get(i + 1) {
4507 None => true,
4508 Some(next) => is_breakable_whitespace(*next),
4509 }
4510}
4511
4512fn paren_group_end<'a>(slice: &'a str, element_spans: &[ElementSpan], offset: usize) -> Option<(usize, &'a str)> {
4526 debug_assert!(slice.starts_with('('));
4527 let mut depth: i32 = 0;
4528 for (local_byte, c) in slice.char_indices() {
4529 let global_byte = offset + local_byte;
4530 if depth > 0 && is_inside_element(global_byte, element_spans) {
4535 continue;
4536 }
4537 match c {
4538 '(' => depth += 1,
4539 ')' => {
4540 depth -= 1;
4541 if depth == 0 {
4542 let end = local_byte + 1;
4543 let inner = &slice[1..local_byte];
4544 return Some((end, inner));
4545 }
4546 }
4547 _ => {}
4548 }
4549 }
4550 None
4551}
4552
4553fn split_at_parenthetical(
4570 text: &str,
4571 line_length: usize,
4572 element_spans: &[ElementSpan],
4573 length_mode: ReflowLengthMode,
4574) -> Option<(String, String)> {
4575 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
4576
4577 if text.starts_with('(')
4579 && let Some((end_local, inner)) = paren_group_end(text, element_spans, 0)
4580 && inner.contains(' ')
4581 {
4582 let mut first_end = end_local;
4589 loop {
4590 first_end += text[first_end..]
4591 .char_indices()
4592 .take_while(|(_, c)| !is_breakable_whitespace(*c))
4593 .last()
4594 .map_or(0, |(idx, c)| idx + c.len_utf8());
4595 match element_containing(first_end, element_spans) {
4596 Some(span) => first_end = span.end,
4597 None => break,
4598 }
4599 }
4600 let rest_start = first_end;
4601 let first = &text[..first_end];
4602 if measure(first, 0, element_spans, length_mode).fits(line_length) {
4605 let rest = text[rest_start..].trim_start();
4606 if !rest.is_empty() {
4607 return Some((first.to_string(), rest.to_string()));
4608 }
4609 }
4610 }
4611
4612 let mut best_open_byte: Option<usize> = None;
4614 let mut pos = 0usize;
4615 while pos < text.len() {
4616 if text.as_bytes()[pos] != b'(' {
4618 let c = text[pos..].chars().next().unwrap();
4619 pos += c.len_utf8();
4620 continue;
4621 }
4622 if is_inside_element(pos, element_spans) {
4624 pos += 1;
4625 continue;
4626 }
4627 if let Some((end_local, inner)) = paren_group_end(&text[pos..], element_spans, pos) {
4628 let first = text[..pos].trim_end_matches(is_breakable_whitespace);
4629 let first_len = measure(first, 0, element_spans, length_mode).effective();
4630 if first.len() < pos
4633 && !first.is_empty()
4634 && first_len >= min_first_len
4635 && first_len <= line_length
4636 && inner.contains(' ')
4637 && best_open_byte.is_none_or(|prev| pos > prev)
4638 {
4639 best_open_byte = Some(pos);
4640 }
4641 pos += end_local;
4642 } else {
4643 pos += 1;
4644 }
4645 }
4646
4647 let open_byte = best_open_byte?;
4648 let first = text[..open_byte].trim_end_matches(is_breakable_whitespace).to_string();
4649 let rest = text[open_byte..].to_string();
4650 if first.is_empty() || rest.trim().is_empty() {
4651 return None;
4652 }
4653 Some((first, rest))
4654}
4655
4656#[derive(Clone, Copy, Debug, PartialEq, Eq)]
4664struct ElementSpan {
4665 start: usize,
4666 end: usize,
4667 full: usize,
4668 link_saving: usize,
4671 code_saving: usize,
4673 is_hard: bool,
4675}
4676
4677impl ElementSpan {
4678 fn new(start: usize, len: usize, full: usize, width: LineWidth, is_hard: bool) -> Self {
4681 Self {
4682 start,
4683 end: start + len,
4684 full,
4685 link_saving: full - width.link_exempt,
4686 code_saving: full - width.code_exempt,
4687 is_hard,
4688 }
4689 }
4690
4691 fn contains(&self, pos: usize) -> bool {
4692 pos > self.start && pos < self.end
4693 }
4694
4695 fn within(&self, start: usize, end: usize) -> bool {
4696 self.start >= start && self.end <= end
4697 }
4698
4699 fn exempt_width(&self) -> LineWidth {
4700 LineWidth {
4701 link_exempt: self.full - self.link_saving,
4702 code_exempt: self.full - self.code_saving,
4703 }
4704 }
4705}
4706
4707fn link_text_parts(element: &Element, break_link_text: bool) -> Option<(&str, &str, &str)> {
4713 if !break_link_text {
4714 return None;
4715 }
4716 let (raw, open) = match element {
4717 Element::Link(raw)
4718 | Element::ReferenceLink(raw)
4719 | Element::EmptyReferenceLink(raw)
4720 | Element::ShortcutReference(raw) => (raw.as_str(), 0),
4721 Element::InlineImage(raw) | Element::ReferenceImage(raw) | Element::EmptyReferenceImage(raw) => {
4722 (raw.as_str(), 1)
4723 }
4724 _ => return None,
4725 };
4726 let inner = bracketed_text(raw, open)?;
4727 Some((&raw[..=open], inner, &raw[open + 1 + inner.len()..]))
4728}
4729
4730fn element_is_hard(element: &Element, break_link_text: bool) -> bool {
4738 match element {
4739 Element::Bold { content, .. } | Element::Italic { content, .. } | Element::Strikethrough { content, .. } => {
4740 content.contains(['[', '`', '<', '$', '{'])
4741 }
4742 _ => match link_text_parts(element, break_link_text) {
4743 Some((_, inner, suffix)) => {
4744 inner.contains(['[', '`', '<', '$', '{']) || suffix.chars().any(char::is_whitespace)
4745 }
4746 None => true,
4747 },
4748 }
4749}
4750
4751fn compute_element_spans(
4757 elements: &[Element],
4758 mode: ReflowLengthMode,
4759 exemptions: LengthExemptions,
4760 break_link_text: bool,
4761) -> Vec<ElementSpan> {
4762 let mut spans = Vec::new();
4763 let mut offset = 0;
4764 for element in elements {
4765 let len = element.display_len(ReflowLengthMode::Bytes);
4766 if !matches!(element, Element::Text(_)) {
4767 let full = element.display_len(mode);
4768 let width = element.exempt_width(mode, exemptions);
4769 spans.push(ElementSpan::new(
4770 offset,
4771 len,
4772 full,
4773 width,
4774 element_is_hard(element, break_link_text),
4775 ));
4776 }
4777 offset += len;
4778 }
4779 spans
4780}
4781
4782fn measure(text: &str, offset: usize, spans: &[ElementSpan], mode: ReflowLengthMode) -> LineWidth {
4790 let full = display_len(text, mode);
4791 let end = offset + text.len();
4792 let mut width = LineWidth::plain(full);
4793 for span in spans.iter().filter(|span| span.within(offset, end)) {
4794 width.link_exempt -= span.link_saving;
4795 width.code_exempt -= span.code_saving;
4796 }
4797 width
4798}
4799
4800fn line_width_components(line: &str, options: &ReflowOptions) -> LineWidth {
4805 let raw = display_len(line, options.length_mode);
4806 if !options.length_exemptions.any() {
4807 return LineWidth::plain(raw);
4808 }
4809 let elements = parse_markdown_elements_inner(
4810 line,
4811 options.attr_lists,
4812 options.myst_roles,
4813 options.defined_references.as_ref(),
4814 );
4815 let spans = compute_element_spans(
4816 &elements,
4817 options.length_mode,
4818 options.length_exemptions,
4819 options.break_link_text,
4820 );
4821 measure(line, 0, &spans, options.length_mode)
4822}
4823
4824fn line_width(line: &str, options: &ReflowOptions) -> usize {
4826 line_width_components(line, options).effective()
4827}
4828
4829fn line_fits(line: &str, options: &ReflowOptions) -> bool {
4835 display_len(line, options.length_mode) <= options.line_length || line_width(line, options) <= options.line_length
4836}
4837
4838fn element_containing(pos: usize, spans: &[ElementSpan]) -> Option<ElementSpan> {
4842 let past = spans.partition_point(|span| span.start < pos);
4843 past.checked_sub(1)
4844 .map(|index| spans[index])
4845 .filter(|span| span.contains(pos))
4846}
4847
4848fn is_inside_element(pos: usize, spans: &[ElementSpan]) -> bool {
4850 element_containing(pos, spans).is_some()
4851}
4852
4853const MIN_SPLIT_RATIO: f64 = 0.3;
4856
4857fn split_at_clause_punctuation(
4861 text: &str,
4862 line_length: usize,
4863 element_spans: &[ElementSpan],
4864 length_mode: ReflowLengthMode,
4865) -> Option<(String, String)> {
4866 let chars: Vec<char> = text.chars().collect();
4867 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
4868
4869 let mut width_acc = LineWidth::default();
4875 let mut search_end_char = 0;
4876 let mut byte = 0usize;
4877 let mut idx = 0usize;
4878 while idx < chars.len() {
4879 let (advance_chars, advance_bytes, width) = match element_spans.iter().find(|s| s.start == byte) {
4880 Some(span) => {
4881 let source = &text[span.start..span.end];
4882 (
4883 source.chars().count(),
4884 source.len(),
4885 measure(source, span.start, element_spans, length_mode),
4886 )
4887 }
4888 None => {
4889 let c = chars[idx];
4890 (
4891 1,
4892 c.len_utf8(),
4893 LineWidth::plain(display_len(&c.to_string(), length_mode)),
4894 )
4895 }
4896 };
4897 if !(width_acc + width).fits(line_length) {
4898 break;
4899 }
4900 width_acc += width;
4901 byte += advance_bytes;
4902 idx += advance_chars;
4903 search_end_char = idx;
4904 }
4905
4906 let mut paren_depth: i32 = 0;
4913 let mut best_pos = None;
4914 for i in (0..search_end_char).rev() {
4915 let byte_start: usize = chars[..i].iter().map(|c| c.len_utf8()).sum();
4917 let byte_after: usize = byte_start + chars[i].len_utf8();
4919
4920 if !is_inside_element(byte_start, element_spans) {
4921 match chars[i] {
4922 ')' => paren_depth += 1,
4923 '(' => paren_depth = paren_depth.saturating_sub(1),
4924 _ => {}
4925 }
4926 }
4927
4928 if paren_depth == 0
4929 && is_clause_punctuation(chars[i])
4930 && clause_break_allowed_after(&chars, i)
4931 && !is_inside_element(byte_after, element_spans)
4932 {
4933 best_pos = Some(i);
4934 break;
4935 }
4936 }
4937
4938 let pos = best_pos?;
4939
4940 let first: String = chars[..=pos].iter().collect();
4942 if measure(&first, 0, element_spans, length_mode).effective() < min_first_len {
4943 return None;
4944 }
4945
4946 let rest: String = chars[pos + 1..].iter().collect();
4948 let rest = rest.trim_start().to_string();
4949
4950 if rest.is_empty() {
4951 return None;
4952 }
4953
4954 Some((first, rest))
4955}
4956
4957fn paren_depth_map(text: &str, element_spans: &[ElementSpan]) -> Vec<i32> {
4964 let mut map = vec![0i32; text.len()];
4965 let mut depth = 0i32;
4966 for (byte, c) in text.char_indices() {
4967 if !is_inside_element(byte, element_spans) {
4968 match c {
4969 '(' => depth += 1,
4970 ')' => depth = depth.saturating_sub(1),
4971 _ => {}
4972 }
4973 }
4974 let end = (byte + c.len_utf8()).min(map.len());
4976 for slot in &mut map[byte..end] {
4977 *slot = depth;
4978 }
4979 }
4980 map
4981}
4982
4983fn is_standalone_parenthetical(line: &str) -> bool {
4992 let trimmed = line.trim();
4993 if !trimmed.starts_with('(') {
4994 return false;
4995 }
4996 let Some(close) = trimmed.rfind(')') else {
4999 return false;
5000 };
5001 if trimmed[close + 1..].contains(char::is_whitespace) {
5002 return false;
5003 }
5004 let core = &trimmed[..=close];
5005 let inner = &core[1..core.len() - 1];
5007 if !inner.contains(' ') {
5008 return false;
5009 }
5010 let mut depth = 0i32;
5012 for c in core.chars() {
5013 match c {
5014 '(' => depth += 1,
5015 ')' => depth -= 1,
5016 _ => {}
5017 }
5018 if depth < 0 {
5019 return false;
5020 }
5021 }
5022 depth == 0
5023}
5024
5025fn split_at_break_word(
5029 text: &str,
5030 line_length: usize,
5031 element_spans: &[ElementSpan],
5032 length_mode: ReflowLengthMode,
5033 custom_break_words: &[String],
5034) -> Option<(String, String)> {
5035 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
5036 let mut best_split = None;
5037
5038 let depth_map = paren_depth_map(text, element_spans);
5041
5042 let mut offset = 0;
5045 for token in text.split_inclusive(' ') {
5046 let Some(word) = token.strip_suffix(' ') else {
5047 break;
5048 };
5049 let position = offset;
5050 offset += token.len();
5051 if word.is_empty()
5052 || !(BREAK_WORDS.iter().any(|default| word.eq_ignore_ascii_case(default))
5053 || custom_break_words
5054 .iter()
5055 .any(|custom| unicase::UniCase::new(word) == unicase::UniCase::new(custom.as_str())))
5056 {
5057 continue;
5058 }
5059 let first_part = text[..position].trim_end();
5060 let first_part_len = measure(first_part, 0, element_spans, length_mode).effective();
5061 let inside_paren = depth_map.get(position).is_some_and(|&depth| depth > 0);
5062 if first_part_len >= min_first_len
5063 && first_part_len <= line_length
5064 && !is_inside_element(position, element_spans)
5065 && !inside_paren
5066 {
5067 best_split = Some(position);
5069 }
5070 }
5071
5072 let byte_start = best_split?;
5073
5074 let first = text[..byte_start].trim_end().to_string();
5075 let rest = text[byte_start..].to_string();
5076
5077 if first.is_empty() || rest.trim().is_empty() {
5078 return None;
5079 }
5080
5081 Some((first, rest))
5082}
5083
5084fn replaces_whitespace(text: &str, first: &str, rest: &str, element_spans: &[ElementSpan]) -> bool {
5095 if !text.starts_with(first) || !text.ends_with(rest) {
5096 return false;
5097 }
5098 let gap_end = text.len() - rest.len();
5099 gap_end > first.len()
5100 && text[first.len()..gap_end].chars().all(is_breakable_whitespace)
5101 && !element_spans
5102 .iter()
5103 .any(|span| first.len() < span.end && span.start < gap_end)
5104}
5105
5106fn cascade_split_line(text: &str, options: &ReflowOptions) -> Vec<String> {
5117 let line_length = options.line_length;
5118 let length_mode = options.length_mode;
5119 let attr_lists = options.attr_lists;
5120 let myst_roles = options.myst_roles;
5121 let defined_references = options.defined_references.as_ref();
5122 if line_length == 0 || display_len(text, length_mode) <= line_length {
5123 return vec![text.to_string()];
5124 }
5125
5126 let elements = parse_markdown_elements_inner(text, attr_lists, myst_roles, defined_references);
5127 let element_spans = compute_element_spans(
5128 &elements,
5129 length_mode,
5130 options.length_exemptions,
5131 options.break_link_text,
5132 );
5133
5134 if measure(text, 0, &element_spans, length_mode).fits(line_length) {
5137 return vec![text.to_string()];
5138 }
5139
5140 let rebased_spans = |start: usize| -> Vec<ElementSpan> {
5144 if start == 0 {
5145 return element_spans.clone();
5146 }
5147 element_spans
5148 .iter()
5149 .filter(|span| span.end > start)
5150 .map(|span| ElementSpan {
5151 start: span.start.saturating_sub(start),
5152 end: span.end.saturating_sub(start),
5153 ..*span
5154 })
5155 .collect()
5156 };
5157
5158 let mut result = Vec::new();
5159 let mut start = 0usize;
5160
5161 loop {
5162 let remaining = &text[start..];
5163 let spans = rebased_spans(start);
5164 if measure(remaining, 0, &spans, length_mode).fits(line_length) {
5165 result.push(remaining.to_string());
5166 return result;
5167 }
5168
5169 let at_whitespace = |candidate: Option<(String, String)>| {
5178 candidate.filter(|(first, rest)| replaces_whitespace(remaining, first, rest, &spans))
5179 };
5180 let split = at_whitespace(split_at_parenthetical(remaining, line_length, &spans, length_mode))
5181 .or_else(|| at_whitespace(split_at_clause_punctuation(remaining, line_length, &spans, length_mode)))
5182 .or_else(|| {
5183 at_whitespace(split_at_break_word(
5184 remaining,
5185 line_length,
5186 &spans,
5187 length_mode,
5188 &options.break_words,
5189 ))
5190 });
5191
5192 if let Some((first, rest)) = split {
5193 let consumed = remaining.len().saturating_sub(rest.len());
5194 if consumed == 0 {
5197 break;
5198 }
5199 result.push(first);
5200 start += consumed;
5201 continue;
5202 }
5203
5204 break;
5206 }
5207
5208 let mut fallback_options = options.clone();
5210 fallback_options.break_on_sentences = false;
5211 fallback_options.preserve_breaks = false;
5212 fallback_options.sentence_per_line = false;
5213 fallback_options.semantic_line_breaks = false;
5214 fallback_options.require_sentence_capital = true;
5215 fallback_options.max_list_continuation_indent = None;
5216 fallback_options.defined_references = None;
5217 let remaining = &text[start..];
5218 let tail_elements = if start == 0 {
5219 elements
5220 } else {
5221 parse_markdown_elements_inner(remaining, attr_lists, myst_roles, defined_references)
5222 };
5223 result.extend(reflow_elements(&tail_elements, &fallback_options));
5224 result
5225}
5226
5227fn cascade_semantic_sentence(text: &str, options: &ReflowOptions) -> Vec<String> {
5231 let (Some(open), Some(close)) = (text.find('('), text.rfind(')')) else {
5232 return cascade_split_line(text, options);
5233 };
5234 if open >= close || !text[open..close].contains(['.', '!', '?', '。', '!', '?']) {
5235 return cascade_split_line(text, options);
5236 }
5237 let abbreviations = get_abbreviations(&options.abbreviations);
5238 let mut structure = sentence_structure_with_links(
5239 text,
5240 options.defined_references.as_ref(),
5241 &abbreviations,
5242 options.link_text_punctuation,
5243 true,
5244 );
5245 let protected = structure.parentheticals.len();
5246 structure
5247 .parentheticals
5248 .retain(|&(start, end)| line_fits(&text[start..end], options));
5249 if structure.parentheticals.len() == protected {
5250 return cascade_split_line(text, options);
5251 }
5252
5253 split_into_sentence_ranges(
5254 text,
5255 &abbreviations,
5256 options.require_sentence_capital,
5257 options.cjk_soft_break,
5258 options.defined_references.as_ref(),
5259 Some(ParagraphStructure {
5260 text,
5261 structure: &structure,
5262 base: 0,
5263 }),
5264 options.link_text_punctuation,
5265 )
5266 .into_iter()
5267 .flat_map(|(start, end)| cascade_split_line(&text[start..end], options))
5268 .collect()
5269}
5270
5271fn reflow_elements_semantic(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
5275 let sentence_lines = reflow_elements_sentence_per_line(elements, options);
5277
5278 if options.line_length == 0 {
5281 return sentence_lines;
5282 }
5283
5284 let mut result = Vec::new();
5285 let mut sentence_starts = Vec::new();
5286 for line in sentence_lines {
5287 if options.link_text_punctuation == LinkTextPunctuation::Trailing {
5288 sentence_starts.push(result.len());
5289 }
5290 if line_fits(&line, options) {
5291 result.push(line);
5292 } else {
5293 result.extend(cascade_semantic_sentence(&line, options));
5294 }
5295 }
5296
5297 let min_line_len = ((options.line_length as f64) * MIN_SPLIT_RATIO) as usize;
5300 let mut merged: Vec<String> = Vec::with_capacity(result.len());
5301 for (index, line) in result.into_iter().enumerate() {
5302 if !merged.is_empty()
5305 && sentence_starts.binary_search(&index).is_err()
5306 && line_width(&line, options) < min_line_len
5307 && !line.trim().is_empty()
5308 {
5309 if is_standalone_parenthetical(&line) {
5312 merged.push(line);
5313 continue;
5314 }
5315
5316 let prev_ends_at_sentence = {
5318 let trimmed = merged.last().unwrap().trim_end();
5319 trimmed
5320 .chars()
5321 .rev()
5322 .find(|&c| !(is_closing_quote(c) || is_closing_bracket(c)))
5323 .is_some_and(|c| matches!(c, '.' | '!' | '?') || is_cjk_sentence_ending(c))
5324 };
5325
5326 if !prev_ends_at_sentence {
5327 let prev = merged.last_mut().unwrap();
5328 let combined = format!("{prev} {line}");
5329 if line_fits(&combined, options) {
5331 *prev = combined;
5332 continue;
5333 }
5334 }
5335 }
5336 merged.push(line);
5337 }
5338 merged
5339}
5340
5341fn rfind_safe_space(
5352 line: &str,
5353 element_spans: &[ElementSpan],
5354 options: &ReflowOptions,
5355 relax_soft_spans: bool,
5356) -> Option<usize> {
5357 line.char_indices().rev().map(|(pos, _)| pos).find(|&pos| {
5358 line.as_bytes()[pos] == b' '
5359 && !is_inside_element_filtered(pos, element_spans, options, relax_soft_spans)
5360 && !starts_block_construct(&line[pos + 1..])
5361 && !ends_with_unescaped_backslash(line[..pos].trim_end_matches(is_breakable_whitespace))
5362 })
5363}
5364
5365fn is_inside_element_filtered(
5366 pos: usize,
5367 spans: &[ElementSpan],
5368 options: &ReflowOptions,
5369 relax_soft_spans: bool,
5370) -> bool {
5371 spans.iter().any(|span| {
5372 span.contains(pos)
5373 && (!relax_soft_spans
5374 || span.is_hard
5375 || (options.atomic_spans && span.exempt_width().fits(options.line_length)))
5376 })
5377}
5378
5379#[derive(Clone, Copy)]
5384struct Attached<'a> {
5385 text: &'a str,
5386 width: LineWidth,
5387 separator: &'a str,
5388}
5389
5390fn break_before_attached(
5407 lines: &mut Vec<String>,
5408 current_line: &mut String,
5409 current_width: &mut LineWidth,
5410 element_spans: &mut Vec<ElementSpan>,
5411 attach: Attached<'_>,
5412 options: &ReflowOptions,
5413) -> Option<usize> {
5414 let length_mode = options.length_mode;
5415 let last_space = rfind_safe_space(current_line, element_spans, options, false)
5416 .or_else(|| rfind_safe_space(current_line, element_spans, options, true))?;
5417 let before = current_line[..last_space]
5418 .trim_end_matches(is_breakable_whitespace)
5419 .to_string();
5420 let after = current_line[last_space + 1..].to_string();
5421 let after_width = measure(&after, last_space + 1, element_spans, length_mode);
5422 lines.push(before);
5423 let carried = after.len();
5424 let Attached { text, width, separator } = attach;
5425 *current_line = format!("{after}{separator}{text}");
5426 *current_width = after_width + LineWidth::plain(display_len(separator, length_mode)) + width;
5427 rebase_spans_after_break(element_spans, last_space + 1);
5428 Some(carried)
5429}
5430
5431fn rebase_spans_after_break(element_spans: &mut Vec<ElementSpan>, carried_start: usize) {
5440 element_spans.retain(|span| span.end > carried_start);
5441 for span in element_spans.iter_mut() {
5442 span.start = span.start.saturating_sub(carried_start);
5443 span.end -= carried_start;
5444 }
5445}
5446
5447fn reflow_elements(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
5449 let mut lines = Vec::new();
5450 let mut current_line = String::new();
5451 let mut current_width = LineWidth::default();
5454 let mut current_line_element_spans: Vec<ElementSpan> = Vec::new();
5456 let length_mode = options.length_mode;
5457 let exemptions = options.length_exemptions;
5458 let sentence_spacing = options
5460 .sentence_double_space
5461 .then(|| get_abbreviations(&options.abbreviations));
5462
5463 for (idx, element) in elements.iter().enumerate() {
5464 let element_len = element.display_len(length_mode);
5465 let element_width = element.exempt_width(length_mode, exemptions);
5466 let is_hard = element_is_hard(element, options.break_link_text);
5467
5468 let is_adjacent_to_prev = if idx > 0 {
5477 match (&elements[idx - 1], element) {
5478 (Element::Text(t), _) => !t.is_empty() && !t.ends_with(is_breakable_whitespace),
5479 (_, Element::Text(t)) => !t.is_empty() && !t.starts_with(is_breakable_whitespace),
5480 _ => true,
5481 }
5482 } else {
5483 false
5484 };
5485
5486 if let Element::Text(text) = element {
5488 let has_leading_space = text.starts_with(is_breakable_whitespace);
5490 let words = split_breakable_words_with_gaps(text);
5492
5493 for (i, &(word, gap)) in words.iter().enumerate() {
5494 let word_width = LineWidth::plain(display_len(word, length_mode));
5496 let separator = gap_separator(¤t_line, gap, sentence_spacing.as_ref());
5498 let separator_width = LineWidth::plain(separator.len());
5499 let is_trailing_punct = word.chars().all(|c| {
5505 matches!(c, ',' | '.' | ':' | ';' | '!' | '?' | ')' | ']' | '}') || is_significant_whitespace(c)
5506 });
5507
5508 let is_first_adjacent = i == 0 && is_adjacent_to_prev;
5511
5512 if is_first_adjacent {
5513 if !(current_width + word_width).fits(options.line_length)
5515 && !current_width.is_empty()
5516 && break_before_attached(
5517 &mut lines,
5518 &mut current_line,
5519 &mut current_width,
5520 &mut current_line_element_spans,
5521 Attached {
5522 text: word,
5523 width: word_width,
5524 separator: "",
5525 },
5526 options,
5527 )
5528 .is_some()
5529 {
5530 } else {
5535 current_line.push_str(word);
5536 current_width += word_width;
5537 }
5538 } else if !current_width.is_empty()
5539 && !(current_width + separator_width + word_width).fits(options.line_length)
5540 {
5541 if is_trailing_punct {
5542 if break_before_attached(
5549 &mut lines,
5550 &mut current_line,
5551 &mut current_width,
5552 &mut current_line_element_spans,
5553 Attached {
5554 text: word,
5555 width: word_width,
5556 separator,
5557 },
5558 options,
5559 )
5560 .is_none()
5561 {
5562 current_line.push_str(separator);
5563 current_line.push_str(word);
5564 current_width += separator_width + word_width;
5565 }
5566 } else if !starts_block_construct(word)
5567 && !ends_with_unescaped_backslash(current_line.trim_end_matches(is_breakable_whitespace))
5568 {
5569 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
5571 current_line = word.to_string();
5572 current_width = word_width;
5573 current_line_element_spans.clear();
5574 } else if break_before_attached(
5575 &mut lines,
5576 &mut current_line,
5577 &mut current_width,
5578 &mut current_line_element_spans,
5579 Attached {
5580 text: word,
5581 width: word_width,
5582 separator,
5583 },
5584 options,
5585 )
5586 .is_some()
5587 {
5588 } else {
5594 if i > 0 || has_leading_space {
5597 current_line.push_str(separator);
5598 current_width += separator_width;
5599 }
5600 current_line.push_str(word);
5601 current_width += word_width;
5602 }
5603 } else {
5604 let add_space = !current_width.is_empty() && (i > 0 || has_leading_space);
5616 if add_space {
5617 current_line.push_str(separator);
5618 current_width += separator_width;
5619 }
5620 current_line.push_str(word);
5621 current_width += word_width;
5622 }
5623 }
5624 } else {
5625 let separator = gap_separator(
5627 ¤t_line,
5628 gap_before_element(elements, idx),
5629 sentence_spacing.as_ref(),
5630 );
5631 let separator_width = LineWidth::plain(separator.len());
5632 let link_parts = link_text_parts(element, options.break_link_text);
5633 let span_info = match element {
5634 Element::Italic { content, underscore } => {
5635 let marker = if *underscore { "_" } else { "*" };
5636 Some((content.as_str(), marker, marker, false))
5637 }
5638 Element::Bold { content, underscore } => {
5639 let marker = if *underscore { "__" } else { "**" };
5640 Some((content.as_str(), marker, marker, false))
5641 }
5642 Element::Strikethrough { content, double } => {
5643 let marker = if *double { "~~" } else { "~" };
5644 Some((content.as_str(), marker, marker, false))
5645 }
5646 Element::Code { content, marker } => Some((content.as_str(), marker.as_str(), marker.as_str(), true)),
5647 _ => link_parts.map(|(prefix, inner, suffix)| (inner, prefix, suffix, false)),
5648 };
5649 let is_link = link_parts.is_some();
5650
5651 let breakable: Option<Vec<&str>> = match span_info {
5658 Some((content, _, suffix, is_code)) => {
5659 if is_code {
5660 (!options.atomic_spans && options.wrap_code_spans && code_span_wraps_losslessly(content))
5661 .then(|| split_breakable_words(content).collect())
5662 } else if is_link {
5663 (!options.atomic_spans || !element_width.fits(options.line_length))
5664 .then(|| {
5665 link_text_break_units(
5666 content,
5667 suffix,
5668 options.line_length,
5669 length_mode,
5670 options.defined_references.as_ref(),
5671 options.attr_lists,
5672 )
5673 })
5674 .flatten()
5675 } else {
5676 (!options.atomic_spans || element_len > options.line_length)
5677 .then(|| breakable_units(content, options.defined_references.as_ref(), options.attr_lists))
5678 .flatten()
5679 }
5680 }
5681 None => None,
5682 };
5683
5684 if let Some(words) = breakable {
5685 let (_, prefix, suffix, is_code) = span_info.expect("breakable implies a span");
5686 let n = words.len();
5687 if n == 0 {
5688 let full = format!("{prefix}{suffix}");
5690 let full_width = LineWidth::plain(display_len(&full, length_mode));
5691 if !is_adjacent_to_prev && !current_width.is_empty() {
5692 current_line.push_str(separator);
5693 current_width += separator_width;
5694 }
5695 current_line.push_str(&full);
5696 current_width += full_width;
5697 } else {
5698 let suffix_span_width = LineWidth::plain(display_len(suffix, length_mode));
5704
5705 for (i, word) in words.iter().enumerate() {
5706 let is_first = i == 0;
5707 let is_last = i == n - 1;
5708
5709 let space_start = if is_first && is_code && word.starts_with('`') {
5710 " "
5711 } else {
5712 ""
5713 };
5714 let space_end = if is_last && is_code && word.ends_with('`') {
5715 " "
5716 } else {
5717 ""
5718 };
5719
5720 let word_str: String = match (is_first, is_last) {
5721 (true, true) => format!("{prefix}{space_start}{word}{space_end}{suffix}"),
5722 (true, false) => format!("{prefix}{space_start}{word}"),
5723 (false, true) => format!("{word}{space_end}{suffix}"),
5724 (false, false) => word.to_string(),
5725 };
5726 let word_elements = parse_elements(&word_str, options);
5727 let word_spans =
5728 compute_element_spans(&word_elements, length_mode, exemptions, options.break_link_text);
5729 let word_width = measure(&word_str, 0, &word_spans, length_mode);
5730
5731 let needs_space = if is_first {
5732 !is_adjacent_to_prev && !current_width.is_empty()
5733 } else {
5734 !current_width.is_empty()
5735 };
5736 let (space, space_width) = if is_first {
5738 (separator, separator_width)
5739 } else {
5740 (" ", LineWidth::plain(1))
5741 };
5742
5743 if needs_space
5744 && !(current_width + space_width + word_width).fits(options.line_length)
5745 && !starts_block_construct(&word_str)
5746 {
5747 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
5748 current_line = word_str;
5749 current_width = word_width;
5750 current_line_element_spans.clear();
5751 for span in word_spans {
5752 current_line_element_spans.push(span);
5753 }
5754 if is_link && is_last {
5755 current_line_element_spans.push(ElementSpan::new(
5756 word.len(),
5757 suffix.len(),
5758 display_len(suffix, length_mode),
5759 suffix_span_width,
5760 true,
5761 ));
5762 }
5763 } else {
5764 let mut start_pos = current_line.len();
5765 if needs_space {
5766 current_line.push_str(space);
5767 current_width += space_width;
5768 start_pos += space.len();
5769 }
5770 current_line.push_str(&word_str);
5771 current_width += word_width;
5772 for mut span in word_spans {
5773 span.start += start_pos;
5774 span.end += start_pos;
5775 current_line_element_spans.push(span);
5776 }
5777 if is_link && is_last {
5778 current_line_element_spans.push(ElementSpan::new(
5779 start_pos + word.len(),
5780 suffix.len(),
5781 display_len(suffix, length_mode),
5782 suffix_span_width,
5783 true,
5784 ));
5785 }
5786 }
5787 }
5788 }
5789 } else {
5790 let element_str = format!("{element}");
5793
5794 if is_adjacent_to_prev {
5795 if !(current_width + element_width).fits(options.line_length)
5797 && let Some(carried) = break_before_attached(
5798 &mut lines,
5799 &mut current_line,
5800 &mut current_width,
5801 &mut current_line_element_spans,
5802 Attached {
5803 text: &element_str,
5804 width: element_width,
5805 separator: "",
5806 },
5807 options,
5808 )
5809 {
5810 current_line_element_spans.push(ElementSpan::new(
5814 carried,
5815 element_str.len(),
5816 element_len,
5817 element_width,
5818 is_hard,
5819 ));
5820 } else {
5821 let start = current_line.len();
5822 current_line.push_str(&element_str);
5823 current_width += element_width;
5824 current_line_element_spans.push(ElementSpan::new(
5825 start,
5826 element_str.len(),
5827 element_len,
5828 element_width,
5829 is_hard,
5830 ));
5831 }
5832 } else if !current_width.is_empty()
5833 && !(current_width + separator_width + element_width).fits(options.line_length)
5834 {
5835 if !starts_block_construct(&element_str) {
5836 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
5838 current_line.clone_from(&element_str);
5839 current_width = element_width;
5840 current_line_element_spans.clear();
5841 current_line_element_spans.push(ElementSpan::new(
5842 0,
5843 element_str.len(),
5844 element_len,
5845 element_width,
5846 is_hard,
5847 ));
5848 } else if let Some(carried) = break_before_attached(
5849 &mut lines,
5850 &mut current_line,
5851 &mut current_width,
5852 &mut current_line_element_spans,
5853 Attached {
5854 text: &element_str,
5855 width: element_width,
5856 separator,
5857 },
5858 options,
5859 ) {
5860 let start = carried + separator.len();
5864 current_line_element_spans.push(ElementSpan::new(
5865 start,
5866 element_str.len(),
5867 element_len,
5868 element_width,
5869 is_hard,
5870 ));
5871 } else {
5872 let ends_with_opener =
5875 current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
5876 if !ends_with_opener {
5877 current_line.push_str(separator);
5878 current_width += separator_width;
5879 }
5880 let start = current_line.len();
5881 current_line.push_str(&element_str);
5882 current_width += element_width;
5883 current_line_element_spans.push(ElementSpan::new(
5884 start,
5885 element_str.len(),
5886 element_len,
5887 element_width,
5888 is_hard,
5889 ));
5890 }
5891 } else {
5892 let ends_with_opener =
5894 current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
5895 if !current_width.is_empty() && !ends_with_opener {
5896 current_line.push_str(separator);
5897 current_width += separator_width;
5898 }
5899 let start = current_line.len();
5900 current_line.push_str(&element_str);
5901 current_width += element_width;
5902 current_line_element_spans.push(ElementSpan::new(
5903 start,
5904 element_str.len(),
5905 element_len,
5906 element_width,
5907 is_hard,
5908 ));
5909 }
5910 }
5911 }
5912 }
5913
5914 if !current_line.is_empty() {
5916 lines.push(current_line.trim_end_matches(is_breakable_whitespace).to_string());
5917 }
5918
5919 lines
5920}
5921
5922pub fn reflow_markdown(content: &str, options: &ReflowOptions) -> String {
5924 let lines: Vec<&str> = content.lines().collect();
5925 let inside_code_span = lines_touching_multiline_code_span(content);
5927 let mut result = Vec::new();
5928 let mut i = 0;
5929
5930 while i < lines.len() {
5931 let line = lines[i];
5932 let trimmed = line.trim();
5933
5934 if trimmed.is_empty() {
5936 result.push(String::new());
5937 i += 1;
5938 continue;
5939 }
5940
5941 if trimmed.starts_with('#') {
5943 result.push(line.to_string());
5944 i += 1;
5945 continue;
5946 }
5947
5948 if trimmed.starts_with(":::") {
5950 result.push(line.to_string());
5951 i += 1;
5952 continue;
5953 }
5954
5955 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
5957 result.push(line.to_string());
5958 i += 1;
5959 while i < lines.len() {
5961 result.push(lines[i].to_string());
5962 if lines[i].trim().starts_with("```") || lines[i].trim().starts_with("~~~") {
5963 i += 1;
5964 break;
5965 }
5966 i += 1;
5967 }
5968 continue;
5969 }
5970
5971 if calculate_indentation_width_default(line) >= 4 {
5973 result.push(line.to_string());
5975 i += 1;
5976 while i < lines.len() {
5977 let next_line = lines[i];
5978 if calculate_indentation_width_default(next_line) >= 4 || next_line.trim().is_empty() {
5980 result.push(next_line.to_string());
5981 i += 1;
5982 } else {
5983 break;
5984 }
5985 }
5986 continue;
5987 }
5988
5989 if trimmed.starts_with('>') {
5991 let gt_pos = line.find('>').expect("'>' must exist since trimmed.starts_with('>')");
5994 let quote_prefix = line[0..=gt_pos].to_string();
5995 let quote_content = &line[quote_prefix.len()..].trim_start();
5996
5997 let reflowed = reflow_line(quote_content, options);
5998 for reflowed_line in &reflowed {
5999 result.push(format!("{quote_prefix} {reflowed_line}"));
6000 }
6001 i += 1;
6002 continue;
6003 }
6004
6005 if is_horizontal_rule(trimmed) {
6007 result.push(line.to_string());
6008 i += 1;
6009 continue;
6010 }
6011
6012 if is_unordered_list_marker(trimmed) || is_numbered_list_item(trimmed) {
6014 let indent = line.len() - line.trim_start().len();
6016 let indent_str = " ".repeat(indent);
6017
6018 let mut marker_end = indent;
6021 let mut content_start = indent;
6022
6023 if trimmed.chars().next().is_some_and(char::is_numeric) {
6024 if let Some(period_pos) = line[indent..].find('.') {
6026 marker_end = indent + period_pos + 1; content_start = marker_end;
6028 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
6032 content_start += 1;
6033 }
6034 }
6035 } else {
6036 marker_end = indent + 1; content_start = marker_end;
6039 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
6043 content_start += 1;
6044 }
6045 }
6046
6047 let min_continuation_indent = content_start;
6049
6050 let rest = &line[content_start..];
6053 if rest.starts_with("[ ] ") || rest.starts_with("[x] ") || rest.starts_with("[X] ") {
6054 marker_end = content_start + 3; content_start += 4; }
6057
6058 let marker = &line[indent..marker_end];
6059
6060 let mut list_content = vec![trim_preserving_hard_break(&line[content_start..])];
6063 i += 1;
6064
6065 while i < lines.len() {
6069 let next_line = lines[i];
6070 let next_trimmed = next_line.trim();
6071
6072 if is_block_boundary(next_trimmed) {
6074 break;
6075 }
6076
6077 let next_indent = next_line.len() - next_line.trim_start().len();
6079 if next_indent >= min_continuation_indent {
6080 let trimmed_start = next_line.trim_start();
6083 list_content.push(trim_preserving_hard_break(trimmed_start));
6084 i += 1;
6085 } else {
6086 break;
6088 }
6089 }
6090
6091 let combined_content = if options.preserve_breaks {
6094 list_content[0].clone()
6095 } else {
6096 let has_hard_breaks = list_content.iter().any(|line| has_hard_break(line));
6098 if has_hard_breaks {
6099 list_content.join("\n")
6101 } else {
6102 join_reflow_lines(&list_content, options)
6103 }
6104 };
6105
6106 let trimmed_marker = marker;
6108 let continuation_spaces = if let Some(max_indent) = options.max_list_continuation_indent {
6109 indent + (content_start - indent).min(max_indent)
6112 } else {
6113 content_start
6114 };
6115
6116 let prefix_length = indent + trimmed_marker.len() + 1;
6118
6119 let adjusted_options = ReflowOptions {
6121 line_length: options.line_length.saturating_sub(prefix_length),
6122 ..options.clone()
6123 };
6124
6125 let reflowed = reflow_line(&combined_content, &adjusted_options);
6126 for (j, reflowed_line) in reflowed.iter().enumerate() {
6127 if j == 0 {
6128 result.push(format!("{indent_str}{trimmed_marker} {reflowed_line}"));
6129 } else {
6130 let continuation_indent = " ".repeat(continuation_spaces);
6132 result.push(format!("{continuation_indent}{reflowed_line}"));
6133 }
6134 }
6135 continue;
6136 }
6137
6138 if crate::utils::table_utils::TableUtils::is_potential_table_row(line) {
6140 result.push(line.to_string());
6141 i += 1;
6142 continue;
6143 }
6144
6145 if trimmed.starts_with('[') && line.contains("]:") {
6147 result.push(line.to_string());
6148 i += 1;
6149 continue;
6150 }
6151
6152 if is_definition_list_marker(trimmed) && has_block_line_above(&lines, i) {
6158 result.push(line.to_string());
6159 i += 1;
6160 continue;
6161 }
6162
6163 let mut is_single_line_paragraph = true;
6165 if i + 1 < lines.len() {
6166 let next_trimmed = lines[i + 1].trim();
6167 if !is_block_boundary(next_trimmed) {
6169 is_single_line_paragraph = false;
6170 }
6171 }
6172
6173 if is_single_line_paragraph && line_fits(line, options) {
6175 result.push(line.to_string());
6176 i += 1;
6177 continue;
6178 }
6179
6180 let mut paragraph_parts: Vec<(String, bool)> = Vec::new();
6184 let mut current_part = vec![line];
6185 i += 1;
6186
6187 if options.preserve_breaks {
6189 let hard_break_type = if line.strip_suffix('\r').unwrap_or(line).ends_with('\\') {
6191 Some("\\")
6192 } else if line.ends_with(" ") {
6193 Some(" ")
6194 } else {
6195 None
6196 };
6197 let reflowed = reflow_line(line, options);
6198
6199 if let Some(break_marker) = hard_break_type {
6201 if !reflowed.is_empty() {
6202 let mut reflowed_with_break = reflowed;
6203 let last_idx = reflowed_with_break.len() - 1;
6204 if !has_hard_break(&reflowed_with_break[last_idx]) {
6205 reflowed_with_break[last_idx].push_str(break_marker);
6206 }
6207 result.extend(reflowed_with_break);
6208 }
6209 } else {
6210 result.extend(reflowed);
6211 }
6212 } else {
6213 let paragraph_start = i - 1;
6227 let paragraph_scan = options.sentence_per_line.then(|| {
6228 let mut paragraph_end = i;
6229 while paragraph_end < lines.len() && !is_block_boundary(lines[paragraph_end].trim()) {
6230 paragraph_end += 1;
6231 }
6232 let joined_paragraph = lines[paragraph_start..paragraph_end].join(" ");
6233 let joined_atomic = sentence_structure(&joined_paragraph, options.defined_references.as_ref()).atomic;
6234 let mut line_starts = Vec::with_capacity(paragraph_end - paragraph_start);
6235 let mut line_offset = 0;
6236 for paragraph_line in &lines[paragraph_start..paragraph_end] {
6237 line_starts.push(line_offset);
6238 line_offset += paragraph_line.len() + 1;
6239 }
6240 (joined_atomic, line_starts)
6241 });
6242
6243 while i < lines.len() {
6244 let prev_line = if !current_part.is_empty() {
6245 current_part.last().unwrap()
6246 } else {
6247 ""
6248 };
6249 let next_line = lines[i];
6250 let next_trimmed = next_line.trim();
6251
6252 if is_block_boundary(next_trimmed) {
6254 break;
6255 }
6256
6257 let prev_trimmed = prev_line.trim();
6260 let abbreviations = get_abbreviations(&options.abbreviations);
6261 let ends_with_sentence = (prev_trimmed.ends_with('.')
6262 || prev_trimmed.ends_with('!')
6263 || prev_trimmed.ends_with('?')
6264 || prev_trimmed.ends_with(".*")
6265 || prev_trimmed.ends_with("!*")
6266 || prev_trimmed.ends_with("?*")
6267 || prev_trimmed.ends_with("._")
6268 || prev_trimmed.ends_with("!_")
6269 || prev_trimmed.ends_with("?_")
6270 || prev_trimmed.ends_with(".\"")
6272 || prev_trimmed.ends_with("!\"")
6273 || prev_trimmed.ends_with("?\"")
6274 || prev_trimmed.ends_with(".'")
6275 || prev_trimmed.ends_with("!'")
6276 || prev_trimmed.ends_with("?'")
6277 || prev_trimmed.ends_with(".\u{201D}")
6278 || prev_trimmed.ends_with("!\u{201D}")
6279 || prev_trimmed.ends_with("?\u{201D}")
6280 || prev_trimmed.ends_with(".\u{2019}")
6281 || prev_trimmed.ends_with("!\u{2019}")
6282 || prev_trimmed.ends_with("?\u{2019}")
6283 || ends_cjk_sentence_with_closer(prev_trimmed))
6288 && !text_ends_with_abbreviation(
6289 prev_trimmed.trim_end_matches(['*', '_', '"', '\'', '\u{201D}', '\u{2019}']),
6290 &abbreviations,
6291 )
6292 && !text_ends_with_numbered_abbreviation(
6293 prev_trimmed.trim_end_matches(['*', '_', '"', '\'', '\u{201D}', '\u{2019}']),
6294 next_line,
6295 );
6296
6297 let inside_construct = paragraph_scan.as_ref().is_some_and(|(joined_atomic, line_starts)| {
6300 let join_offset = line_starts[i - paragraph_start] - 1;
6301 joined_atomic
6302 .iter()
6303 .any(|&(start, end)| start <= join_offset && join_offset < end)
6304 });
6305
6306 let ends_at_hard_break = has_hard_break(prev_line);
6313 if ends_at_hard_break
6314 || (is_self_contained_display_math_line(prev_line) && !inside_code_span[i - 1])
6315 || (is_self_contained_display_math_line(next_line) && !inside_code_span[i])
6316 || (options.sentence_per_line && ends_with_sentence && !inside_construct)
6317 {
6318 paragraph_parts.push((join_reflow_lines(¤t_part, options), ends_at_hard_break));
6320 current_part = vec![next_line];
6321 } else {
6322 current_part.push(next_line);
6323 }
6324 i += 1;
6325 }
6326
6327 if !current_part.is_empty() {
6329 if current_part.len() == 1 {
6330 paragraph_parts.push((current_part[0].to_string(), false));
6332 } else {
6333 paragraph_parts.push((join_reflow_lines(¤t_part, options), false));
6334 }
6335 }
6336
6337 for (j, (part, ends_at_hard_break)) in paragraph_parts.iter().enumerate() {
6339 let reflowed = reflow_line(part, options);
6340 result.extend(reflowed);
6341
6342 if *ends_at_hard_break
6348 && j < paragraph_parts.len() - 1
6349 && !result.is_empty()
6350 && !options.sentence_per_line
6351 {
6352 let last_idx = result.len() - 1;
6353 if !has_hard_break(&result[last_idx]) {
6354 result[last_idx].push_str(" ");
6355 }
6356 }
6357 }
6358 }
6359 }
6360
6361 let mut result_text = result.join("\n");
6364 if content.ends_with('\n') {
6365 result_text.push('\n');
6366 }
6367 result_text
6368}
6369
6370#[derive(Debug, Clone)]
6372pub struct ParagraphReflow {
6373 pub start_byte: usize,
6375 pub end_byte: usize,
6377 pub reflowed_text: String,
6379}
6380
6381#[derive(Debug, Clone)]
6387pub struct BlockquoteLineData {
6388 pub(crate) content: String,
6390 pub(crate) is_explicit: bool,
6392 pub(crate) prefix: Option<String>,
6394}
6395
6396impl BlockquoteLineData {
6397 pub fn explicit(content: String, prefix: String) -> Self {
6399 Self {
6400 content,
6401 is_explicit: true,
6402 prefix: Some(prefix),
6403 }
6404 }
6405
6406 pub fn lazy(content: String) -> Self {
6408 Self {
6409 content,
6410 is_explicit: false,
6411 prefix: None,
6412 }
6413 }
6414}
6415
6416#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6418pub enum BlockquoteContinuationStyle {
6419 Explicit,
6420 Lazy,
6421}
6422
6423pub fn blockquote_continuation_style(lines: &[BlockquoteLineData]) -> BlockquoteContinuationStyle {
6431 let mut explicit_count = 0usize;
6432 let mut lazy_count = 0usize;
6433
6434 for line in lines.iter().skip(1) {
6435 if line.is_explicit {
6436 explicit_count += 1;
6437 } else {
6438 lazy_count += 1;
6439 }
6440 }
6441
6442 if explicit_count > 0 && lazy_count == 0 {
6443 BlockquoteContinuationStyle::Explicit
6444 } else if lazy_count > 0 && explicit_count == 0 {
6445 BlockquoteContinuationStyle::Lazy
6446 } else if explicit_count >= lazy_count {
6447 BlockquoteContinuationStyle::Explicit
6448 } else {
6449 BlockquoteContinuationStyle::Lazy
6450 }
6451}
6452
6453pub fn dominant_blockquote_prefix(lines: &[BlockquoteLineData], fallback: &str) -> String {
6458 let mut counts: std::collections::HashMap<String, (usize, usize)> = std::collections::HashMap::new();
6459
6460 for (idx, line) in lines.iter().enumerate() {
6461 let Some(prefix) = line.prefix.as_ref() else {
6462 continue;
6463 };
6464 counts
6465 .entry(prefix.clone())
6466 .and_modify(|entry| entry.0 += 1)
6467 .or_insert((1, idx));
6468 }
6469
6470 counts
6471 .into_iter()
6472 .max_by(|(_, (count_a, first_idx_a)), (_, (count_b, first_idx_b))| {
6473 count_a.cmp(count_b).then_with(|| first_idx_b.cmp(first_idx_a))
6474 })
6475 .map_or_else(|| fallback.to_string(), |(prefix, _)| prefix)
6476}
6477
6478pub(crate) fn should_force_explicit_blockquote_line(content_line: &str) -> bool {
6483 let trimmed = content_line.trim_start();
6484 trimmed.starts_with('>')
6485 || trimmed.starts_with('#')
6486 || trimmed.starts_with("```")
6487 || trimmed.starts_with("~~~")
6488 || is_unordered_list_marker(trimmed)
6489 || is_numbered_list_item(trimmed)
6490 || is_horizontal_rule(trimmed)
6491 || is_definition_list_marker(trimmed)
6492 || (trimmed.starts_with('[') && trimmed.contains("]:"))
6493 || trimmed.starts_with(":::")
6494 || (trimmed.starts_with('<')
6495 && !trimmed.starts_with("<http")
6496 && !trimmed.starts_with("<https")
6497 && !trimmed.starts_with("<mailto:"))
6498}
6499
6500pub fn reflow_blockquote_content(
6509 lines: &[BlockquoteLineData],
6510 explicit_prefix: &str,
6511 continuation_style: BlockquoteContinuationStyle,
6512 options: &ReflowOptions,
6513) -> Vec<String> {
6514 let content_strs: Vec<&str> = lines.iter().map(|l| l.content.as_str()).collect();
6515 let ends_in_code_span = lines_ending_in_code_span(&content_strs);
6516 let segments = split_into_segments_strs(&content_strs, &ends_in_code_span);
6517 let mut reflowed_content_lines: Vec<String> = Vec::new();
6518
6519 for segment in segments {
6520 let hard_break_type = segment.last().and_then(|&(line, in_code_span)| {
6521 let line = line.strip_suffix('\r').unwrap_or(line);
6522 if in_code_span {
6523 None
6524 } else if line.ends_with('\\') {
6525 Some("\\")
6526 } else if line.ends_with(" ") {
6527 Some(" ")
6528 } else {
6529 None
6530 }
6531 });
6532
6533 let pieces: Vec<&str> = segment
6534 .iter()
6535 .map(|&(line, in_code_span)| {
6536 if in_code_span {
6537 line
6538 } else if let Some(l) = line.strip_suffix('\\') {
6539 l.trim_end_matches(is_breakable_whitespace)
6540 } else if let Some(l) = line.strip_suffix(" ") {
6541 l.trim_end_matches(is_breakable_whitespace)
6542 } else {
6543 line.trim_end_matches(is_breakable_whitespace)
6544 }
6545 })
6546 .collect();
6547
6548 let segment_text = join_reflow_lines(&pieces, options);
6549 let segment_text = trim_breakable_whitespace(&segment_text);
6550 if segment_text.is_empty() {
6551 continue;
6552 }
6553
6554 let mut segment_options = options.clone();
6555 if options.sentence_pack
6556 && !reflowed_content_lines.is_empty()
6557 && !should_force_explicit_blockquote_line(segment_text)
6558 {
6559 segment_options.first_line_length = None;
6560 }
6561 let mut reflowed = reflow_line(segment_text, &segment_options);
6562 if let Some(break_marker) = hard_break_type
6563 && !reflowed.is_empty()
6564 {
6565 let last_idx = reflowed.len() - 1;
6566 if !has_hard_break(&reflowed[last_idx]) {
6567 reflowed[last_idx].push_str(break_marker);
6568 }
6569 }
6570 reflowed_content_lines.extend(reflowed);
6571 }
6572
6573 let mut styled_lines: Vec<String> = Vec::new();
6574 for (idx, line) in reflowed_content_lines.iter().enumerate() {
6575 let force_explicit = idx == 0
6576 || continuation_style == BlockquoteContinuationStyle::Explicit
6577 || should_force_explicit_blockquote_line(line);
6578 if force_explicit {
6579 styled_lines.push(format!("{explicit_prefix}{line}"));
6580 } else {
6581 styled_lines.push(line.clone());
6582 }
6583 }
6584
6585 styled_lines
6586}
6587
6588fn is_blockquote_content_boundary(content: &str) -> bool {
6589 let trimmed = content.trim();
6590 trimmed.is_empty()
6591 || is_block_boundary(trimmed)
6592 || crate::utils::table_utils::TableUtils::is_potential_table_row(content)
6593 || trimmed.starts_with(":::")
6594 || crate::utils::is_template_directive_only(content)
6595 || is_standalone_attr_list(content)
6596 || is_snippet_block_delimiter(content)
6597}
6598
6599fn split_into_segments_strs<'a>(lines: &[&'a str], ends_in_code_span: &[bool]) -> Vec<Vec<(&'a str, bool)>> {
6603 let mut segments = Vec::new();
6604 let mut current = Vec::new();
6605 let inside_code_span = lines_touching_multiline_code_span(&lines.join("\n"));
6609
6610 for (idx, &line) in lines.iter().enumerate() {
6611 let is_display_math =
6616 is_self_contained_display_math_line(line) && !inside_code_span.get(idx).is_some_and(|&inside| inside);
6617 if is_display_math && !current.is_empty() {
6618 segments.push(std::mem::take(&mut current));
6619 }
6620 current.push((line, ends_in_code_span[idx]));
6621 if (has_hard_break(line) && !ends_in_code_span[idx]) || is_display_math {
6622 segments.push(std::mem::take(&mut current));
6623 }
6624 }
6625
6626 if !current.is_empty() {
6627 segments.push(current);
6628 }
6629
6630 segments
6631}
6632
6633fn reflow_blockquote_paragraph_at_line(
6634 content: &str,
6635 lines: &[&str],
6636 target_idx: usize,
6637 options: &ReflowOptions,
6638) -> Option<ParagraphReflow> {
6639 let mut anchor_idx = target_idx;
6640 let mut target_level = if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[target_idx]) {
6641 parsed.nesting_level
6642 } else {
6643 let mut found = None;
6644 let mut idx = target_idx;
6645 loop {
6646 if lines[idx].trim().is_empty() {
6647 break;
6648 }
6649 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[idx]) {
6650 found = Some((idx, parsed.nesting_level));
6651 break;
6652 }
6653 if idx == 0 {
6654 break;
6655 }
6656 idx -= 1;
6657 }
6658 let (idx, level) = found?;
6659 anchor_idx = idx;
6660 level
6661 };
6662
6663 let mut para_start = anchor_idx;
6665 while para_start > 0 {
6666 let prev_idx = para_start - 1;
6667 let prev_line = lines[prev_idx];
6668
6669 if prev_line.trim().is_empty() {
6670 break;
6671 }
6672
6673 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(prev_line) {
6674 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
6675 break;
6676 }
6677 para_start = prev_idx;
6678 continue;
6679 }
6680
6681 let prev_lazy = prev_line.trim_start();
6682 if is_blockquote_content_boundary(prev_lazy) {
6683 break;
6684 }
6685 para_start = prev_idx;
6686 }
6687
6688 while para_start < lines.len() {
6690 let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[para_start]) else {
6691 para_start += 1;
6692 continue;
6693 };
6694 target_level = parsed.nesting_level;
6695 break;
6696 }
6697
6698 if para_start >= lines.len() || para_start > target_idx {
6699 return None;
6700 }
6701
6702 let mut collected: Vec<(usize, BlockquoteLineData)> = Vec::new();
6705 let mut idx = para_start;
6706 while idx < lines.len() {
6707 if !collected.is_empty() && has_hard_break(&collected[collected.len() - 1].1.content) {
6708 break;
6709 }
6710
6711 let line = lines[idx];
6712 if line.trim().is_empty() {
6713 break;
6714 }
6715
6716 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(line) {
6717 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
6718 break;
6719 }
6720 collected.push((
6721 idx,
6722 BlockquoteLineData::explicit(trim_preserving_hard_break(parsed.content), parsed.prefix.to_string()),
6723 ));
6724 idx += 1;
6725 continue;
6726 }
6727
6728 let lazy_content = line.trim_start();
6729 if is_blockquote_content_boundary(lazy_content) {
6730 break;
6731 }
6732
6733 collected.push((idx, BlockquoteLineData::lazy(trim_preserving_hard_break(lazy_content))));
6734 idx += 1;
6735 }
6736
6737 if collected.is_empty() {
6738 return None;
6739 }
6740
6741 let para_end = collected[collected.len() - 1].0;
6742 if target_idx < para_start || target_idx > para_end {
6743 return None;
6744 }
6745
6746 let line_data: Vec<BlockquoteLineData> = collected.iter().map(|(_, d)| d.clone()).collect();
6747
6748 let fallback_prefix = line_data
6749 .iter()
6750 .find_map(|d| d.prefix.clone())
6751 .unwrap_or_else(|| "> ".to_string());
6752 let explicit_prefix = dominant_blockquote_prefix(&line_data, &fallback_prefix);
6753 let continuation_style = blockquote_continuation_style(&line_data);
6754
6755 let adjusted_line_length = options
6756 .line_length
6757 .saturating_sub(display_len(&explicit_prefix, options.length_mode))
6758 .max(1);
6759
6760 let adjusted_options = if options.sentence_pack && continuation_style == BlockquoteContinuationStyle::Lazy {
6761 ReflowOptions {
6762 first_line_length: if options.line_length == 0 || options.line_length == usize::MAX {
6763 None
6764 } else {
6765 Some(adjusted_line_length)
6766 },
6767 ..options.clone()
6768 }
6769 } else {
6770 ReflowOptions {
6771 line_length: adjusted_line_length,
6772 first_line_length: None,
6773 ..options.clone()
6774 }
6775 };
6776
6777 let styled_lines = reflow_blockquote_content(&line_data, &explicit_prefix, continuation_style, &adjusted_options);
6778
6779 if styled_lines.is_empty() {
6780 return None;
6781 }
6782
6783 let mut start_byte = 0;
6785 for line in lines.iter().take(para_start) {
6786 start_byte += line.len() + 1;
6787 }
6788
6789 let mut end_byte = start_byte;
6790 for line in lines.iter().take(para_end + 1).skip(para_start) {
6791 end_byte += line.len() + 1;
6792 }
6793
6794 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
6795 if !includes_trailing_newline {
6796 end_byte -= 1;
6797 }
6798
6799 let reflowed_joined = styled_lines.join("\n");
6800 let reflowed_text = if includes_trailing_newline {
6801 if reflowed_joined.ends_with('\n') {
6802 reflowed_joined
6803 } else {
6804 format!("{reflowed_joined}\n")
6805 }
6806 } else if reflowed_joined.ends_with('\n') {
6807 reflowed_joined.trim_end_matches('\n').to_string()
6808 } else {
6809 reflowed_joined
6810 };
6811
6812 Some(ParagraphReflow {
6813 start_byte,
6814 end_byte,
6815 reflowed_text,
6816 })
6817}
6818
6819pub fn reflow_paragraph_at_line(content: &str, line_number: usize, line_length: usize) -> Option<ParagraphReflow> {
6837 reflow_paragraph_at_line_with_mode(content, line_number, line_length, ReflowLengthMode::default())
6838}
6839
6840pub fn reflow_paragraph_at_line_with_mode(
6842 content: &str,
6843 line_number: usize,
6844 line_length: usize,
6845 length_mode: ReflowLengthMode,
6846) -> Option<ParagraphReflow> {
6847 let options = ReflowOptions {
6848 line_length,
6849 length_mode,
6850 ..Default::default()
6851 };
6852 reflow_paragraph_at_line_with_options(content, line_number, &options)
6853}
6854
6855pub fn reflow_paragraph_at_line_with_options(
6866 content: &str,
6867 line_number: usize,
6868 options: &ReflowOptions,
6869) -> Option<ParagraphReflow> {
6870 if line_number == 0 {
6871 return None;
6872 }
6873
6874 let lines: Vec<&str> = content.lines().collect();
6875
6876 if line_number > lines.len() {
6878 return None;
6879 }
6880
6881 let target_idx = line_number - 1; let target_line = lines[target_idx];
6883 let trimmed = target_line.trim();
6884
6885 if let Some(blockquote_reflow) = reflow_blockquote_paragraph_at_line(content, &lines, target_idx, options) {
6888 return Some(blockquote_reflow);
6889 }
6890
6891 if is_paragraph_boundary(trimmed, target_line) {
6893 return None;
6894 }
6895
6896 let mut para_start = target_idx;
6898 while para_start > 0 {
6899 let prev_idx = para_start - 1;
6900 let prev_line = lines[prev_idx];
6901 let prev_trimmed = prev_line.trim();
6902
6903 if is_paragraph_boundary(prev_trimmed, prev_line) {
6905 break;
6906 }
6907
6908 para_start = prev_idx;
6909 }
6910
6911 let mut para_end = target_idx;
6913 while para_end + 1 < lines.len() {
6914 let next_idx = para_end + 1;
6915 let next_line = lines[next_idx];
6916 let next_trimmed = next_line.trim();
6917
6918 if is_paragraph_boundary(next_trimmed, next_line) {
6920 break;
6921 }
6922
6923 para_end = next_idx;
6924 }
6925
6926 let paragraph_lines = &lines[para_start..=para_end];
6928
6929 let mut start_byte = 0;
6931 for line in lines.iter().take(para_start) {
6932 start_byte += line.len() + 1; }
6934
6935 let mut end_byte = start_byte;
6936 for line in paragraph_lines {
6937 end_byte += line.len() + 1; }
6939
6940 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
6943
6944 if !includes_trailing_newline {
6946 end_byte -= 1;
6947 }
6948
6949 let paragraph_text = paragraph_lines.join("\n");
6951
6952 let reflowed = reflow_markdown(¶graph_text, options);
6954
6955 let reflowed_text = if includes_trailing_newline {
6959 if reflowed.ends_with('\n') {
6961 reflowed
6962 } else {
6963 format!("{reflowed}\n")
6964 }
6965 } else {
6966 if reflowed.ends_with('\n') {
6968 reflowed.trim_end_matches('\n').to_string()
6969 } else {
6970 reflowed
6971 }
6972 };
6973
6974 Some(ParagraphReflow {
6975 start_byte,
6976 end_byte,
6977 reflowed_text,
6978 })
6979}
6980fn decompose_code_span(raw: &str) -> Option<(&str, &str)> {
6986 let marker_len = raw.bytes().take_while(|&b| b == b'`').count();
6987 if marker_len == 0 {
6988 return None;
6989 }
6990 let marker = &raw[..marker_len];
6991 if raw.len() < marker_len * 2 {
6992 return None;
6993 }
6994 let content = &raw[marker_len..raw.len() - marker_len];
6995 Some((content, marker))
6996}
6997
6998#[cfg(test)]
6999mod tests {
7000 use super::*;
7001
7002 #[test]
7003 fn source_sentence_breaks_respect_custom_abbreviations_and_atomic_constructs() {
7004 let custom = Some(vec!["Widget".to_string()]);
7005 assert_eq!(
7006 join_sentence_lines(
7007 &["Use Widget.", "then retry."],
7008 CjkSoftBreak::Space,
7009 &custom,
7010 LinkTextPunctuation::Ignore,
7011 None,
7012 ),
7013 "Use Widget. then retry."
7014 );
7015 for lines in [
7016 vec!["Read `literal.", "text` here."],
7017 vec here."],
7018 vec!["Read <span title=\"literal.", "text\"> here."],
7019 ] {
7020 assert_eq!(
7021 join_sentence_lines(&lines, CjkSoftBreak::Space, &None, LinkTextPunctuation::Ignore, None),
7022 lines.join(" ")
7023 );
7024 }
7025 assert_eq!(
7026 join_sentence_lines(
7027 &["First sentence.", "lowercase follows."],
7028 CjkSoftBreak::Space,
7029 &None,
7030 LinkTextPunctuation::Ignore,
7031 None,
7032 ),
7033 "First sentence.\nlowercase follows."
7034 );
7035 }
7036
7037 #[test]
7038 fn double_spaced_sentence_lines_put_two_spaces_only_where_a_break_ends_a_sentence() {
7039 let join = |lines: &[&str], cjk| {
7040 join_double_spaced_sentence_lines(lines, cjk, &None, LinkTextPunctuation::Ignore, None)
7041 };
7042 assert_eq!(
7043 join(&["First sentence.", "Second one."], CjkSoftBreak::Space),
7044 "First sentence. Second one."
7045 );
7046 assert_eq!(
7047 join(&["Is it?", "It is!", "Done."], CjkSoftBreak::Space),
7048 "Is it? It is! Done."
7049 );
7050 assert_eq!(
7052 join(&["He said “done.”", "Then he left."], CjkSoftBreak::Space),
7053 "He said “done.” Then he left."
7054 );
7055 assert_eq!(
7059 join(&["Then (he left.)", "End."], CjkSoftBreak::Space),
7060 "Then (he left.) End."
7061 );
7062 assert_eq!(
7064 join(&["A sentence that", "goes on."], CjkSoftBreak::Space),
7065 "A sentence that goes on."
7066 );
7067 assert_eq!(
7068 join(&["Ask e.g.", "the team."], CjkSoftBreak::Space),
7069 "Ask e.g. the team."
7070 );
7071 assert_eq!(
7073 join(&["完成了。", "下一句。"], CjkSoftBreak::Space),
7074 "完成了。 下一句。"
7075 );
7076 assert_eq!(join(&["完成了。", "下一句。"], CjkSoftBreak::Join), "完成了。下一句。");
7077 }
7078
7079 #[test]
7080 fn gap_separator_keeps_two_spaces_only_after_a_sentence_end() {
7081 let abbreviations = get_abbreviations(&None);
7082 let on = Some(&abbreviations);
7083 assert_eq!(gap_separator("It ended.", " ", on), " ");
7084 assert_eq!(gap_separator("It ended.)", " ", on), " ");
7085 assert_eq!(gap_separator("It ended.", " ", on), " ");
7086 assert_eq!(gap_separator("It goes", " ", on), " ");
7087 assert_eq!(gap_separator("Ask Dr.", " ", on), " ");
7088 assert_eq!(gap_separator("It ended.", " ", None), " ");
7089 }
7090
7091 #[test]
7092 fn breakable_words_come_with_the_gaps_before_them() {
7093 assert_eq!(
7094 split_breakable_words_with_gaps(" One. Two\tthree "),
7095 vec![("One.", " "), ("Two", " "), ("three", "\t")]
7096 );
7097 assert_eq!(split_breakable_words_with_gaps("word"), vec![("word", "")]);
7098 assert!(split_breakable_words_with_gaps(" ").is_empty());
7099 }
7100
7101 #[test]
7105 fn preserves_content_accepts_whitespace_changes_and_rejects_the_rest() {
7106 let accepted: &[(&str, &[&str])] = &[
7107 ("one two three", &["one two three"]),
7108 ("one two three", &["one two", "three"]),
7109 ("one two three", &["one", "two", "three"]),
7110 ("one two ", &["one two"]),
7112 ("日本語のテキスト", &["日本語の", "テキスト"]),
7114 ("_First. Second._", &["_First.", "Second._"]),
7116 ];
7117 for (original, reflowed) in accepted {
7118 let reflowed: Vec<String> = reflowed.iter().map(ToString::to_string).collect();
7119 assert!(
7120 preserves_content(original, &reflowed),
7121 "{original:?} -> {reflowed:?} only moves whitespace"
7122 );
7123 }
7124
7125 let rejected: &[(&str, &[&str])] = &[
7126 ("one two three", &["one two"]),
7128 ("one two", &["one two three"]),
7130 ("one two", &["two one"]),
7132 ("_First. Second._", &["_First._", "_Second._"]),
7134 ("alpha and beta", &["alpha", "andbeta"]),
7136 ("mot suivant : autre", &["mot suivant: autre"]),
7138 ];
7139 for (original, reflowed) in rejected {
7140 let reflowed: Vec<String> = reflowed.iter().map(ToString::to_string).collect();
7141 assert!(
7142 !preserves_content(original, &reflowed),
7143 "{original:?} -> {reflowed:?} changes the text, not just its line breaks"
7144 );
7145 }
7146 }
7147
7148 #[test]
7151 fn nested_structure_reads_past_indentation_starting_the_text() {
7152 for indent in [" ", "\t", " "] {
7153 let text = format!("{indent}run `cargo test` now");
7154 let start = text.find('`').unwrap();
7155 let end = text.rfind('`').unwrap() + 1;
7156 assert_eq!(
7157 nested_structure(&text, None, false).code_spans,
7158 vec![(start, end)],
7159 "{text:?}"
7160 );
7161 }
7162 assert!(code_span_runs_into_indentation(&[" `cargo test", " --all`"]));
7163 }
7164
7165 #[test]
7168 fn lines_touching_multiline_code_span_reads_an_indented_code_block_as_code() {
7169 assert_eq!(
7170 lines_touching_multiline_code_span(" `a\n $$x`$$\n"),
7171 [false, false]
7172 );
7173 assert_eq!(lines_touching_multiline_code_span("Run `a\n$$x`$$\n"), [true, true]);
7174 }
7175
7176 #[test]
7178 fn reflow_line_falls_back_to_the_input_when_content_would_change() {
7179 let options = ReflowOptions {
7180 line_length: 40,
7181 ..Default::default()
7182 };
7183 let line = "one two three four five six seven eight nine ten";
7184
7185 assert!(preserves_content(line, &reflow_line(line, &options)));
7186 assert_eq!(reflow_line(line, &options), reflow_line_unchecked(line, &options));
7187 }
7188
7189 #[test]
7190 fn cascade_split_line_handles_a_very_long_line_without_overflowing() {
7191 let words: Vec<String> = (0..4000).map(|i| format!("word{i}")).collect();
7197 let line = words.join(" ");
7198
7199 let options = ReflowOptions {
7200 line_length: 80,
7201 length_mode: ReflowLengthMode::Chars,
7202 ..Default::default()
7203 };
7204 let out = cascade_split_line(&line, &options);
7205
7206 assert!(out.len() > 1, "a very long line should split into many lines");
7207 for segment in &out {
7208 assert!(
7209 display_len(segment, ReflowLengthMode::Chars) <= 80 || !segment.contains(' '),
7210 "each wrapped line should fit the width (or be a single unbreakable token)"
7211 );
7212 }
7213 let rejoined = out.join(" ");
7215 let original_words: Vec<&str> = line.split(' ').collect();
7216 let result_words: Vec<&str> = rejoined.split_whitespace().collect();
7217 assert_eq!(original_words, result_words, "reflow must preserve all words in order");
7218 }
7219
7220 #[test]
7225 fn test_helper_function_text_ends_with_abbreviation() {
7226 let abbreviations = get_abbreviations(&None);
7228
7229 assert!(text_ends_with_abbreviation("Dr.", &abbreviations));
7231 assert!(text_ends_with_abbreviation("word Dr.", &abbreviations));
7232 assert!(text_ends_with_abbreviation("e.g.", &abbreviations));
7233 assert!(text_ends_with_abbreviation("i.e.", &abbreviations));
7234 assert!(text_ends_with_abbreviation("Mr.", &abbreviations));
7235 assert!(text_ends_with_abbreviation("Mrs.", &abbreviations));
7236 assert!(text_ends_with_abbreviation("Ms.", &abbreviations));
7237 assert!(text_ends_with_abbreviation("Prof.", &abbreviations));
7238
7239 assert!(!text_ends_with_abbreviation("etc.", &abbreviations));
7241 assert!(!text_ends_with_abbreviation("paradigms.", &abbreviations));
7242 assert!(!text_ends_with_abbreviation("programs.", &abbreviations));
7243 assert!(!text_ends_with_abbreviation("items.", &abbreviations));
7244 assert!(!text_ends_with_abbreviation("systems.", &abbreviations));
7245 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)); }
7251
7252 #[test]
7253 fn test_footnote_after_period_splits_sentence() {
7254 let text = "First sentence.[^1] Second sentence.";
7258 let sentences = split_into_sentences(text, None, true, CjkSoftBreak::Space);
7259 assert_eq!(
7260 sentences,
7261 vec!["First sentence.[^1]".to_string(), "Second sentence.".to_string()],
7262 "footnote glued to the period should keep the boundary and stay attached to the first sentence"
7263 );
7264 }
7265
7266 #[test]
7267 fn test_multiple_consecutive_footnotes_after_period_splits_sentence() {
7268 let text = "Notes here.[^1][^2] Second sentence.";
7270 let sentences = split_into_sentences(text, None, true, CjkSoftBreak::Space);
7271 assert_eq!(
7272 sentences,
7273 vec!["Notes here.[^1][^2]".to_string(), "Second sentence.".to_string()]
7274 );
7275 }
7276
7277 #[test]
7278 fn test_footnote_before_period_still_splits_sentence() {
7279 let text = "Annotation here[^1]. Second sentence.";
7283 let sentences = split_into_sentences(text, None, true, CjkSoftBreak::Space);
7284 assert_eq!(
7285 sentences,
7286 vec!["Annotation here[^1].".to_string(), "Second sentence.".to_string()]
7287 );
7288 }
7289
7290 #[test]
7291 fn test_mid_sentence_footnote_does_not_split() {
7292 let text = "The system word[^1] more words. Next sentence.";
7295 let sentences = split_into_sentences(text, None, true, CjkSoftBreak::Space);
7296 assert_eq!(
7297 sentences,
7298 vec![
7299 "The system word[^1] more words.".to_string(),
7300 "Next sentence.".to_string()
7301 ]
7302 );
7303 }
7304
7305 #[test]
7306 fn test_bare_numeric_bracket_after_period_does_not_split() {
7307 let text = "Citation here.[1] Second sentence.";
7310 let sentences = split_into_sentences(text, None, true, CjkSoftBreak::Space);
7311 assert_eq!(
7312 sentences,
7313 vec![text.to_string()],
7314 "a bare numeric bracket must not be treated as a sentence boundary"
7315 );
7316 }
7317
7318 #[test]
7319 fn test_footnote_glued_to_following_word_does_not_split() {
7320 let text = "First sentence.[^1]Continued glued text.";
7323 let sentences = split_into_sentences(text, None, true, CjkSoftBreak::Space);
7324 assert_eq!(sentences, vec![text.to_string()]);
7325 }
7326
7327 #[test]
7328 fn test_footnote_at_end_of_text_is_preserved() {
7329 let text = "Sentence.[^1]";
7332 let sentences = split_into_sentences(text, None, true, CjkSoftBreak::Space);
7333 assert_eq!(sentences, vec![text.to_string()]);
7334 }
7335
7336 #[test]
7337 fn test_abbreviation_before_footnote_does_not_split() {
7338 let text = "See the notes, e.g.[^1] this one.";
7341 let sentences = split_into_sentences(text, None, true, CjkSoftBreak::Space);
7342 assert_eq!(
7343 sentences,
7344 vec![text.to_string()],
7345 "e.g. is an abbreviation, not a sentence boundary"
7346 );
7347 }
7348
7349 #[test]
7350 fn sentence_boundary_never_falls_inside_an_atomic_construct() {
7351 let cases = [
7357 "Prefix [link. Still link](https://example.com) tail. Next sentence.",
7358 "Prefix [target](<https://example.com/First. Second>) tail. Next sentence.",
7359 "Prefix [text](url \"Title. More\") tail. Next sentence.",
7360 "Prefix  tail. Next sentence.",
7361 "Prefix [ref text. More][ref] tail. Next sentence.",
7362 "Prefix [collapsed. More][] tail. Next sentence.",
7363 "Prefix [[Page name. Title]] tail. Next sentence.",
7364 "Prefix $x. Y$ tail. Next sentence.",
7365 "Prefix $$x. Y$$ tail. Next sentence.",
7366 "Prefix <span title=\"A. B\">x</span> tail. Next sentence.",
7367 "Prefix `code. Still code` tail. Next sentence.",
7368 ];
7369 for text in cases {
7370 let sentences = split_into_sentences(text, None, true, CjkSoftBreak::Space);
7371 let (head, tail) = text.rsplit_once(" tail. ").expect("case has a tail");
7372 assert_eq!(
7373 sentences,
7374 vec![format!("{head} tail."), tail.to_string()],
7375 "input {text:?}"
7376 );
7377 }
7378
7379 let text = "Prefix [shortcut. More] tail. Next sentence.";
7383 let whole = vec![
7384 "Prefix [shortcut. More] tail.".to_string(),
7385 "Next sentence.".to_string(),
7386 ];
7387 let defined = HashSet::from(["shortcut. more".to_string()]);
7388 assert_eq!(
7389 split_into_sentences(text, Some(&defined), true, CjkSoftBreak::Space),
7390 whole
7391 );
7392 assert_eq!(split_into_sentences(text, None, true, CjkSoftBreak::Space), whole);
7393 assert_eq!(
7394 split_into_sentences(text, Some(&HashSet::new()), true, CjkSoftBreak::Space),
7395 vec!["Prefix [shortcut.", "More] tail.", "Next sentence."]
7396 );
7397 }
7398
7399 #[test]
7400 fn a_sentence_may_open_with_a_link_or_image() {
7401 for text in [
7406 "Opening sentence. [First. Second](https://example.com)",
7407 "Opening sentence. ",
7408 "Opening sentence. [[First. Second]]",
7409 "Opening sentence. [[first-note|First. Second]]",
7410 "Opening sentence. [Ref link][ref]",
7411 "Opening sentence. [](url) continues.",
7414 "Opening sentence. [][ref] continues.",
7415 "Opening sentence. [![First image][img]](url) continues.",
7418 "Opening sentence. [![First image][]](url) continues.",
7419 "Opening sentence. [![First image][img]][ref] continues.",
7420 ] {
7421 let (head, tail) = text.split_once(". ").expect("case has a boundary");
7422 assert_eq!(
7423 split_into_sentences(text, None, true, CjkSoftBreak::Space),
7424 vec![format!("{head}."), tail.to_string()],
7425 "input {text:?}"
7426 );
7427 }
7428 let text = "Opening sentence. [![First image]](url) continues.";
7431 let defined = HashSet::from(["first image".to_string()]);
7432 assert_eq!(
7433 split_into_sentences(text, Some(&defined), true, CjkSoftBreak::Space),
7434 vec!["Opening sentence.", "[![First image]](url) continues."]
7435 );
7436 assert_eq!(
7437 split_into_sentences(text, Some(&HashSet::new()), true, CjkSoftBreak::Space),
7438 vec![text.to_string()],
7439 "an undefined shortcut is bracketed text, and `!` opens no sentence"
7440 );
7441 assert_eq!(
7444 split_into_sentences(
7445 "Opening sentence. [](url) continues.",
7446 None,
7447 true,
7448 CjkSoftBreak::Space
7449 ),
7450 vec](url) continues."]
7451 );
7452 let defined = HashSet::from(["smith 2020".to_string()]);
7455 assert_eq!(
7456 split_into_sentences(
7457 "Claim ends here. [Smith 2020] more text.",
7458 Some(&defined),
7459 true,
7460 CjkSoftBreak::Space
7461 ),
7462 vec!["Claim ends here.", "[Smith 2020] more text."]
7463 );
7464 let none_defined = HashSet::new();
7470 for text in [
7471 "Opening sentence. [first link](https://example.com) continues.",
7472 "Opening sentence. [[first note]] continues.",
7473 "Opening sentence. [[First Note|first note]] continues.",
7474 "Opening sentence. [[Page continues.",
7475 "Opening sentence. [[First] stray]] continues.",
7476 "Opening sentence.  continues.",
7477 "Opening sentence. [1] is the citation.",
7478 "Opening sentence. [First](unterminated",
7479 "Opening sentence. [First][unterminated",
7480 "Opening sentence. [First] (aside) continues.",
7481 "Claim ends here. [Smith 2020]",
7482 "Claim ends here. [Smith 2020] more text.",
7483 "See the RFC. [RFC] More text.",
7484 "Claim ends here. [^Note] more text.",
7485 ] {
7486 assert_eq!(
7487 split_into_sentences(text, Some(&none_defined), true, CjkSoftBreak::Space),
7488 vec![text.to_string()],
7489 "input {text:?}"
7490 );
7491 }
7492 }
7493
7494 #[test]
7495 fn link_opener_is_read_off_the_parse() {
7496 let len = |text: &str, defs: Option<&HashSet<String>>| {
7499 let chars: Vec<char> = text.chars().collect();
7500 let char_offsets = char_byte_offsets(&chars);
7501 let NestedStructure { links, .. } = sentence_structure(text, defs);
7502 let st = SentenceText {
7503 text,
7504 chars: &chars,
7505 char_offsets: &char_offsets,
7506 links: &links,
7507 code_spans: &[],
7508 markers: &[],
7509 marker_closers: &[],
7510 paragraph: None,
7511 cjk_soft_break: CjkSoftBreak::Space,
7512 };
7513 st.link_end_at(0).map_or(0, |end| link_opener_len(&chars, 0, end))
7514 };
7515 let none = HashSet::new();
7516 assert_eq!(len("[text](url)", Some(&none)), 1);
7517 assert_eq!(
7518 len("[text][ref]", Some(&none)),
7519 1,
7520 "a full reference is a link whether or not defined"
7521 );
7522 assert_eq!(len("[text][]", Some(&none)), 1);
7523 assert_eq!(len("", Some(&none)), 2);
7524 assert_eq!(len("[[wiki]]", Some(&none)), 2);
7525 assert_eq!(
7526 len("[[wiki|shown]]", Some(&none)),
7527 7,
7528 "the displayed text starts after the alias pipe"
7529 );
7530 assert_eq!(len("![[img.png|100]]", Some(&none)), 11);
7531 assert_eq!(len("[[wiki|a|b]]", Some(&none)), 7, "the first pipe starts the alias");
7532 assert_eq!(
7533 len("[[wiki|shown]] [[a|b]]", Some(&none)),
7534 7,
7535 "a pipe past the closing `]]` is not this alias"
7536 );
7537 assert_eq!(
7538 len("[a \\] b](url)", Some(&none)),
7539 1,
7540 "an escaped bracket does not close the text"
7541 );
7542 assert_eq!(
7543 len("[](url)", Some(&none)),
7544 1,
7545 "the outer opener is skipped first"
7546 );
7547 for text in [
7551 "[^1]",
7552 "[text](unterminated",
7553 "[text][unterminated",
7554 "[text] (url)",
7555 "[[wiki",
7556 "[[wiki]",
7557 "[[First] stray]]",
7558 "[Smith 2020]",
7559 "[Smith 2020] (see also)",
7560 "[unclosed",
7561 "!bang",
7562 "text",
7563 ] {
7564 assert_eq!(len(text, Some(&none)), 0, "input {text:?}");
7565 }
7566 let smith = HashSet::from(["smith 2020".to_string()]);
7569 assert_eq!(len("[Smith 2020]", Some(&smith)), 1);
7570 assert_eq!(len("[Smith 2020]", None), 1);
7571 }
7572
7573 #[test]
7574 fn sentence_per_line_reflow_breaks_before_a_bracket_only_where_the_check_counts() {
7575 let defined = HashSet::from(["spec".to_string()]);
7583 let options = ReflowOptions {
7584 line_length: 120,
7585 sentence_per_line: true,
7586 defined_references: Some(defined.clone()),
7587 ..Default::default()
7588 };
7589 for (text, expected) in [
7590 (
7591 "Claim ends here. [Smith](https://example.com) more text. Second sentence.",
7592 vec more text.",
7595 "Second sentence.",
7596 ],
7597 ),
7598 (
7599 "Wow! [smith](https://example.com) more text. Second sentence.",
7600 vec more text.", "Second sentence."],
7601 ),
7602 (
7603 "Claim ends here. [smith](https://example.com) more text. Second sentence.",
7604 vec more text.",
7606 "Second sentence.",
7607 ],
7608 ),
7609 (
7610 "Claim ends here. [smith][ref] more text. Second sentence.",
7611 vec!["Claim ends here. [smith][ref] more text.", "Second sentence."],
7612 ),
7613 (
7614 "Claim ends here.  more text. Second sentence.",
7615 vec more text.", "Second sentence."],
7616 ),
7617 (
7618 "Claim ends here.[Link](https://example.com) more text. Second sentence.",
7619 vec more text.",
7621 "Second sentence.",
7622 ],
7623 ),
7624 (
7625 "See the RFC. [RFC] More text. Second sentence.",
7626 vec!["See the RFC. [RFC] More text.", "Second sentence."],
7627 ),
7628 (
7629 "See the spec. [Spec] More text. Second sentence.",
7630 vec!["See the spec.", "[Spec] More text.", "Second sentence."],
7631 ),
7632 (
7633 "See the spec. [spec] more text. Second sentence.",
7634 vec!["See the spec. [spec] more text.", "Second sentence."],
7635 ),
7636 (
7637 "Claim ends here. [[page|Second sentence]] continues. Third sentence.",
7638 vec![
7639 "Claim ends here.",
7640 "[[page|Second sentence]] continues.",
7641 "Third sentence.",
7642 ],
7643 ),
7644 (
7645 "Claim ends here. [[Page|second sentence]] continues. Third sentence.",
7646 vec![
7647 "Claim ends here. [[Page|second sentence]] continues.",
7648 "Third sentence.",
7649 ],
7650 ),
7651 ] {
7652 let lines = reflow_line(text, &options);
7653 assert_eq!(lines, expected, "input {text:?}");
7654 assert_eq!(
7657 split_into_sentences(text, Some(&defined), true, CjkSoftBreak::Space).len(),
7658 expected.len(),
7659 "check count for {text:?}"
7660 );
7661 for line in &lines {
7662 assert_eq!(
7663 split_into_sentences(line, Some(&defined), true, CjkSoftBreak::Space).len(),
7664 1,
7665 "line {line:?} of {text:?}"
7666 );
7667 }
7668 }
7669 }
7670
7671 #[test]
7672 fn sentence_per_line_reflow_holds_atomic_constructs_whole() {
7673 let options = ReflowOptions {
7677 line_length: 80,
7678 sentence_per_line: true,
7679 ..Default::default()
7680 };
7681 let lines = reflow_line(
7682 "Prefix `code. Still code` and [link. Still link](https://example.com) tail. Next sentence.",
7683 &options,
7684 );
7685 assert_eq!(
7686 lines,
7687 vec tail.".to_string(),
7689 "Next sentence.".to_string(),
7690 ]
7691 );
7692
7693 let lines = reflow_line(
7694 "Prefix  and [target](<https://example.com/First. Second>) tail. Next sentence.",
7695 &options,
7696 );
7697 assert_eq!(
7698 lines,
7699 vec and [target](<https://example.com/First. Second>) tail.".to_string(),
7701 "Next sentence.".to_string(),
7702 ]
7703 );
7704
7705 let lines = reflow_line("First one. Then [link](url) second. Third one.", &options);
7708 assert_eq!(
7709 lines,
7710 vec second.".to_string(),
7713 "Third one.".to_string(),
7714 ]
7715 );
7716 }
7717
7718 #[test]
7719 fn test_is_unordered_list_marker() {
7720 assert!(is_unordered_list_marker("- item"));
7722 assert!(is_unordered_list_marker("* item"));
7723 assert!(is_unordered_list_marker("+ item"));
7724 assert!(is_unordered_list_marker("-")); assert!(is_unordered_list_marker("*"));
7726 assert!(is_unordered_list_marker("+"));
7727
7728 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")); }
7739
7740 #[test]
7741 fn test_is_block_boundary() {
7742 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"));
7764 assert!(!is_block_boundary("*emphasis*")); assert!(!is_block_boundary("[link](url)")); assert!(!is_block_boundary("some words here"));
7767 }
7768
7769 #[test]
7770 fn test_definition_list_boundary_in_single_line_paragraph() {
7771 let options = ReflowOptions {
7774 line_length: 80,
7775 ..Default::default()
7776 };
7777 let input = "Term\n: Definition of the term";
7778 let result = reflow_markdown(input, &options);
7779 assert!(
7781 result.contains(": Definition"),
7782 "Definition list item should not be merged into previous line. Got: {result:?}"
7783 );
7784 let lines: Vec<&str> = result.lines().collect();
7785 assert_eq!(lines.len(), 2, "Should remain two separate lines. Got: {lines:?}");
7786 assert_eq!(lines[0], "Term");
7787 assert_eq!(lines[1], ": Definition of the term");
7788 }
7789
7790 #[test]
7791 fn test_is_paragraph_boundary() {
7792 assert!(is_paragraph_boundary("# Heading", "# Heading"));
7794 assert!(is_paragraph_boundary("- item", "- item"));
7795 assert!(is_paragraph_boundary(":::", ":::"));
7796 assert!(is_paragraph_boundary(": definition", ": definition"));
7797
7798 assert!(is_paragraph_boundary("code", " code"));
7800 assert!(is_paragraph_boundary("code", "\tcode"));
7801
7802 assert!(is_paragraph_boundary("| a | b |", "| a | b |"));
7804 assert!(is_paragraph_boundary("a | b", "a | b")); assert!(!is_paragraph_boundary("regular text", "regular text"));
7808 assert!(!is_paragraph_boundary("text", " text")); }
7810
7811 #[test]
7812 fn test_div_marker_boundary_in_reflow_paragraph_at_line() {
7813 let content = "Some paragraph text here.\n\n::: {.callout-note}\nThis is a callout.\n:::\n";
7816 let result = reflow_paragraph_at_line(content, 3, 80);
7818 assert!(result.is_none(), "Div marker line should not be reflowed");
7819 }
7820
7821 #[test]
7822 fn starts_block_construct_detects_block_openers() {
7823 for case in ["- item", "-", "* item", "*", "+ item", "+", "-\titem"] {
7825 assert!(starts_block_construct(case), "bullet: {case:?}");
7826 }
7827 for case in ["1. item", "1) item", "01. x", "000000001. x", "1.\titem"] {
7830 assert!(starts_block_construct(case), "ordered: {case:?}");
7831 }
7832 for case in ["> quote", ">quote", ">"] {
7834 assert!(starts_block_construct(case), "blockquote: {case:?}");
7835 }
7836 for case in ["# heading", "###### h6", "#", "##"] {
7838 assert!(starts_block_construct(case), "heading: {case:?}");
7839 }
7840 for case in [
7843 "#hashtag",
7844 "#help-desk.",
7845 "#123",
7846 "##tag",
7847 "######x",
7848 "#\u{1F600}",
7849 "#*x",
7850 "#_x",
7851 ] {
7852 assert!(starts_block_construct(case), "heading missing space: {case:?}");
7853 }
7854 for case in ["```", "```rust", "````", "~~~", "~~~text"] {
7856 assert!(starts_block_construct(case), "fence: {case:?}");
7857 }
7858 for case in ["---", "--", "===", "=", "***", "___", "_ _ _", "- - -"] {
7860 assert!(starts_block_construct(case), "setext/thematic: {case:?}");
7861 }
7862 for case in [": definition", ":\tdefinition", ":", ": ", "::: note"] {
7865 assert!(starts_block_construct(case), "definition list: {case:?}");
7866 }
7867 for case in [
7870 "[^1]: text",
7871 "[^note]:",
7872 "[ref]: http://example.com",
7873 "[wat]: url follows",
7874 ] {
7875 assert!(starts_block_construct(case), "definition: {case:?}");
7876 }
7877 for case in ["<div>content", "</div>", "<p>text", "<table>", "<pre>code", "<h1>x"] {
7879 assert!(starts_block_construct(case), "html block: {case:?}");
7880 }
7881 for case in ["<!-- note -->", "<!-->", "<?php", "<!DOCTYPE html>", "<![CDATA[x]]>"] {
7883 assert!(starts_block_construct(case), "untagged html block: {case:?}");
7884 }
7885 for case in [
7888 "--- | ---",
7889 "------ | - |",
7890 "-|-",
7891 "--:|:--",
7892 "- | -",
7893 "---|",
7894 "-- |\t:-:",
7895 ] {
7896 assert!(starts_block_construct(case), "table delimiter row: {case:?}");
7897 }
7898 }
7899
7900 #[test]
7901 fn starts_block_construct_allows_ordinary_prose() {
7902 for case in [
7903 "",
7904 "word",
7905 "-5 degrees",
7906 "--flag",
7907 "-item",
7908 "####### seven hashes is not a heading",
7909 "#######x",
7910 "a #b",
7911 "x#y",
7912 "7#8",
7913 "1.5 million",
7914 "1234567890. ten digits is not a list marker",
7915 "0000000001. ten digits is not a list marker either",
7916 "2. item",
7919 "7. item",
7920 "0. item",
7921 "42) x",
7922 "123456. item",
7923 "1.",
7924 "1)",
7925 "123456.",
7926 "123456)",
7927 "1.item",
7928 "1:30 pm",
7929 "*emphasis*",
7930 "**bold** text",
7931 "__bold__ text",
7932 "_emphasis_ text",
7933 "`code` span",
7934 "`` double backtick span ``",
7935 "~~strikethrough~~",
7936 "=x",
7937 "== ==",
7938 "(parenthetical)",
7939 "[link](url)",
7940 "[text][ref] more",
7941 "[bracketed] aside",
7942 "[a](b) [ref]: first bracket is a link, not a label",
7943 "[esc\\]: not a close] text",
7944 "<span>inline</span>",
7945 "<b>bold</b>",
7946 "<https://example.com> autolink",
7947 "<mailto:a@b.com>",
7948 "<notarealtag>",
7949 "--flag | grep x",
7950 "-- | it depends",
7951 "--- | -x |",
7952 ] {
7953 assert!(!starts_block_construct(case), "prose: {case:?}");
7954 }
7955 }
7956
7957 #[test]
7958 fn merge_block_construct_continuations_merges_marker_led_lines() {
7959 let lines = vec![
7960 "First sentence?".to_string(),
7961 "- looks like a list item".to_string(),
7962 "Second sentence.".to_string(),
7963 ];
7964 assert_eq!(
7965 merge_block_construct_continuations(lines),
7966 vec![
7967 "First sentence? - looks like a list item".to_string(),
7968 "Second sentence.".to_string(),
7969 ]
7970 );
7971
7972 let lines = vec!["- real list content".to_string(), "continuation".to_string()];
7975 assert_eq!(
7976 merge_block_construct_continuations(lines.clone()),
7977 lines,
7978 "first line must never be merged"
7979 );
7980
7981 let lines = vec!["prose".to_string(), "1.".to_string(), "[ref]:".to_string()];
7984 assert_eq!(
7985 merge_block_construct_continuations(lines),
7986 vec!["prose 1. [ref]:".to_string()],
7987 "a merge that creates an opener must fold again"
7988 );
7989 }
7990
7991 #[test]
7992 fn merge_block_construct_continuations_folds_a_line_after_a_backslash() {
7993 let lines = vec!["Press the \\".to_string(), "key.".to_string()];
7995 assert_eq!(
7996 merge_block_construct_continuations(lines),
7997 vec!["Press the \\ key.".to_string()]
7998 );
7999
8000 let lines = vec!["A path C:\\\\".to_string(), "here.".to_string()];
8003 assert_eq!(merge_block_construct_continuations(lines.clone()), lines);
8004
8005 let lines = vec!["Line one".to_string(), "ends here \\".to_string()];
8008 assert_eq!(merge_block_construct_continuations(lines.clone()), lines);
8009 }
8010
8011 #[test]
8012 fn wrap_never_starts_a_line_with_a_block_marker() {
8013 let options = ReflowOptions {
8014 line_length: 25,
8015 ..Default::default()
8016 };
8017 let lines = reflow_line(
8020 "Some words here and then - a dash clause that wraps around the limit.",
8021 &options,
8022 );
8023 assert_eq!(
8024 lines,
8025 vec![
8026 "Some words here and",
8027 "then - a dash clause that",
8028 "wraps around the limit."
8029 ]
8030 );
8031
8032 for input in [
8034 "Alpha beta gamma delta epsilon - dash clause here to wrap",
8035 "Alpha beta gamma delta epsilon > quote lookalike here to wrap",
8036 "Alpha beta gamma delta epsilon # heading lookalike here to wrap",
8037 "Alpha beta gamma delta epsilon 1. ordered lookalike here to wrap",
8038 "Alpha beta gamma delta epsilon * star clause here to wrap",
8039 "Alpha beta gamma delta epsilon + plus clause here to wrap",
8040 ] {
8041 for width in 10..40 {
8042 let options = ReflowOptions {
8043 line_length: width,
8044 ..Default::default()
8045 };
8046 for line in reflow_line(input, &options) {
8047 assert!(
8048 !starts_block_construct(&line),
8049 "width {width}: wrapped line opens a block construct: {line:?} (input {input:?})"
8050 );
8051 }
8052 }
8053 }
8054 }
8055
8056 #[test]
8059 fn hash_word_never_starts_a_wrapped_line_in_any_mode() {
8060 let options_for = |mode: &str, width: usize| ReflowOptions {
8061 line_length: width,
8062 sentence_per_line: mode == "sentence-per-line",
8063 semantic_line_breaks: mode == "semantic",
8064 ..Default::default()
8065 };
8066 let inputs = [
8067 "Alpha beta gamma delta epsilon #help-desk and the rest here to wrap",
8068 "Alpha beta gamma delta epsilon, ##tag and the rest here to wrap",
8069 "Alpha beta gamma delta fixed in #123, thanks everyone for the report here",
8070 "Is it open? #help-desk is. Really! #123 too. Yes? #1 also.",
8071 "Ask in the channel, #help-desk or elsewhere, and #123 maybe, then more.",
8072 "Questions about this guide? Post them in the channel #help-desk.",
8073 ];
8074 for mode in ["wrap", "sentence-per-line", "semantic"] {
8075 for input in inputs {
8076 for width in 10..60 {
8077 let lines = reflow_line(input, &options_for(mode, width));
8078 for line in &lines {
8079 let hashes = line.bytes().take_while(|&b| b == b'#').count();
8080 assert!(
8081 !starts_block_construct(line) && !(1..=6).contains(&hashes),
8082 "{mode} width {width}: line opens a block construct: {line:?} (input {input:?})"
8083 );
8084 }
8085 let words = |s: &str| s.split_whitespace().map(String::from).collect::<Vec<_>>();
8086 assert_eq!(
8087 words(&lines.join(" ")),
8088 words(input),
8089 "{mode} width {width}: content changed (input {input:?})"
8090 );
8091 }
8092 }
8093 }
8094 }
8095
8096 #[test]
8098 fn hash_controls_stay_where_they_are() {
8099 let options = ReflowOptions {
8100 line_length: 80,
8101 ..Default::default()
8102 };
8103 for input in [
8104 "Issue numbers like # alone and 7#8 and a#b stay mid-line",
8105 "Seven hashes #######y are prose and fixes #12 are fine here",
8106 ] {
8107 assert_eq!(reflow_line(input, &options), vec![input.to_string()]);
8108 }
8109 let options = ReflowOptions {
8111 line_length: 20,
8112 ..Default::default()
8113 };
8114 let lines = reflow_line("aaaa bbbb cccc dddd #######y zzzz", &options);
8115 assert!(
8116 lines.iter().any(|l| l.starts_with("#######y")),
8117 "7+ hashes may start a line: {lines:?}"
8118 );
8119 }
8120
8121 #[test]
8124 fn block_guard_covers_every_line_start_md018_acts_on() {
8125 use crate::config::MarkdownFlavor;
8126 use crate::lint_context::LintContext;
8127 use crate::rule::Rule;
8128 use crate::rules::MD018NoMissingSpaceAtx;
8129
8130 let rule = MD018NoMissingSpaceAtx::new();
8131 let mut acted_on = 0;
8132 for line in [
8133 "#a",
8134 "##a",
8135 "###a",
8136 "####a",
8137 "#####a",
8138 "######a",
8139 "#######a",
8140 "# a",
8141 "## a",
8142 "#",
8143 "##",
8144 "#1",
8145 "#123",
8146 "#help-desk.",
8147 "#*a",
8148 "#_a",
8149 "#\u{FE0F}",
8150 "#\u{1F600}",
8151 "#a#b",
8152 "##\u{00e9}",
8153 "#-x",
8154 "#.x",
8155 "#(x)",
8156 ] {
8157 let content = format!("para\n{line}\n");
8158 let ctx = LintContext::new(&content, MarkdownFlavor::Standard, None);
8159 let acts = ctx.lines[1].heading.is_some()
8160 || ctx.lines[1].atx_missing_space.is_some()
8161 || !rule.check(&ctx).unwrap().is_empty();
8162 if acts {
8163 acted_on += 1;
8164 assert!(
8165 starts_block_construct(line),
8166 "MD018/heading detection acts on {line:?} but the reflow guard allows it"
8167 );
8168 }
8169 }
8170 assert!(acted_on >= 10, "the corpus must exercise the detector, got {acted_on}");
8171 }
8172
8173 #[test]
8174 fn sentence_per_line_keeps_block_markers_mid_line() {
8175 let options = ReflowOptions {
8176 line_length: 80,
8177 sentence_per_line: true,
8178 ..Default::default()
8179 };
8180 let lines = reflow_line(
8183 "Google Calendar (Can't we get rid of this dependency? - I don't really see the need)",
8184 &options,
8185 );
8186 assert_eq!(
8187 lines,
8188 vec!["Google Calendar (Can't we get rid of this dependency? - I don't really see the need)".to_string()]
8189 );
8190
8191 let lines = reflow_line("See section 4? # is the marker we use. Fine.", &options);
8193 assert_eq!(lines, vec!["See section 4? # is the marker we use.", "Fine."]);
8194
8195 let lines = reflow_line("Is this a problem? > I quote someone here.", &options);
8196 assert_eq!(lines, vec!["Is this a problem? > I quote someone here."]);
8197
8198 let lines = reflow_line("Another case! 1. Not a list. More text follows here.", &options);
8199 for line in &lines {
8200 assert!(
8201 !starts_block_construct(line),
8202 "sentence-per-line output opens a block construct: {line:?}"
8203 );
8204 }
8205 }
8206
8207 fn strict_sentence_lines(input: &str, require_sentence_capital: bool) -> Vec<String> {
8209 let options = ReflowOptions {
8210 line_length: 80,
8211 sentence_per_line: true,
8212 require_sentence_capital,
8213 ..Default::default()
8214 };
8215 reflow_line(input, &options)
8216 }
8217
8218 #[test]
8219 fn strict_mode_lets_a_sentence_open_with_a_number() {
8220 for (input, expected) in [
8224 (
8225 "The number of items was 5. 2 of them failed.",
8226 vec!["The number of items was 5.", "2 of them failed."],
8227 ),
8228 (
8229 "Sometimes we have 2. 3 might be here.",
8230 vec!["Sometimes we have 2.", "3 might be here."],
8231 ),
8232 (
8233 "The number of items was 5. 2nd sentence.",
8234 vec!["The number of items was 5.", "2nd sentence."],
8235 ),
8236 (
8237 "Released in 2020. 3 of them failed.",
8238 vec!["Released in 2020.", "3 of them failed."],
8239 ),
8240 (
8241 "First sentence. 2nd sentence.",
8242 vec!["First sentence.", "2nd sentence."],
8243 ),
8244 (
8245 "We met at 6:00 sharp. 6:00 is early.",
8246 vec!["We met at 6:00 sharp.", "6:00 is early."],
8247 ),
8248 ("Pi is 3.14 roughly. Next.", vec!["Pi is 3.14 roughly.", "Next."]),
8249 (
8252 "A \"Is this a test?\" 2020 was memorable.",
8253 vec!["A \"Is this a test?\"", "2020 was memorable."],
8254 ),
8255 ] {
8256 assert_eq!(strict_sentence_lines(input, true), expected, "input {input:?}");
8257 }
8258
8259 for input in [
8262 "The count was 5. and that was all.",
8263 "See fig. 3 for details.",
8264 "See no. 5 in the list.",
8265 "See ch. 12 and vol. 3 for more.",
8266 "A \"Is this a test?\" guide to it.",
8267 ] {
8268 assert_eq!(
8269 strict_sentence_lines(input, true),
8270 vec![input.to_string()],
8271 "input {input:?}"
8272 );
8273 }
8274 }
8275
8276 #[test]
8277 fn sentence_never_opens_with_an_ordered_list_marker() {
8278 for (input, require_capital, expected) in [
8285 (
8286 "Steps: 1. Do this. 2. Do that.",
8287 true,
8288 vec!["Steps: 1.", "Do this. 2.", "Do that."],
8289 ),
8290 (
8291 "First sentence. 1. Do that.",
8292 true,
8293 vec!["First sentence. 1.", "Do that."],
8294 ),
8295 ("Do this! 2. Do that.", true, vec!["Do this! 2.", "Do that."]),
8296 ("Do this. 12) Do that.", true, vec!["Do this. 12) Do that."]),
8297 ("Do this. 2. do that.", true, vec!["Do this. 2. do that."]),
8298 ("Do this. 2. do that.", false, vec!["Do this. 2.", "do that."]),
8299 (
8300 "Twelve. 1234567890. next one here.",
8301 true,
8302 vec!["Twelve. 1234567890. next one here."],
8303 ),
8304 ("Do this. 2 more times.", true, vec!["Do this.", "2 more times."]),
8307 ("How many? 2.", true, vec!["How many?", "2."]),
8308 ("第一句。 2. Do that.", true, vec!["第一句。 2.", "Do that."]),
8310 ("第一句。 2) 第二句。", true, vec!["第一句。 2) 第二句。"]),
8311 ("第一句。 2 more.", true, vec!["第一句。", "2 more."]),
8312 ("第一句。 第二句。", true, vec!["第一句。", "第二句。"]),
8313 ] {
8314 let lines = strict_sentence_lines(input, require_capital);
8315 assert_eq!(lines, expected, "input {input:?}, require capital {require_capital}");
8316 for line in &lines {
8317 let chars: Vec<char> = line.chars().collect();
8318 assert!(
8319 !opens_ordered_list_marker(&chars),
8320 "line opens with an ordered-list marker: {line:?} (input {input:?})"
8321 );
8322 }
8323 }
8324 }
8325
8326 #[test]
8327 fn opens_ordered_list_marker_matches_the_marker_shape() {
8328 let chars = |s: &str| s.chars().collect::<Vec<char>>();
8329 for text in [
8330 "2. x",
8331 "1) x",
8332 "12. x",
8333 "1.\tx",
8334 "2.\nx",
8335 "1)\nx",
8336 "1234567890. x",
8337 "0. x",
8338 ] {
8339 assert!(opens_ordered_list_marker(&chars(text)), "{text:?} is a marker");
8340 }
8341 for text in ["2.x", "2.", "2)", "2 x", "x. y", "", " 2. x", "2.5 x", "-2. x"] {
8342 assert!(!opens_ordered_list_marker(&chars(text)), "{text:?} is not a marker");
8343 }
8344 }
8345
8346 #[test]
8347 fn inline_math_directly_after_display_math_stays_atomic() {
8348 let options = ReflowOptions {
8356 line_length: 8,
8357 ..Default::default()
8358 };
8359 let lines = reflow_line("$$a$$$bb cc dd$ x", &options);
8360 assert_eq!(lines, vec!["$$a$$$bb cc dd$".to_string(), "x".to_string()]);
8361 }
8362
8363 #[test]
8364 fn test_code_span_parsing() {
8365 let elements = parse_markdown_elements_inner("`code`", false, false, None);
8367 assert_eq!(elements.len(), 1);
8368 assert!(matches!(&elements[0], Element::Code { content, marker } if content == "code" && marker == "`"));
8369
8370 let elements = parse_markdown_elements_inner("``code``", false, false, None);
8372 assert_eq!(elements.len(), 1);
8373 assert!(matches!(&elements[0], Element::Code { content, marker } if content == "code" && marker == "``"));
8374
8375 let elements = parse_markdown_elements_inner("``code`inside``", false, false, None);
8377 assert_eq!(elements.len(), 1);
8378 assert!(
8379 matches!(&elements[0], Element::Code { content, marker } if content == "code`inside" && marker == "``")
8380 );
8381
8382 let elements = parse_markdown_elements_inner("`` code ``", false, false, None);
8384 assert_eq!(elements.len(), 1);
8385 assert!(matches!(&elements[0], Element::Code { content, marker } if content == " code " && marker == "``"));
8386
8387 let elements = parse_markdown_elements_inner("`unclosed", false, false, None);
8389 assert_eq!(elements.len(), 1);
8390 assert!(matches!(&elements[0], Element::Text(s) if s == "`unclosed"));
8391
8392 let elements = parse_markdown_elements_inner("`unclosed [link](url)", false, false, None);
8394 assert_eq!(elements.len(), 2);
8396 assert!(matches!(&elements[0], Element::Text(s) if s == "`unclosed "));
8397 assert!(matches!(&elements[1], Element::Link(s) if s == "[link](url)"));
8398 }
8399
8400 #[test]
8401 fn test_reflow_performance_long_input() {
8402 let mut text = String::new();
8405 for i in 1..400 {
8406 let backticks = "`".repeat(i);
8407 text.push_str(&backticks);
8408 text.push(' ');
8409 }
8410
8411 let start = std::time::Instant::now();
8412 let elements = parse_markdown_elements_inner(&text, false, false, None);
8413 let duration = start.elapsed();
8414
8415 assert!(duration.as_millis() < 100, "Parsing took too long: {duration:?}");
8417 assert!(!elements.is_empty());
8418 }
8419
8420 #[test]
8421 fn test_reflow_performance_display_math_heavy() {
8422 let text = "$$a$$".repeat(4000);
8427
8428 let start = std::time::Instant::now();
8429 let elements = parse_markdown_elements_inner(&text, false, false, None);
8430 let duration = start.elapsed();
8431
8432 assert!(duration.as_millis() < 100, "Parsing took too long: {duration:?}");
8433 assert_eq!(elements.len(), 4000);
8434 }
8435
8436 #[test]
8437 fn inline_math_len_at_start_matches_regex_at_slice_start() {
8438 let alphabet = ['$', 'a', ' '];
8443 let mut inputs: Vec<String> = vec![String::new()];
8444 let mut frontier: Vec<String> = vec![String::new()];
8445 for _ in 0..6 {
8446 let mut longer = Vec::new();
8447 for prefix in &frontier {
8448 for ch in alphabet {
8449 let mut s = prefix.clone();
8450 s.push(ch);
8451 longer.push(s);
8452 }
8453 }
8454 inputs.extend(longer.iter().cloned());
8455 frontier = longer;
8456 }
8457 inputs.push("$αβ$x".to_string());
8459 inputs.push("$α$$".to_string());
8460
8461 for s in &inputs {
8462 let expected = INLINE_MATH_REGEX
8463 .find(s)
8464 .ok()
8465 .flatten()
8466 .filter(|m| m.start() == 0)
8467 .map(|m| m.end());
8468 assert_eq!(inline_math_len_at_start(s), expected, "input: {s:?}");
8469 }
8470 }
8471
8472 #[test]
8473 fn inline_math_probe_after_dollar_matches_uncached_parse() {
8474 let cases = [
8480 ("$$a$$$b c$ x", r#"[DisplayMath("a"), InlineMath("b c"), Text(" x")]"#),
8481 (
8482 "$$a$$$b$ $$a$$$b$",
8483 r#"[DisplayMath("a"), InlineMath("b"), Text(" "), DisplayMath("a"), InlineMath("b")]"#,
8484 ),
8485 (
8487 "$$a$$$ x $y z$",
8488 r#"[DisplayMath("a"), InlineMath(" x "), Text("y z$")]"#,
8489 ),
8490 ("$$a$$$$ x", r#"[DisplayMath("a"), Text("$$ x")]"#),
8492 ("$$a$$$$b$$ x", r#"[DisplayMath("a"), DisplayMath("b"), Text(" x")]"#),
8493 (
8495 "$a$$b$$c$$d$ tail",
8496 r#"[Text("$a"), DisplayMath("b"), Text("c$$d$ tail")]"#,
8497 ),
8498 ];
8499 for (input, expected) in cases {
8500 let elements = parse_markdown_elements_inner(input, false, false, None);
8501 assert_eq!(format!("{elements:?}"), expected, "input: {input:?}");
8502 }
8503 }
8504
8505 #[test]
8506 fn test_atomic_spans() {
8507 let text_emphasis = "hello **word1 word2**";
8509
8510 let options_disabled = ReflowOptions {
8511 line_length: 18,
8512 atomic_spans: true,
8513 ..Default::default()
8514 };
8515 let lines_disabled = reflow_line(text_emphasis, &options_disabled);
8516 assert_eq!(lines_disabled, vec!["hello", "**word1 word2**"]);
8517
8518 let options_enabled = ReflowOptions {
8519 line_length: 18,
8520 atomic_spans: false,
8521 ..Default::default()
8522 };
8523 let lines_enabled = reflow_line(text_emphasis, &options_enabled);
8524 assert_eq!(lines_enabled, vec!["hello **word1", "word2**"]);
8525
8526 let text_code = "hello `word1 word2`";
8528
8529 let lines_code_disabled = reflow_line(text_code, &options_disabled);
8530 assert_eq!(lines_code_disabled, vec!["hello", "`word1 word2`"]);
8531
8532 let lines_code_enabled = reflow_line(text_code, &options_enabled);
8533 assert_eq!(lines_code_enabled, vec!["hello `word1", "word2`"]);
8534
8535 let text_code_padding = "hello `` `word1` `word2` ``";
8537 let lines_padding_enabled = reflow_line(text_code_padding, &options_enabled);
8538 assert_eq!(lines_padding_enabled, vec![r#"hello `` `word1`"#, r#"`word2` ``"#]);
8539
8540 let text_attached = "**one two**,"; let options_11 = ReflowOptions {
8545 line_length: 11,
8546 atomic_spans: true,
8547 ..Default::default()
8548 };
8549 assert_eq!(reflow_line(text_attached, &options_11), vec!["**one two**,"]);
8550
8551 let options_10 = ReflowOptions {
8553 line_length: 10,
8554 atomic_spans: true,
8555 ..Default::default()
8556 };
8557 assert_eq!(reflow_line(text_attached, &options_10), vec!["**one", "two**,"]);
8558 }
8559
8560 #[test]
8561 fn test_emphasis_containing_markers_is_not_split() {
8562 let options = ReflowOptions {
8563 line_length: 5,
8564 atomic_spans: false,
8565 ..Default::default()
8566 };
8567 let lines = reflow_line(r#"*foo \*bar*"#, &options);
8569 assert_eq!(lines, vec![r#"*foo \*bar*"#.to_string()]);
8570 }
8571
8572 fn semantic_shape(markdown: &str) -> String {
8577 let mut options = Options::empty();
8578 options.insert(Options::ENABLE_STRIKETHROUGH);
8579 let mut out = String::new();
8580 let push_prose = |out: &mut String, text: &str| {
8581 for c in text.chars() {
8582 if c.is_whitespace() {
8583 if !out.ends_with(char::is_whitespace) {
8584 out.push(' ');
8585 }
8586 } else {
8587 out.push(c);
8588 }
8589 }
8590 };
8591 for event in Parser::new_ext(markdown, options) {
8592 match event {
8593 Event::Text(text) => push_prose(&mut out, &text),
8594 Event::SoftBreak | Event::HardBreak => push_prose(&mut out, " "),
8595 Event::Code(code) => out.push_str(&format!("<code>{code}</code>")),
8597 Event::Start(tag) => out.push_str(&format!("<{tag:?}>")),
8598 Event::End(tag) => out.push_str(&format!("</{tag:?}>")),
8599 other => out.push_str(&format!("{other:?}")),
8600 }
8601 }
8602 out.trim().to_string()
8603 }
8604
8605 #[test]
8606 fn test_wrapping_a_span_never_changes_what_it_parses_to() {
8607 let corpus = [
8611 "_This is a very, very, very, very, very long line with some `code` inside._",
8612 "_alpha beta gamma delta epsilon `a b` zeta eta theta iota kappa lambda_",
8613 "**strong text with `code` and more words than fit on one single line**",
8614 "~~struck text with `code` and more words than fit on one single line~~",
8615 "_emphasis with **nested strong that is quite long** and trailing words_",
8616 "***A doubly nested bold italic span with more words than fit on a line***",
8619 "___Another doubly nested span with more words than fit on a single line___",
8620 "**_mixed strong then emphasis with more words than fit on a single line_**",
8621 "*__mixed emphasis then strong with more words than fit on a single line__*",
8622 "**~~strong strikethrough with more words than fit on a single line here~~**",
8623 "**a * b with a stray marker and plenty more words to pass the budget**",
8626 "_foo `a` bar `b` baz qux quux corge grault garply waldo fred plugh xyzzy_",
8627 "text before _a long emphasis with `code` inside of it here_ and after",
8628 "(_a parenthesized long emphasis with `code` inside of it right here_)",
8629 r#"*foo \*bar baz qux quux corge grault garply waldo fred plugh xyzzy*"#,
8630 "_tab\tseparated `a\tb` words spread out over quite a long emphasis span_",
8631 "_This has [text](<a b c d e f g h i j k>) and trailing words to wrap._",
8634 "_This has [`code`](<a b c d e f g h i j k>) and trailing words to wrap._",
8635 r#"_See [x](https://example.com "a long link title here") and `code` too._"#,
8636 "_A [link with a long label](https://example.com/path) and `code` here._",
8637 "_An image  plus `code` and more text_",
8638 ];
8639 for text in corpus {
8640 let expected = semantic_shape(text);
8641 for line_length in [20, 30, 40, 80] {
8642 for atomic_spans in [true, false] {
8643 let options = ReflowOptions {
8644 line_length,
8645 atomic_spans,
8646 ..Default::default()
8647 };
8648 let wrapped = reflow_line(text, &options).join("\n");
8649 assert_eq!(
8650 semantic_shape(&wrapped),
8651 expected,
8652 "reflow changed the parse of {text:?} at line_length={line_length} \
8653 atomic_spans={atomic_spans}\n wrapped: {wrapped:?}"
8654 );
8655 }
8656 }
8657 }
8658 }
8659
8660 #[test]
8661 fn test_wrapping_a_span_keeps_whole_the_constructs_pulldown_cannot_see() {
8662 let cases = [
8666 (
8667 "_alpha beta gamma [[a wiki link]] delta epsilon zeta eta_",
8668 "[[a wiki link]]",
8669 ),
8670 (
8671 "_alpha beta gamma {{< foo bar >}} delta epsilon zeta eta_",
8672 "{{< foo bar >}}",
8673 ),
8674 ("_alpha beta gamma $a + b$ delta epsilon zeta eta theta_", "$a + b$"),
8675 ("_alpha beta gamma $$a + b$$ delta epsilon zeta eta theta_", "$$a + b$$"),
8676 ];
8677 for (text, construct) in cases {
8678 for line_length in [12, 20, 30] {
8679 for atomic_spans in [true, false] {
8680 let options = ReflowOptions {
8681 line_length,
8682 atomic_spans,
8683 ..Default::default()
8684 };
8685 let wrapped = reflow_line(text, &options).join("\n");
8686 assert!(
8687 wrapped.contains(construct),
8688 "{construct} was broken at line_length={line_length} \
8689 atomic_spans={atomic_spans}: {wrapped:?}"
8690 );
8691 }
8692 }
8693 }
8694 }
8695
8696 #[test]
8697 fn test_overlong_emphasis_with_nested_code_span_wraps() {
8698 let options = ReflowOptions {
8702 line_length: 80,
8703 atomic_spans: true,
8704 ..Default::default()
8705 };
8706 let text = "_This is a very, very, very, very, very, very, very long line that exceeds 80 characters with some `code` inside._";
8707 let lines = reflow_line(text, &options);
8708 assert_eq!(
8709 lines,
8710 vec![
8711 "_This is a very, very, very, very, very, very, very long line that exceeds 80",
8712 "characters with some `code` inside._",
8713 ]
8714 );
8715 }
8716
8717 #[test]
8718 fn test_overlong_emphasis_with_nested_strong_wraps() {
8719 let options = ReflowOptions {
8721 line_length: 80,
8722 atomic_spans: true,
8723 ..Default::default()
8724 };
8725 let text = "_This is a very, very, very, very, very, very, very long line that exceeds 80 characters with some **bold** inside._";
8726 let lines = reflow_line(text, &options);
8727 assert_eq!(
8728 lines,
8729 vec![
8730 "_This is a very, very, very, very, very, very, very long line that exceeds 80",
8731 "characters with some **bold** inside._",
8732 ]
8733 );
8734 }
8735
8736 #[test]
8737 fn test_overlong_doubly_nested_span_wraps() {
8738 let options = ReflowOptions {
8743 line_length: 80,
8744 atomic_spans: true,
8745 ..Default::default()
8746 };
8747 let body = "This is a very, very, very, very, very, very, very, very, very, very long line that is emphasised.";
8748 for (open, close) in [
8749 ("***", "***"),
8750 ("___", "___"),
8751 ("**_", "_**"),
8752 ("*__", "__*"),
8753 ("**~~", "~~**"),
8754 ] {
8755 let text = format!("{open}{body}{close}");
8756 assert!(text.len() > options.line_length, "case must start over budget");
8757 let lines = reflow_line(&text, &options);
8758 assert!(
8759 lines.len() > 1,
8760 "{open}...{close} should wrap but stayed on one line: {lines:?}"
8761 );
8762 assert!(
8763 lines.iter().all(|line| line.len() <= options.line_length),
8764 "{open}...{close} left a line over the budget: {lines:?}"
8765 );
8766 assert_eq!(
8767 lines.join(" "),
8768 text,
8769 "{open}...{close} wrapping must only replace a space with a newline"
8770 );
8771 }
8772 }
8773
8774 #[test]
8775 fn test_overlong_span_with_stray_marker_stays_whole() {
8776 let options = ReflowOptions {
8780 line_length: 40,
8781 atomic_spans: true,
8782 ..Default::default()
8783 };
8784 let text = "**alpha * beta gamma delta epsilon zeta eta theta iota kappa**";
8785 let lines = reflow_line(text, &options);
8786 assert_eq!(lines, vec![text], "stray marker must keep the span whole");
8787 }
8788
8789 #[test]
8790 fn test_overlong_span_never_breaks_inside_a_nested_reference_link() {
8791 let options = ReflowOptions {
8797 line_length: 30,
8798 atomic_spans: true,
8799 defined_references: Some(HashSet::from([
8800 "ref".to_string(),
8801 "one two three four five six seven".to_string(),
8803 ])),
8804 ..Default::default()
8805 };
8806 for (text, link) in [
8807 (
8808 "_**alpha [one two three four five six seven][ref] beta gamma delta**_",
8809 "[one two three four five six seven][ref]",
8810 ),
8811 (
8812 "**alpha [one two three four five six seven][ref] beta gamma delta**",
8813 "[one two three four five six seven][ref]",
8814 ),
8815 (
8816 "_**alpha ![one two three four five six seven][ref] beta gamma delta**_",
8817 "![one two three four five six seven][ref]",
8818 ),
8819 (
8820 "_**alpha [one two three four five six seven][] beta gamma delta**_",
8821 "[one two three four five six seven][]",
8822 ),
8823 (
8824 "_**alpha [one two three four five six seven] beta gamma delta**_",
8825 "[one two three four five six seven]",
8826 ),
8827 ] {
8828 let lines = reflow_line(text, &options);
8829 assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
8830 assert!(
8831 lines.iter().any(|line| line.contains(link)),
8832 "{link} must stay on one line: {lines:?}"
8833 );
8834 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
8835 }
8836 }
8837
8838 #[test]
8839 fn test_overlong_span_breaks_inside_an_undefined_shortcut_reference() {
8840 let options = ReflowOptions {
8844 line_length: 30,
8845 atomic_spans: true,
8846 defined_references: Some(HashSet::new()),
8847 ..Default::default()
8848 };
8849 let text = "_**alpha [one two three four five six seven] beta gamma delta**_";
8850 let lines = reflow_line(text, &options);
8851 assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
8852 assert!(
8853 !lines
8854 .iter()
8855 .any(|line| line.contains("[one two three four five six seven]")),
8856 "an undefined shortcut is prose and should break: {lines:?}"
8857 );
8858 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
8859 }
8860
8861 #[test]
8862 fn test_overlong_span_never_breaks_inside_a_nested_attr_list() {
8863 let attr = "{.highlight key=\"a b c\"}";
8867 let text = format!("_**alpha beta gamma delta epsilon zeta{attr} eta theta iota kappa**_");
8868 let options = ReflowOptions {
8869 line_length: 20,
8870 atomic_spans: true,
8871 attr_lists: true,
8872 ..Default::default()
8873 };
8874 let lines = reflow_line(&text, &options);
8875 assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
8876 assert!(
8877 lines.iter().any(|line| line.contains(attr)),
8878 "attr list must stay on one line: {lines:?}"
8879 );
8880 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
8881
8882 let plain = ReflowOptions {
8885 attr_lists: false,
8886 ..options
8887 };
8888 let lines = reflow_line(&text, &plain);
8889 assert!(
8890 !lines.iter().any(|line| line.contains(attr)),
8891 "without the flavor the braces are prose and should break: {lines:?}"
8892 );
8893 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
8894 }
8895
8896 #[test]
8897 fn test_overlong_emphasis_never_breaks_inside_nested_code_span() {
8898 let options = ReflowOptions {
8902 line_length: 30,
8903 atomic_spans: true,
8904 ..Default::default()
8905 };
8906 let text = "_alpha beta gamma delta epsilon `a b` zeta eta theta iota kappa_";
8907 let lines = reflow_line(text, &options);
8908 assert!(lines.len() > 1, "over-long emphasis should wrap: {lines:?}");
8909 assert!(
8910 lines.iter().any(|line| line.contains("`a b`")),
8911 "nested code span must stay whole with its interior spaces: {lines:?}"
8912 );
8913 for line in &lines {
8914 assert_eq!(
8915 line.matches('`').count() % 2,
8916 0,
8917 "no line may contain half a code span: {line:?}"
8918 );
8919 }
8920 }
8921
8922 #[test]
8923 fn test_definition_list_marker_does_not_start_line() {
8924 let options = ReflowOptions {
8925 line_length: 20,
8926 ..Default::default()
8927 };
8928 let lines = reflow_line("This is a term and : definition here.", &options);
8930 for line in &lines {
8931 assert!(
8932 !line.trim_start().starts_with(": "),
8933 "Wrapped line should not start with definition marker: {line}"
8934 );
8935 }
8936 }
8937
8938 #[test]
8939 fn test_div_marker_does_not_start_line() {
8940 let options = ReflowOptions {
8941 line_length: 20,
8942 ..Default::default()
8943 };
8944 let lines = reflow_line("This is some text with ::: class marker.", &options);
8946 for line in &lines {
8947 assert!(
8948 !line.trim_start().starts_with(":::"),
8949 "Wrapped line should not start with div marker: {line}"
8950 );
8951 }
8952 }
8953
8954 fn reflow_markdown_preserving_rendering(content: &str, options: &ReflowOptions) -> String {
8967 let render = |text: &str| {
8968 let mut parser_options = Options::empty();
8969 parser_options.insert(Options::ENABLE_STRIKETHROUGH);
8970 parser_options.insert(Options::ENABLE_DEFINITION_LIST);
8971 let mut html = String::new();
8972 pulldown_cmark::html::push_html(&mut html, Parser::new_ext(text, parser_options));
8973 html.retain(|c| !c.is_ascii_whitespace());
8974 html
8975 };
8976 let reflowed = reflow_markdown(content, options);
8977 assert_eq!(
8978 render(content),
8979 render(&reflowed),
8980 "rendering changed for input: {content:?}"
8981 );
8982 reflowed
8983 }
8984
8985 #[test]
8993 fn a_paragraph_part_never_ends_inside_an_atomic_construct() {
8994 let options = ReflowOptions {
8995 line_length: 0,
8996 sentence_per_line: true,
8997 ..Default::default()
8998 };
8999
9000 let cases = [
9001 ("[(完成。)\n继续。](url)", "[(完成。) 继续。](url)"),
9003 ("`(完成。)\n继续。`", "`(完成。) 继续。`"),
9005 ("[Done.\nNext](url)", "[Done. Next](url)"),
9008 ("(完成。)\n继续。", "(完成。)\n继续。"),
9010 ("Done. Next", "Done.\nNext"),
9011 ];
9012
9013 for (input, expected) in cases {
9014 assert_eq!(
9015 reflow_markdown_preserving_rendering(input, &options),
9016 expected,
9017 "input: {input:?}"
9018 );
9019 }
9020 }
9021
9022 #[test]
9025 fn a_bare_colon_line_ends_the_paragraph_above_it() {
9026 let options = ReflowOptions {
9027 line_length: 0,
9028 sentence_per_line: true,
9029 ..Default::default()
9030 };
9031
9032 for input in ["文章です。\n:", "Done!\n:", "完成。\n:", "Term\n:\nNext term\n: text"] {
9033 assert_eq!(
9034 reflow_markdown_preserving_rendering(input, &options),
9035 input,
9036 "input: {input:?}"
9037 );
9038 }
9039 }
9040
9041 #[test]
9046 fn a_colon_leading_the_first_line_of_a_paragraph_is_prose() {
9047 let options = ReflowOptions {
9048 line_length: 0,
9049 sentence_per_line: true,
9050 ..Default::default()
9051 };
9052
9053 for (input, expected) in [
9054 (
9055 ":warning: First sentence. Second sentence.",
9056 ":warning: First sentence.\nSecond sentence.",
9057 ),
9058 (
9059 "Term\n\n:warning: First sentence. Second sentence.",
9060 "Term\n\n:warning: First sentence.\nSecond sentence.",
9061 ),
9062 (
9063 "# Heading\n:warning: First sentence. Second sentence.",
9064 "# Heading\n:warning: First sentence.\nSecond sentence.",
9065 ),
9066 ] {
9067 assert_eq!(
9068 reflow_markdown_preserving_rendering(input, &options),
9069 expected,
9070 "input: {input:?}"
9071 );
9072 }
9073 for input in [
9074 "Term\n:warning: First sentence. Second sentence.",
9075 ":warning: First sentence.\n:note: Second sentence.",
9076 "- term\n :warning: First sentence. Second sentence.",
9077 ] {
9078 assert_eq!(
9079 reflow_markdown_preserving_rendering(input, &options),
9080 input,
9081 "input: {input:?}"
9082 );
9083 }
9084 }
9085}
9086
9087#[cfg(test)]
9088mod shortcode_delimiter_tests {
9089 use super::*;
9090
9091 #[test]
9092 fn reflow_preserves_shortcode_arguments_containing_closing_markers() {
9093 let options = ReflowOptions {
9094 line_length: 30,
9095 ..Default::default()
9096 };
9097 for (open, close) in [("{{<", ">}}"), ("{{%", "%}}")] {
9098 for quote in ["`", "\""] {
9099 let tag =
9100 format!("{open} note title={quote}literal {close} friend with some words and more{quote} {close}");
9101 let line = format!("Before {tag} after with more prose words.");
9102 let fixed = reflow_line(&line, &options).join("\n");
9103 assert!(fixed.contains(&tag), "{line:?} -> {fixed:?}");
9104 assert!(fixed.contains('\n'), "surrounding prose should still reflow");
9105 }
9106 }
9107 }
9108}