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_cjk_char, is_cjk_sentence_ending, is_closing_bracket, is_closing_quote, is_opening_quote,
15 text_ends_with_abbreviation,
16};
17use crate::utils::unicode::joins_cjk_soft_break;
18use pulldown_cmark::{BrokenLink, CowStr, Event, LinkType, Options, Parser, Tag, TagEnd};
19use std::collections::HashSet;
20use std::sync::LazyLock;
21use unicode_width::UnicodeWidthStr;
22
23#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
25#[serde(rename_all = "kebab-case")]
26pub enum CjkSoftBreak {
27 #[default]
29 Space,
30 Join,
33}
34
35#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
38#[serde(rename_all = "kebab-case")]
39pub enum LinkTextPunctuation {
40 #[default]
42 Ignore,
43 Trailing,
45}
46
47#[derive(Clone, Copy)]
51struct TerminalLink {
52 start: usize,
53 end: usize,
54 punctuation: char,
55}
56
57#[derive(Clone, Copy, Debug, Default, PartialEq)]
59pub enum ReflowLengthMode {
60 Chars,
62 #[default]
64 Visual,
65 Bytes,
67}
68
69pub(crate) fn display_len(s: &str, mode: ReflowLengthMode) -> usize {
71 match mode {
72 ReflowLengthMode::Chars => s.chars().count(),
73 ReflowLengthMode::Visual => s.width(),
74 ReflowLengthMode::Bytes => s.len(),
75 }
76}
77
78fn is_breakable_whitespace(c: char) -> bool {
82 matches!(c, ' ' | '\t' | '\n' | '\r')
83}
84
85fn is_significant_whitespace(c: char) -> bool {
92 c.is_whitespace() && !is_breakable_whitespace(c)
93}
94
95pub(crate) fn trim_breakable_whitespace(text: &str) -> &str {
98 text.trim_matches(is_breakable_whitespace)
99}
100
101fn split_breakable_words(text: &str) -> impl Iterator<Item = &str> {
103 text.split(is_breakable_whitespace).filter(|word| !word.is_empty())
104}
105
106fn code_span_wraps_losslessly(content: &str) -> bool {
115 let mut prev_ws = false;
116 for c in content.chars() {
117 let ws = is_breakable_whitespace(c);
118 if ws && (prev_ws || c != ' ') {
119 return false;
120 }
121 prev_ws = ws;
122 }
123 true
124}
125
126struct NestedStructure {
129 parentheticals: Vec<(usize, usize)>,
131 terminal_links: Vec<TerminalLink>,
133 atomic: Vec<(usize, usize)>,
140 markers: Vec<(usize, usize)>,
145 marker_closers: Vec<(usize, usize)>,
151 links: Vec<(usize, usize)>,
156 code_spans: Vec<(usize, usize)>,
159}
160
161struct OpenSpan {
163 span: (usize, usize),
165 content: Option<(usize, usize)>,
168}
169
170fn note_span_content(open: &mut [OpenSpan], start: usize, end: usize) {
173 for open_span in open.iter_mut() {
174 if start >= open_span.span.0 && end <= open_span.span.1 {
175 open_span.content = Some(match open_span.content {
176 Some((known_start, known_end)) => (known_start.min(start), known_end.max(end)),
177 None => (start, end),
178 });
179 }
180 }
181}
182
183fn merge_ranges(mut ranges: Vec<(usize, usize)>) -> Vec<(usize, usize)> {
184 ranges.sort_unstable();
187 let mut merged: Vec<(usize, usize)> = Vec::with_capacity(ranges.len());
188 for (start, end) in ranges {
189 match merged.last_mut() {
190 Some(last) if start <= last.1 => last.1 = last.1.max(end),
191 _ => merged.push((start, end)),
192 }
193 }
194 merged
195}
196
197fn nested_structure(content: &str, defined_references: Option<&HashSet<String>>, attr_lists: bool) -> NestedStructure {
199 let mut options = Options::empty();
200 options.insert(Options::ENABLE_STRIKETHROUGH);
201
202 let mut atomic: Vec<(usize, usize)> = Vec::new();
203 let mut markers: Vec<(usize, usize)> = Vec::new();
204 let mut marker_closers: Vec<(usize, usize)> = Vec::new();
205 let mut links: Vec<(usize, usize)> = Vec::new();
206 let mut code_spans: Vec<(usize, usize)> = Vec::new();
207 let mut open: Vec<OpenSpan> = Vec::new();
210
211 let indent = content.len() - content.trim_start_matches([' ', '\t']).len();
216 for (event, range) in Parser::new_ext(&content[indent..], options).into_offset_iter() {
217 let (start, end) = (range.start + indent, range.end + indent);
218 if !matches!(event, Event::End(_)) {
222 note_span_content(&mut open, start, end);
223 }
224 match event {
225 Event::Start(Tag::Link { .. } | Tag::Image { .. }) => {
226 atomic.push((start, end));
227 links.push((start, end));
228 }
229 Event::Code(_) => {
230 atomic.push((start, end));
231 code_spans.push((start, end));
232 }
233 Event::InlineHtml(_) => {
234 atomic.push((start, end));
235 }
236 Event::Start(Tag::Emphasis | Tag::Strong | Tag::Strikethrough) => {
237 open.push(OpenSpan {
238 span: (start, end),
239 content: None,
240 });
241 }
242 Event::End(TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough) => {
243 if let Some(OpenSpan {
244 span: (span_start, span_end),
245 content,
246 }) = open.pop()
247 {
248 match content {
249 Some((content_start, content_end)) => {
253 markers.push((span_start, content_start));
254 markers.push((content_end, span_end));
255 marker_closers.push((content_end, span_end));
256 }
257 None => atomic.push((span_start, span_end)),
260 }
261 }
262 }
263 _ => {}
264 }
265 }
266
267 for span in all_link_spans(content, defined_references) {
274 atomic.push((span.start, span.end));
275 links.push((span.start, span.end));
276 }
277
278 for found in WIKI_LINK_REGEX.find_iter(content) {
282 atomic.push((found.start(), found.end()));
283 links.push((found.start(), found.end()));
284 }
285 atomic.extend(crate::utils::shortcode_utils::shortcode_ranges(content));
286
287 let held = merge_ranges(atomic.clone());
296 let mut masked = String::with_capacity(content.len());
297 let mut next_held = 0;
298 for (pos, ch) in content.char_indices() {
299 while held.get(next_held).is_some_and(|&(_, end)| end <= pos) {
300 next_held += 1;
301 }
302 if held.get(next_held).is_some_and(|&(start, _)| start <= pos) {
303 masked.extend(std::iter::repeat_n(' ', ch.len_utf8()));
304 } else {
305 masked.push(ch);
306 }
307 }
308 for found in DISPLAY_MATH_REGEX.find_iter(&masked) {
309 atomic.push((found.start(), found.end()));
310 }
311 let mut from = 0;
312 while let Ok(Some(found)) = INLINE_MATH_REGEX.find_from_pos(&masked, from) {
313 atomic.push((found.start(), found.end()));
314 from = found.end();
315 }
316
317 if attr_lists {
323 for found in ATTR_LIST_PATTERN.find_iter(content) {
324 atomic.push((found.start(), found.end()));
325 }
326 }
327
328 links.sort_unstable();
331 links.dedup();
332
333 NestedStructure {
340 parentheticals: Vec::new(),
341 terminal_links: Vec::new(),
342 atomic: merge_ranges(atomic),
343 markers: merge_ranges(markers),
344 marker_closers: merge_ranges(marker_closers),
345 links,
346 code_spans,
347 }
348}
349
350fn breakable_units<'a>(
373 content: &'a str,
374 defined_references: Option<&HashSet<String>>,
375 attr_lists: bool,
376) -> Option<Vec<&'a str>> {
377 if !content.contains(['`', '*', '_', '~', '[', '<', '$', '{']) {
380 return Some(split_breakable_words(content).collect());
381 }
382
383 let NestedStructure { atomic, markers, .. } = nested_structure(content, defined_references, attr_lists);
384
385 let mut units = Vec::new();
386 let mut unit_start = None;
387 let mut next_atomic = 0;
388 let mut next_marker = 0;
389 for (offset, ch) in content.char_indices() {
390 while atomic.get(next_atomic).is_some_and(|&(_, end)| end <= offset) {
391 next_atomic += 1;
392 }
393 if atomic.get(next_atomic).is_some_and(|&(start, _)| offset >= start) {
394 if unit_start.is_none() {
397 unit_start = Some(offset);
398 }
399 continue;
400 }
401 while markers.get(next_marker).is_some_and(|&(_, end)| end <= offset) {
402 next_marker += 1;
403 }
404 if matches!(ch, '`' | '*' | '_' | '~') && markers.get(next_marker).is_none_or(|&(start, _)| offset < start) {
405 return None;
406 }
407 if is_breakable_whitespace(ch) {
408 if let Some(start) = unit_start.take() {
409 units.push(&content[start..offset]);
410 }
411 } else if unit_start.is_none() {
412 unit_start = Some(offset);
413 }
414 }
415 if let Some(start) = unit_start {
416 units.push(&content[start..]);
417 }
418 Some(units)
419}
420
421pub(crate) fn link_text_break_units<'a>(
441 inner: &'a str,
442 suffix: &str,
443 budget: usize,
444 mode: ReflowLengthMode,
445 defined_references: Option<&HashSet<String>>,
446 attr_lists: bool,
447) -> Option<Vec<&'a str>> {
448 if display_len(suffix, mode) > budget && display_len(inner, mode) + 2 <= budget {
449 return None;
450 }
451 let units = breakable_units(inner, defined_references, attr_lists)?;
452 if units.len() < 2 {
453 return None;
454 }
455 let tail = format!("{}{suffix}", units[units.len() - 1]);
456 if display_len(&tail, mode) > budget && !last_token_overflow_only(&tail, budget, mode) {
457 return None;
458 }
459 Some(units)
460}
461
462fn last_token_overflow_only(line: &str, budget: usize, mode: ReflowLengthMode) -> bool {
468 match line.rfind(char::is_whitespace) {
469 None => true,
470 Some(pos) => {
471 let ws_len = line[pos..].chars().next().map_or(1, char::len_utf8);
472 display_len(&line[..pos + ws_len], mode) < budget
473 }
474 }
475}
476
477#[derive(Clone)]
479pub struct ReflowOptions {
480 pub line_length: usize,
482 pub break_on_sentences: bool,
484 pub preserve_breaks: bool,
486 pub sentence_per_line: bool,
488 pub sentence_pack: bool,
490 pub first_line_length: Option<usize>,
492 pub semantic_line_breaks: bool,
494 pub abbreviations: Option<Vec<String>>,
498 pub break_words: Vec<String>,
501 pub length_mode: ReflowLengthMode,
503 pub attr_lists: bool,
506 pub myst_roles: bool,
510 pub require_sentence_capital: bool,
515 pub link_text_punctuation: LinkTextPunctuation,
517 pub max_list_continuation_indent: Option<usize>,
521 pub defined_references: Option<HashSet<String>>,
535 pub atomic_spans: bool,
539 pub wrap_code_spans: bool,
543 pub break_link_text: bool,
552 pub length_exemptions: LengthExemptions,
555 pub cjk_soft_break: CjkSoftBreak,
557}
558
559#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
566pub struct LengthExemptions {
567 pub link_urls: bool,
570 pub code_spans: bool,
572}
573
574impl LengthExemptions {
575 fn any(&self) -> bool {
578 self.link_urls || self.code_spans
579 }
580}
581
582impl Default for ReflowOptions {
583 fn default() -> Self {
584 Self {
585 line_length: 80,
586 break_on_sentences: true,
587 preserve_breaks: false,
588 sentence_per_line: false,
589 sentence_pack: false,
590 first_line_length: None,
591 semantic_line_breaks: false,
592 abbreviations: None,
593 break_words: Vec::new(),
594 length_mode: ReflowLengthMode::default(),
595 attr_lists: false,
596 myst_roles: false,
597 require_sentence_capital: true,
598 link_text_punctuation: LinkTextPunctuation::default(),
599 max_list_continuation_indent: None,
600 defined_references: None,
601 atomic_spans: true,
602 wrap_code_spans: true,
603 break_link_text: false,
604 length_exemptions: LengthExemptions::default(),
605 cjk_soft_break: CjkSoftBreak::default(),
606 }
607 }
608}
609
610#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
617struct LineWidth {
618 link_exempt: usize,
620 code_exempt: usize,
622}
623
624impl LineWidth {
625 fn plain(width: usize) -> Self {
628 Self {
629 link_exempt: width,
630 code_exempt: width,
631 }
632 }
633
634 fn effective(self) -> usize {
637 self.link_exempt.min(self.code_exempt)
638 }
639
640 fn fits(self, line_length: usize) -> bool {
641 self.effective() <= line_length
642 }
643
644 fn is_empty(self) -> bool {
648 self.link_exempt == 0 && self.code_exempt == 0
649 }
650}
651
652impl std::ops::Add for LineWidth {
653 type Output = Self;
654
655 fn add(self, other: Self) -> Self {
656 Self {
657 link_exempt: self.link_exempt + other.link_exempt,
658 code_exempt: self.code_exempt + other.code_exempt,
659 }
660 }
661}
662
663impl std::ops::AddAssign for LineWidth {
664 fn add_assign(&mut self, other: Self) {
665 *self = *self + other;
666 }
667}
668
669pub fn normalize_reference_label(label: &str) -> String {
676 label.split_whitespace().collect::<Vec<_>>().join(" ").to_lowercase()
677}
678
679fn footnote_ref_end(chars: &[char], start: usize) -> Option<usize> {
685 if chars.get(start) != Some(&'[') || chars.get(start + 1) != Some(&'^') {
686 return None;
687 }
688 let label_start = start + 2;
689 let mut label_end = label_start;
690 while matches!(chars.get(label_end), Some(c) if c.is_ascii_alphanumeric() || *c == '_' || *c == '-') {
691 label_end += 1;
692 }
693 (label_end > label_start && chars.get(label_end) == Some(&']')).then_some(label_end + 1)
694}
695
696fn footnote_refs_end(chars: &[char], start: usize) -> Option<usize> {
700 let mut end = footnote_ref_end(chars, start)?;
701 while let Some(after_ref) = footnote_ref_end(chars, end) {
702 end = after_ref;
703 }
704 Some(end)
705}
706
707fn char_byte_offsets(chars: &[char]) -> Vec<usize> {
710 let mut offsets = Vec::with_capacity(chars.len() + 1);
711 let mut offset = 0;
712 for c in chars {
713 offsets.push(offset);
714 offset += c.len_utf8();
715 }
716 offsets.push(offset);
717 offsets
718}
719
720struct SentenceText<'a> {
725 text: &'a str,
726 chars: &'a [char],
727 char_offsets: &'a [usize],
728 links: &'a [(usize, usize)],
729 code_spans: &'a [(usize, usize)],
730 markers: &'a [(usize, usize)],
731 marker_closers: &'a [(usize, usize)],
732 paragraph: Option<ParagraphStructure<'a>>,
737 cjk_soft_break: CjkSoftBreak,
740}
741
742impl SentenceText<'_> {
743 fn in_span_delimiter(&self, pos: usize) -> bool {
748 let Some(&offset) = self.char_offsets.get(pos) else {
749 return false;
750 };
751 match self.markers.binary_search_by_key(&offset, |&(start, _)| start) {
752 Ok(_) => true,
753 Err(0) => false,
754 Err(i) => offset < self.markers[i - 1].1,
755 }
756 }
757
758 fn span_closer_end(&self, pos: usize) -> Option<usize> {
764 let &offset = self.char_offsets.get(pos)?;
765 let covering = match self.marker_closers.binary_search_by_key(&offset, |&(start, _)| start) {
766 Ok(i) => i,
767 Err(0) => return None,
768 Err(i) if offset < self.marker_closers[i - 1].1 => i - 1,
769 Err(_) => return None,
770 };
771 let end = self.marker_closers[covering].1;
772 Some(self.char_offsets.binary_search(&end).unwrap_or_else(|i| i))
773 }
774
775 fn opens_code_span(&self, pos: usize) -> bool {
780 self.char_offsets
781 .get(pos)
782 .is_some_and(|&start| self.code_spans.binary_search_by_key(&start, |&(s, _)| s).is_ok())
783 }
784
785 fn link_end_at(&self, pos: usize) -> Option<usize> {
794 let range_start = match self.chars.get(pos) {
795 Some('[') => pos,
796 Some('!') if self.chars.get(pos + 1) == Some(&'[') => match self.link_range_end_at(pos) {
797 Some(end) => return Some(end),
798 None => pos + 1,
799 },
800 _ => return None,
801 };
802 self.link_range_end_at(range_start)
803 }
804
805 fn link_range_end_at(&self, pos: usize) -> Option<usize> {
807 let start = self.char_offsets[pos];
808 let idx = self.links.binary_search_by_key(&start, |&(s, _)| s).ok()?;
809 let end = self.links[idx].1;
810 Some(self.char_offsets.binary_search(&end).unwrap_or_else(|i| i))
811 }
812
813 fn byte_at(&self, pos: usize) -> usize {
815 self.char_offsets.get(pos).copied().unwrap_or(self.text.len())
816 }
817
818 fn cut_keeps_text(&self, cut: usize, resume: usize) -> bool {
827 if cut < resume {
828 return true;
829 }
830 let resume_byte = self.byte_at(resume);
831 let rest = match self.paragraph {
832 Some(paragraph) => ¶graph.text[paragraph.base + resume_byte..],
833 None => &self.text[resume_byte..],
834 };
835 !starts_block_construct(rest)
836 }
837}
838
839#[derive(Clone, Copy)]
852struct ParagraphStructure<'a> {
853 text: &'a str,
854 structure: &'a NestedStructure,
855 base: usize,
856}
857
858impl ParagraphStructure<'_> {
859 fn window(&self, len: usize) -> NestedStructure {
871 let base = self.base;
872 let window_end = base + len;
873 let inside = |ranges: &[(usize, usize)]| -> Vec<(usize, usize)> {
874 let first = ranges.partition_point(|&(_, end)| end <= base);
875 let past = first + ranges[first..].partition_point(|&(start, _)| start < window_end);
876 ranges[first..past]
877 .iter()
878 .map(|&(start, end)| (start.saturating_sub(base), (end - base).min(len)))
879 .collect()
880 };
881 let starting_inside = |ranges: &[(usize, usize)]| -> Vec<(usize, usize)> {
882 let first = ranges.partition_point(|&(start, _)| start < base);
883 let past = first + ranges[first..].partition_point(|&(start, _)| start < window_end);
884 ranges[first..past]
885 .iter()
886 .map(|&(start, end)| (start - base, (end - base).min(len)))
887 .collect()
888 };
889 let first_terminal = self.structure.terminal_links.partition_point(|link| link.start < base);
890 let past_terminal = first_terminal
891 + self.structure.terminal_links[first_terminal..].partition_point(|link| link.start < window_end);
892 NestedStructure {
893 parentheticals: inside(&self.structure.parentheticals),
894 terminal_links: self.structure.terminal_links[first_terminal..past_terminal]
896 .iter()
897 .filter(|link| link.end <= window_end)
898 .map(|link| TerminalLink {
899 start: link.start - base,
900 end: link.end - base,
901 ..*link
902 })
903 .collect(),
904 atomic: inside(&self.structure.atomic),
905 markers: inside(&self.structure.markers),
906 marker_closers: inside(&self.structure.marker_closers),
907 links: starting_inside(&self.structure.links),
908 code_spans: starting_inside(&self.structure.code_spans),
909 }
910 }
911}
912
913fn marker_run_extent(chars: &[char], from: usize) -> usize {
920 let mut end = from;
921 while end < chars.len() && matches!(chars[end], '*' | '_' | '~') {
922 end += 1;
923 }
924 end
925}
926
927fn delimiter_run_extent(chars: &[char], from: usize) -> usize {
933 let Some(&first) = chars.get(from) else {
934 return from;
935 };
936 if !matches!(first, '*' | '_' | '~') {
937 return from;
938 }
939 let mut end = from;
940 while chars.get(end) == Some(&first) {
941 end += 1;
942 }
943 end
944}
945
946fn cjk_sentence_end(st: &SentenceText<'_>, pos: usize) -> Option<usize> {
984 let mut end = pos + 1;
985 loop {
986 if let Some(after_ref) = footnote_ref_end(st.chars, end)
987 && st.link_range_end_at(end).is_none_or(|link_end| link_end == after_ref)
988 {
989 end = after_ref;
990 continue;
991 }
992 let Some(&next) = st.chars.get(end) else {
993 return Some(end);
994 };
995 if is_closing_bracket(next) || is_closing_quote(next) {
996 end += 1;
997 continue;
998 }
999 if !matches!(next, '*' | '_' | '~') {
1000 return Some(end);
1001 }
1002 if let Some(closer_end) = st.span_closer_end(end) {
1003 if st
1004 .chars
1005 .get(closer_end)
1006 .is_some_and(|after| Some(after) == st.chars.get(closer_end - 1))
1007 {
1008 return None;
1009 }
1010 end = closer_end;
1011 continue;
1012 }
1013 if st.in_span_delimiter(end) {
1014 return Some(end);
1015 }
1016 let run_end = delimiter_run_extent(st.chars, end);
1017 match st.chars.get(run_end) {
1018 None => return Some(run_end),
1019 Some(&after) if is_breakable_whitespace(after) => return Some(run_end),
1020 Some(_) => return None,
1021 }
1022 }
1023}
1024
1025fn sentence_boundary(
1037 st: &SentenceText<'_>,
1038 pos: usize,
1039 abbreviations: &HashSet<String>,
1040 require_sentence_capital: bool,
1041) -> Option<usize> {
1042 let SentenceText { text, chars, .. } = *st;
1043 if pos + 1 >= chars.len() {
1044 return None;
1045 }
1046 let byte_offset_after_punct = st.char_offsets[pos + 1];
1047
1048 let c = chars[pos];
1049 let next_char = chars[pos + 1];
1050
1051 if is_cjk_sentence_ending(c) {
1054 let cut = cjk_sentence_end(st, pos)?;
1059
1060 let mut after_punct_pos = cut;
1062 while after_punct_pos < chars.len() && is_breakable_whitespace(chars[after_punct_pos]) {
1063 after_punct_pos += 1;
1064 }
1065 let resume = after_punct_pos;
1066
1067 if after_punct_pos >= chars.len() {
1070 return None;
1071 }
1072
1073 let break_dropped = st.cjk_soft_break == CjkSoftBreak::Join
1080 && joins_cjk_soft_break(chars[cut - 1])
1081 && joins_cjk_soft_break(chars[resume]);
1082 if (resume > cut) == break_dropped {
1083 return None;
1084 }
1085
1086 if continues_clause(chars[resume]) {
1090 return None;
1091 }
1092
1093 if opens_ordered_list_marker(&chars[after_punct_pos..]) {
1096 return None;
1097 }
1098
1099 while after_punct_pos < chars.len()
1101 && (chars[after_punct_pos] == '*' || chars[after_punct_pos] == '_' || chars[after_punct_pos] == '~')
1102 {
1103 after_punct_pos += 1;
1104 }
1105
1106 if after_punct_pos >= chars.len() {
1107 return None;
1108 }
1109
1110 return st.cut_keeps_text(cut, resume).then_some(cut);
1113 }
1114
1115 if c != '.' && c != '!' && c != '?' {
1117 return None;
1118 }
1119
1120 let inside_quotation = is_closing_quote(next_char);
1123
1124 let (space_pos, after_space_pos) = if matches!(next_char, ' ' | '\n') {
1127 (pos + 1, pos + 2)
1129 } else if is_closing_quote(next_char) && pos + 2 < chars.len() {
1130 match marker_run_end(chars, pos + 2) {
1133 Some(end) => (end, end + 1),
1134 None => return None,
1135 }
1136 } else if matches!(next_char, '*' | '_' | '~') {
1137 match marker_run_end(chars, pos + 1) {
1141 Some(end) => (end, end + 1),
1142 None => return None,
1143 }
1144 } else if next_char == '[' {
1145 match footnote_refs_end(chars, pos + 1) {
1151 Some(end_pos) if matches!(chars.get(end_pos), Some(' ' | '\n')) => (end_pos, end_pos + 1),
1152 _ => return None,
1153 }
1154 } else {
1155 return None;
1156 };
1157
1158 let mut next_char_pos = after_space_pos;
1160 while next_char_pos < chars.len() && is_breakable_whitespace(chars[next_char_pos]) {
1161 next_char_pos += 1;
1162 }
1163
1164 if next_char_pos >= chars.len() {
1166 return None;
1167 }
1168
1169 if opens_ordered_list_marker(&chars[next_char_pos..]) {
1178 return None;
1179 }
1180
1181 let mut first_letter_pos = next_char_pos;
1187 while first_letter_pos < chars.len() {
1188 let ch = chars[first_letter_pos];
1189 if let Some(end) = st.link_end_at(first_letter_pos) {
1190 first_letter_pos += link_opener_len(chars, first_letter_pos, end);
1191 } else if matches!(ch, '*' | '_' | '~') || is_opening_quote(ch) {
1192 first_letter_pos += 1;
1193 } else {
1194 break;
1195 }
1196 }
1197
1198 if first_letter_pos >= chars.len() {
1200 return None;
1201 }
1202
1203 let first_char = chars[first_letter_pos];
1204
1205 if c == '!' || c == '?' {
1211 if inside_quotation && require_sentence_capital && !opens_sentence_in_strict_mode(first_char) {
1212 return None;
1213 }
1214 return Some(space_pos);
1215 }
1216
1217 if pos > 0 {
1223 if matches!(next_char, ' ' | '\n') && chars[pos - 1].is_ascii_digit() {
1227 let mut digit_start = pos - 1;
1228 while digit_start > 0 && chars[digit_start - 1].is_ascii_digit() {
1229 digit_start -= 1;
1230 }
1231 let start = st.byte_at(digit_start);
1232 if st.markers.iter().any(|&(open, end)| {
1233 end == start
1234 && !st
1235 .marker_closers
1236 .iter()
1237 .any(|&(close, end)| close <= open && open < end)
1238 }) {
1239 return None;
1240 }
1241 }
1242
1243 if text_ends_with_abbreviation(&text[..byte_offset_after_punct], abbreviations) {
1245 return None;
1246 }
1247
1248 if single_letter_initial(chars, pos, next_char_pos, first_char) {
1253 return None;
1254 }
1255 }
1256
1257 let elision = pos > 0 && chars[pos - 1] == '.';
1268 let digit_run = pos > 0 && chars[pos - 1].is_numeric();
1269 let bare = space_pos == pos + 1;
1270
1271 let numeric_label = digit_run && bare && {
1276 let mut start = pos;
1277 while start > 0 && chars[start - 1].is_ascii_digit() {
1278 start -= 1;
1279 }
1280 start == 0 || chars[start - 1] == '.' || chars[..start].iter().rev().find(|c| !c.is_whitespace()) == Some(&':')
1281 };
1282 static SENTENCE_FINAL_ABBREVIATIONS: LazyLock<HashSet<String>> =
1286 LazyLock::new(|| ["etc", "inc", "ph.d", "u.s"].into_iter().map(String::from).collect());
1287 let ends_with_optional_abbreviation =
1288 text_ends_with_abbreviation(&text[..byte_offset_after_punct], &SENTENCE_FINAL_ABBREVIATIONS);
1289 if chars[space_pos..next_char_pos].contains(&'\n')
1290 && !inside_quotation
1291 && !elision
1292 && !numeric_label
1293 && !ends_with_optional_abbreviation
1294 {
1295 return Some(space_pos);
1296 }
1297
1298 if st.opens_code_span(first_letter_pos) && !elision && !(digit_run && bare) {
1304 return Some(space_pos);
1305 }
1306
1307 if require_sentence_capital && !opens_sentence_in_strict_mode(first_char) {
1310 return None;
1311 }
1312
1313 Some(space_pos)
1314}
1315
1316fn single_letter_initial(chars: &[char], period: usize, next_word: usize, first_char: char) -> bool {
1319 if period == 0 || !chars[period - 1].is_ascii_uppercase() || (period > 1 && !chars[period - 2].is_whitespace()) {
1320 return false;
1321 }
1322
1323 let before = &chars[..period - 1];
1324 let end = before.iter().rposition(|c| !c.is_whitespace()).map_or(0, |i| i + 1);
1325 if end == 0 {
1326 return true;
1327 }
1328 let before = &before[..end];
1329 let start = before.iter().rposition(|c| c.is_whitespace()).map_or(0, |i| i + 1);
1330 let previous_word = &before[start..];
1331 let previous_ender = previous_word
1332 .iter()
1333 .rev()
1334 .copied()
1335 .find(|&c| !matches!(c, '*' | '_' | '~') && !is_closing_quote(c) && !is_closing_bracket(c));
1336 if previous_word
1339 .iter()
1340 .any(|&c| matches!(c, '.' | '!' | '?' | '<' | '>' | '&') || is_cjk_sentence_ending(c))
1341 || previous_ender.is_some_and(continues_clause)
1342 || !previous_word
1343 .iter()
1344 .find(|c| c.is_alphabetic())
1345 .is_some_and(|c| c.is_lowercase())
1346 || !opens_sentence_in_strict_mode(first_char)
1347 {
1348 return true;
1349 }
1350
1351 chars.get(next_word).is_some_and(char::is_ascii_uppercase)
1354 && chars.get(next_word + 1) == Some(&'.')
1355 && chars.get(next_word + 2).is_none_or(|c| c.is_whitespace())
1356}
1357
1358fn marker_run_end(chars: &[char], from: usize) -> Option<usize> {
1366 let end = marker_run_extent(chars, from);
1367 matches!(chars.get(end), Some(' ' | '\n')).then_some(end)
1368}
1369
1370fn opens_sentence_in_strict_mode(first_char: char) -> bool {
1378 first_char.is_uppercase() || first_char.is_numeric() || is_cjk_char(first_char)
1379}
1380
1381fn continues_clause(c: char) -> bool {
1384 matches!(
1385 c,
1386 ',' | ';' | ':' | '.' | '!' | '?' | '、' | ',' | ';' | ':' | '.' | '。' | '、'
1387 ) || is_cjk_sentence_ending(c)
1388}
1389
1390fn opens_ordered_list_marker(chars: &[char]) -> bool {
1396 let digits = chars.iter().take_while(|c| c.is_ascii_digit()).count();
1397 digits > 0
1398 && matches!(chars.get(digits), Some('.' | ')'))
1399 && matches!(chars.get(digits + 1), Some(' ' | '\t' | '\n'))
1400}
1401
1402fn link_opener_len(chars: &[char], pos: usize, end: usize) -> usize {
1408 let open = if chars[pos] == '!' { pos + 1 } else { pos };
1409 let body = open + 1;
1410 if chars.get(body) != Some(&'[') {
1411 return body - pos;
1412 }
1413 let body = body + 1;
1414 let alias = chars[body..end.saturating_sub(2).max(body)]
1415 .iter()
1416 .position(|&c| c == '|')
1417 .map_or(body, |p| body + p + 1);
1418 alias - pos
1419}
1420
1421fn parse_terminal_links(
1428 text: &str,
1429 references: Option<&HashSet<String>>,
1430 abbreviations: &HashSet<String>,
1431) -> Vec<TerminalLink> {
1432 if !text.contains('[') {
1433 return Vec::new();
1434 }
1435 struct Label {
1436 start: usize,
1437 link_type: LinkType,
1438 visible: String,
1439 literal_terminal: bool,
1440 }
1441 let resolve = |link: BrokenLink<'_>| {
1442 references
1443 .filter(|defs| defs.contains(&normalize_reference_label(&link.reference)))
1444 .map(|_| (CowStr::Borrowed(""), CowStr::Borrowed("")))
1445 };
1446 let parser = Parser::new_with_broken_link_callback(
1447 text,
1448 Options::ENABLE_STRIKETHROUGH | Options::ENABLE_FOOTNOTES,
1449 Some(resolve),
1450 )
1451 .into_offset_iter();
1452 let mut labels: Vec<Label> = Vec::new();
1453 let mut result = Vec::new();
1454 for (event, range) in parser {
1455 match event {
1456 Event::Start(Tag::Link { link_type, .. } | Tag::Image { link_type, .. }) => {
1457 labels.push(Label {
1458 start: range.start,
1459 link_type,
1460 visible: String::new(),
1461 literal_terminal: false,
1462 });
1463 }
1464 Event::Text(value) => {
1465 if let Some(label) = labels.last_mut() {
1466 label.visible.push_str(&value);
1467 let visible = value.trim_end_matches(is_closing_quote);
1468 if let Some(last) = visible.chars().next_back() {
1469 let source = text[range].trim_end_matches(is_closing_quote);
1471 label.literal_terminal =
1472 (matches!(last, '.' | '!' | '?') || is_cjk_sentence_ending(last)) && source.ends_with(last);
1473 }
1474 }
1475 }
1476 Event::End(TagEnd::Link | TagEnd::Image) => {
1477 let Some(label) = labels.pop() else { continue };
1478 let mut end = range.end;
1479 if matches!(label.link_type, LinkType::Collapsed | LinkType::CollapsedUnknown)
1480 && text[end..].starts_with("[]")
1481 {
1482 end += 2;
1486 }
1487 if let Some(parent) = labels.last_mut() {
1488 parent.visible.push_str(&label.visible);
1489 parent.literal_terminal =
1490 label.literal_terminal && !matches!(label.link_type, LinkType::Autolink | LinkType::Email);
1491 } else if !matches!(label.link_type, LinkType::Autolink | LinkType::Email)
1492 && label.literal_terminal
1493 && let Some(punctuation) = label_terminator(&label.visible, abbreviations)
1494 {
1495 result.push(TerminalLink {
1496 start: label.start,
1497 end,
1498 punctuation,
1499 });
1500 }
1501 }
1502 Event::Code(_)
1503 | Event::Html(_)
1504 | Event::InlineHtml(_)
1505 | Event::InlineMath(_)
1506 | Event::DisplayMath(_)
1507 | Event::FootnoteReference(_)
1508 | Event::SoftBreak
1509 | Event::HardBreak => {
1510 if let Some(label) = labels.last_mut() {
1511 label.visible.push(' ');
1512 label.literal_terminal = false;
1513 }
1514 }
1515 _ => {}
1516 }
1517 }
1518 result
1519}
1520
1521fn label_terminator(label: &str, abbreviations: &HashSet<String>) -> Option<char> {
1525 let label = label.trim_end_matches(is_closing_quote);
1526 let punctuation = label.chars().next_back()?;
1527 if !matches!(punctuation, '.' | '!' | '?') && !is_cjk_sentence_ending(punctuation) {
1528 return None;
1529 }
1530 let prefix = &label[..label.len() - punctuation.len_utf8()];
1531 if !prefix.chars().any(char::is_alphanumeric) {
1532 return None;
1533 }
1534 if punctuation != '.' {
1537 return Some(punctuation);
1538 }
1539 if punctuation == '.'
1541 && label[..label.len() - 1]
1542 .chars()
1543 .next_back()
1544 .is_some_and(char::is_numeric)
1545 {
1546 return None;
1547 }
1548 const STRICT_OPENER: &str = " Next";
1550 let probe = format!("{label}{STRICT_OPENER}");
1551 let chars: Vec<char> = probe.chars().collect();
1552 let offsets = char_byte_offsets(&chars);
1553 let st = SentenceText {
1554 text: &probe,
1555 chars: &chars,
1556 char_offsets: &offsets,
1557 links: &[],
1558 code_spans: &[],
1559 markers: &[],
1560 marker_closers: &[],
1561 paragraph: None,
1562 cjk_soft_break: CjkSoftBreak::Space,
1563 };
1564 let pos = label.chars().count() - 1;
1565 debug_assert_eq!(chars[pos], punctuation);
1566 sentence_boundary(&st, pos, abbreviations, true).map(|_| punctuation)
1567}
1568
1569fn terminal_link_boundary(st: &SentenceText<'_>, pos: usize, punctuation: char) -> Option<usize> {
1573 let cut = cjk_sentence_end(st, pos)?;
1574 let chars = st.chars;
1575 let mut resume = cut;
1576 while resume < chars.len() && is_breakable_whitespace(chars[resume]) {
1577 resume += 1;
1578 }
1579 if resume == chars.len() || opens_ordered_list_marker(&chars[resume..]) {
1580 return None;
1581 }
1582 if is_cjk_sentence_ending(punctuation) {
1583 let break_dropped = st.cjk_soft_break == CjkSoftBreak::Join
1584 && joins_cjk_soft_break(chars[cut - 1])
1585 && joins_cjk_soft_break(chars[resume]);
1586 if (resume > cut) == break_dropped || continues_clause(chars[resume]) {
1587 return None;
1588 }
1589 } else {
1590 if resume == cut {
1591 return None;
1592 }
1593 let mut first = resume;
1594 while first < chars.len() {
1595 if let Some(end) = st.link_end_at(first) {
1596 first += link_opener_len(chars, first, end);
1597 } else if matches!(chars[first], '*' | '_' | '~') || is_opening_quote(chars[first]) {
1598 first += 1;
1599 } else {
1600 break;
1601 }
1602 }
1603 if !chars.get(first).is_some_and(|&c| opens_sentence_in_strict_mode(c)) {
1604 return None;
1605 }
1606 }
1607 st.cut_keeps_text(cut, resume).then_some(cut)
1608}
1609
1610fn sentence_structure_with_links(
1611 text: &str,
1612 references: Option<&HashSet<String>>,
1613 abbreviations: &HashSet<String>,
1614 policy: LinkTextPunctuation,
1615 semantic: bool,
1616) -> NestedStructure {
1617 let mut structure = if semantic {
1618 semantic_sentence_structure(text, references)
1619 } else {
1620 sentence_structure(text, references)
1621 };
1622 if policy == LinkTextPunctuation::Trailing {
1623 structure.terminal_links = parse_terminal_links(text, references, abbreviations);
1624 }
1625 structure
1626}
1627
1628pub fn split_into_sentences(
1641 text: &str,
1642 defined_references: Option<&HashSet<String>>,
1643 require_sentence_capital: bool,
1644 cjk_soft_break: CjkSoftBreak,
1645) -> Vec<String> {
1646 let abbreviations = get_abbreviations(&None);
1647 split_into_sentences_with_set(
1648 text,
1649 &abbreviations,
1650 require_sentence_capital,
1651 cjk_soft_break,
1652 defined_references,
1653 None,
1654 LinkTextPunctuation::Ignore,
1655 )
1656}
1657
1658pub(crate) fn split_into_sentences_with_link_text(
1660 text: &str,
1661 references: Option<&HashSet<String>>,
1662 require_sentence_capital: bool,
1663 cjk: CjkSoftBreak,
1664 policy: LinkTextPunctuation,
1665 abbreviations: &Option<Vec<String>>,
1666) -> Vec<String> {
1667 let abbreviations = get_abbreviations(if policy == LinkTextPunctuation::Trailing {
1670 abbreviations
1671 } else {
1672 &None
1673 });
1674 split_into_sentences_with_set(
1675 text,
1676 &abbreviations,
1677 require_sentence_capital,
1678 cjk,
1679 references,
1680 None,
1681 policy,
1682 )
1683}
1684
1685pub(crate) fn semantic_sentence_count(
1688 text: &str,
1689 defined_references: Option<&HashSet<String>>,
1690 require_sentence_capital: bool,
1691 cjk_soft_break: CjkSoftBreak,
1692 abbreviations: &Option<Vec<String>>,
1693 link_text_punctuation: LinkTextPunctuation,
1694) -> usize {
1695 let abbreviations = get_abbreviations(abbreviations);
1696 let structure =
1697 sentence_structure_with_links(text, defined_references, &abbreviations, link_text_punctuation, true);
1698 split_into_sentence_ranges(
1699 text,
1700 &abbreviations,
1701 require_sentence_capital,
1702 cjk_soft_break,
1703 defined_references,
1704 Some(ParagraphStructure {
1705 text,
1706 structure: &structure,
1707 base: 0,
1708 }),
1709 link_text_punctuation,
1710 )
1711 .len()
1712}
1713
1714fn split_into_sentences_with_set(
1722 text: &str,
1723 abbreviations: &HashSet<String>,
1724 require_sentence_capital: bool,
1725 cjk_soft_break: CjkSoftBreak,
1726 defined_references: Option<&HashSet<String>>,
1727 paragraph: Option<ParagraphStructure<'_>>,
1728 link_text_punctuation: LinkTextPunctuation,
1729) -> Vec<String> {
1730 split_into_sentence_ranges(
1731 text,
1732 abbreviations,
1733 require_sentence_capital,
1734 cjk_soft_break,
1735 defined_references,
1736 paragraph,
1737 link_text_punctuation,
1738 )
1739 .into_iter()
1740 .map(|(start, end)| text[start..end].to_string())
1741 .collect()
1742}
1743
1744fn trim_range(text: &str, start: usize, end: usize) -> Option<(usize, usize)> {
1747 let slice = &text[start..end];
1748 let leading = slice.len() - slice.trim_start_matches(is_breakable_whitespace).len();
1749 let trailing = slice.len() - slice.trim_end_matches(is_breakable_whitespace).len();
1750 (leading + trailing < slice.len()).then(|| (start + leading, end - trailing))
1751}
1752
1753fn split_into_sentence_ranges(
1761 text: &str,
1762 abbreviations: &HashSet<String>,
1763 require_sentence_capital: bool,
1764 cjk_soft_break: CjkSoftBreak,
1765 defined_references: Option<&HashSet<String>>,
1766 paragraph: Option<ParagraphStructure<'_>>,
1767 link_text_punctuation: LinkTextPunctuation,
1768) -> Vec<(usize, usize)> {
1769 let char_vec: Vec<char> = text.chars().collect();
1770 let char_offsets = char_byte_offsets(&char_vec);
1771
1772 let NestedStructure {
1776 parentheticals,
1777 terminal_links,
1778 atomic,
1779 links,
1780 code_spans,
1781 markers,
1782 marker_closers,
1783 } = match paragraph {
1784 Some(paragraph) => paragraph.window(text.len()),
1785 None => sentence_structure_with_links(text, defined_references, abbreviations, link_text_punctuation, false),
1786 };
1787 let mut atomic_it = atomic.iter().peekable();
1788 let st = SentenceText {
1789 text,
1790 chars: &char_vec,
1791 char_offsets: &char_offsets,
1792 links: &links,
1793 code_spans: &code_spans,
1794 markers: &markers,
1795 marker_closers: &marker_closers,
1796 paragraph,
1797 cjk_soft_break,
1798 };
1799
1800 let next_sentence_start = |cut: usize| if char_vec.get(cut) == Some(&' ') { cut + 1 } else { cut };
1803
1804 let mut cuts: Vec<usize> = Vec::new();
1808 let mut pos = 0;
1809
1810 while pos < char_vec.len() {
1811 let byte_idx = char_offsets[pos];
1812
1813 while let Some(&&(_, end)) = atomic_it.peek() {
1815 if end <= byte_idx {
1816 atomic_it.next();
1817 } else {
1818 break;
1819 }
1820 }
1821
1822 let in_atomic = atomic_it
1824 .peek()
1825 .is_some_and(|&&(start, end)| byte_idx >= start && byte_idx < end);
1826
1827 let boundary = if in_atomic && !terminal_links.is_empty() {
1828 let end = st.byte_at(pos + 1);
1829 terminal_links
1833 .binary_search_by_key(&end, |link| link.end)
1834 .ok()
1835 .filter(|_| atomic_it.peek().is_some_and(|&&(_, atomic_end)| atomic_end == end))
1836 .and_then(|index| terminal_link_boundary(&st, pos, terminal_links[index].punctuation))
1837 } else if !in_atomic {
1838 sentence_boundary(&st, pos, abbreviations, require_sentence_capital)
1839 } else {
1840 None
1841 };
1842 if let Some(cut) = boundary
1843 && !inside_parenthetical(char_offsets[cut], &parentheticals)
1844 {
1845 pos = next_sentence_start(cut);
1848 cuts.push(cut);
1849 continue;
1850 }
1851
1852 pos += 1;
1853 }
1854
1855 let mut sentences = Vec::new();
1856 let mut sentence_start = 0;
1858 for &cut in &cuts {
1859 if let Some(range) = trim_range(text, sentence_start, char_offsets[cut]) {
1865 sentences.push(range);
1866 }
1867 sentence_start = char_offsets[next_sentence_start(cut)];
1868 }
1869
1870 if let Some(range) = trim_range(text, sentence_start, text.len()) {
1872 sentences.push(range);
1873 }
1874 sentences
1875}
1876
1877fn sentence_structure(text: &str, defined_references: Option<&HashSet<String>>) -> NestedStructure {
1893 let holds_construct = text.contains(['`', '[', '<', '$']);
1898 let holds_emphasis = text.contains(['*', '_', '~']);
1899 if !holds_construct && !holds_emphasis {
1900 return NestedStructure {
1901 parentheticals: Vec::new(),
1902 terminal_links: Vec::new(),
1903 atomic: Vec::new(),
1904 markers: Vec::new(),
1905 marker_closers: Vec::new(),
1906 links: Vec::new(),
1907 code_spans: Vec::new(),
1908 };
1909 }
1910 nested_structure(text, defined_references, false)
1911}
1912
1913fn semantic_sentence_structure(text: &str, defined_references: Option<&HashSet<String>>) -> NestedStructure {
1917 let mut structure = sentence_structure(text, defined_references);
1918 let (Some(open), Some(close)) = (text.find('('), text.rfind(')')) else {
1919 return structure;
1920 };
1921 if open >= close || !text[open..close].contains(['.', '!', '?', '。', '!', '?']) {
1924 return structure;
1925 }
1926 let mut atomic = structure.atomic.iter().peekable();
1927 let mut opens = Vec::new();
1928 let mut groups = Vec::new();
1929 let mut escaped = false;
1930 for (offset, character) in text.char_indices() {
1931 while atomic.peek().is_some_and(|&&(_, end)| end <= offset) {
1932 atomic.next();
1933 }
1934 if atomic
1935 .peek()
1936 .is_some_and(|&&(start, end)| start <= offset && offset < end)
1937 {
1938 escaped = false;
1939 continue;
1940 }
1941 if escaped {
1942 escaped = false;
1943 continue;
1944 }
1945 match character {
1946 '\\' => escaped = true,
1947 '(' => opens.push(offset),
1948 ')' => {
1949 if let Some(start) = opens.pop() {
1950 groups.push((start, offset + 1));
1951 }
1952 }
1953 _ => {}
1954 }
1955 }
1956 structure.parentheticals = merge_ranges(groups);
1957 structure
1958}
1959
1960fn inside_parenthetical(offset: usize, groups: &[(usize, usize)]) -> bool {
1962 let past = groups.partition_point(|&(start, _)| start < offset);
1963 past > 0 && offset < groups[past - 1].1
1964}
1965
1966fn is_horizontal_rule(line: &str) -> bool {
1968 if line.len() < 3 {
1969 return false;
1970 }
1971
1972 let mut chars = line.chars();
1975 let Some(first_char) = chars.next() else {
1976 return false;
1977 };
1978 if first_char != '-' && first_char != '_' && first_char != '*' {
1979 return false;
1980 }
1981
1982 let mut non_space_count = 1usize; for c in chars {
1984 if c == ' ' {
1985 continue;
1986 }
1987 if c != first_char {
1988 return false;
1989 }
1990 non_space_count += 1;
1991 }
1992 non_space_count >= 3
1993}
1994
1995fn is_numbered_list_item(line: &str) -> bool {
1997 let mut chars = line.chars();
1998
1999 if !chars.next().is_some_and(char::is_numeric) {
2001 return false;
2002 }
2003
2004 while let Some(c) = chars.next() {
2006 if c == '.' {
2007 return chars.next() == Some(' ');
2010 }
2011 if !c.is_numeric() {
2012 return false;
2013 }
2014 }
2015
2016 false
2017}
2018
2019fn is_unordered_list_marker(s: &str) -> bool {
2021 matches!(s.as_bytes().first(), Some(b'-' | b'*' | b'+'))
2022 && !is_horizontal_rule(s)
2023 && (s.len() == 1 || s.as_bytes().get(1) == Some(&b' '))
2024}
2025
2026pub(crate) fn is_definition_list_marker(line: &str) -> bool {
2054 let trimmed = line.trim_start();
2055 trimmed.starts_with(':') && calculate_indentation_width_default(&line[..line.len() - trimmed.len()]) <= 3
2056}
2057
2058pub(crate) fn is_self_contained_display_math_line(line: &str) -> bool {
2080 let trimmed = line.trim();
2081 let inner = crate::utils::blockquote::parse_blockquote_prefix(trimmed).map_or(trimmed, |p| p.content.trim());
2082 let inner = inner.strip_suffix('\\').map_or(inner, str::trim_end);
2083 inner.len() >= 4
2084 && inner.starts_with("$$")
2085 && inner.ends_with("$$")
2086 && inner[2..].find("$$") == Some(inner.len() - 4)
2087}
2088
2089pub(crate) fn lines_touching_multiline_code_span(text: &str) -> Vec<bool> {
2112 let line_count = text.lines().count();
2113 if !text.contains('`') || !text.contains("$$") {
2114 return vec![false; line_count];
2115 }
2116 let code_spans = crate::utils::parser_options::code_span_ranges_without_math(text);
2117 let mut spans = code_spans.iter().copied().peekable();
2118 let mut starts_inside = Vec::with_capacity(line_count);
2119 let mut line_start = 0;
2120 for line in text.split_inclusive('\n') {
2121 while spans.next_if(|&(_, end)| end <= line_start).is_some() {}
2122 starts_inside.push(spans.peek().is_some_and(|&(start, _)| start < line_start));
2123 line_start += line.len();
2124 }
2125 (0..line_count)
2126 .map(|i| starts_inside[i] || starts_inside.get(i + 1).is_some_and(|&b| b))
2127 .collect()
2128}
2129
2130pub(crate) fn lines_ending_in_code_span(lines: &[&str]) -> Vec<bool> {
2136 if !lines.iter().any(|line| line.contains('`')) {
2137 return vec![false; lines.len()];
2138 }
2139 let text = lines.join("\n");
2140 let code_spans = nested_structure(&text, None, false).code_spans;
2141 let mut spans = code_spans.iter().copied().peekable();
2142 let mut line_end = 0;
2143 lines
2144 .iter()
2145 .map(|line| {
2146 line_end += line.len();
2147 while spans.next_if(|&(_, end)| end <= line_end).is_some() {}
2148 let inside = spans.peek().is_some_and(|&(start, _)| start <= line_end);
2149 line_end += 1;
2150 inside
2151 })
2152 .collect()
2153}
2154
2155fn has_block_line_above(lines: &[&str], index: usize) -> bool {
2164 let Some(previous) = index.checked_sub(1).map(|above| lines[above].trim()) else {
2165 return false;
2166 };
2167 !(previous.is_empty()
2168 || previous.starts_with('#')
2169 || previous.starts_with("```")
2170 || previous.starts_with("~~~")
2171 || previous.starts_with(":::")
2172 || is_horizontal_rule(previous))
2173}
2174
2175fn is_block_boundary_core(trimmed: &str) -> bool {
2178 trimmed.is_empty()
2179 || trimmed.starts_with('#')
2180 || trimmed.starts_with("```")
2181 || trimmed.starts_with("~~~")
2182 || trimmed.starts_with('>')
2183 || (trimmed.starts_with('[') && trimmed.contains("]:"))
2184 || is_horizontal_rule(trimmed)
2185 || is_unordered_list_marker(trimmed)
2186 || is_numbered_list_item(trimmed)
2187 || is_definition_list_marker(trimmed)
2188 || trimmed.starts_with(":::")
2189}
2190
2191fn is_block_boundary(trimmed: &str) -> bool {
2194 is_block_boundary_core(trimmed) || trimmed.starts_with('|')
2195}
2196
2197fn is_paragraph_boundary(trimmed: &str, line: &str) -> bool {
2201 is_block_boundary_core(trimmed)
2202 || calculate_indentation_width_default(line) >= 4
2203 || crate::utils::table_utils::TableUtils::is_potential_table_row(line)
2204}
2205
2206fn has_hard_break(line: &str) -> bool {
2212 let line = line.strip_suffix('\r').unwrap_or(line);
2213 line.ends_with(" ") || line.ends_with('\\')
2214}
2215
2216struct SoftBreakJoin {
2218 joined: String,
2219 joins: Vec<usize>,
2221 code_spans: Vec<(usize, usize)>,
2223}
2224
2225impl SoftBreakJoin {
2226 fn new<S: AsRef<str>>(lines: &[S]) -> Self {
2227 let mut joined = String::new();
2228 let mut joins = Vec::with_capacity(lines.len().saturating_sub(1));
2229 for (idx, line) in lines.iter().enumerate() {
2230 let line = line.as_ref();
2231 if idx + 1 == lines.len() {
2232 joined.push_str(line);
2233 } else {
2234 joined.push_str(line.strip_suffix('\r').unwrap_or(line));
2235 joins.push(joined.len());
2236 joined.push(' ');
2237 }
2238 }
2239 let code_spans = if !joins.is_empty() && joined.contains('`') {
2240 nested_structure(&joined, None, false).code_spans
2241 } else {
2242 Vec::new()
2243 };
2244 Self {
2245 joined,
2246 joins,
2247 code_spans,
2248 }
2249 }
2250}
2251
2252pub(crate) fn code_span_runs_into_indentation<S: AsRef<str>>(lines: &[S]) -> bool {
2263 let SoftBreakJoin {
2264 joined,
2265 joins,
2266 code_spans,
2267 } = SoftBreakJoin::new(lines);
2268 joins.iter().enumerate().any(|(k, &join)| {
2269 let next_line = &joined[join + 1..joins.get(k + 1).copied().unwrap_or(joined.len())];
2270 next_line.starts_with([' ', '\t']) && code_spans.iter().any(|&(start, end)| start <= join && join < end)
2271 })
2272}
2273
2274pub(crate) fn code_span_crosses_last_break<S: AsRef<str>>(lines: &[S]) -> bool {
2277 let SoftBreakJoin { joins, code_spans, .. } = SoftBreakJoin::new(lines);
2278 joins
2279 .last()
2280 .is_some_and(|&join| code_spans.iter().any(|&(start, end)| start <= join && join < end))
2281}
2282
2283pub(crate) fn join_soft_break_lines<S: AsRef<str>>(lines: &[S], cjk: CjkSoftBreak) -> String {
2308 join_soft_break_lines_with_sentences(lines, cjk, None, LinkTextPunctuation::Ignore, None)
2309}
2310
2311pub(crate) fn join_sentence_lines<S: AsRef<str>>(
2315 lines: &[S],
2316 cjk: CjkSoftBreak,
2317 abbreviations: &Option<Vec<String>>,
2318 link_text_punctuation: LinkTextPunctuation,
2319 references: Option<&HashSet<String>>,
2320) -> String {
2321 join_soft_break_lines_with_sentences(
2322 lines,
2323 cjk,
2324 Some(abbreviations),
2325 link_text_punctuation,
2326 references.filter(|_| link_text_punctuation == LinkTextPunctuation::Trailing),
2327 )
2328}
2329
2330fn join_reflow_lines<S: AsRef<str>>(lines: &[S], options: &ReflowOptions) -> String {
2331 if options.sentence_per_line {
2332 join_sentence_lines(
2333 lines,
2334 options.cjk_soft_break,
2335 &options.abbreviations,
2336 options.link_text_punctuation,
2337 options.defined_references.as_ref(),
2338 )
2339 } else {
2340 join_soft_break_lines(lines, options.cjk_soft_break)
2341 }
2342}
2343
2344fn join_soft_break_lines_with_sentences<S: AsRef<str>>(
2345 lines: &[S],
2346 cjk: CjkSoftBreak,
2347 sentence_abbreviations: Option<&Option<Vec<String>>>,
2348 link_text_punctuation: LinkTextPunctuation,
2349 references: Option<&HashSet<String>>,
2350) -> String {
2351 let SoftBreakJoin {
2352 mut joined,
2353 joins,
2354 code_spans,
2355 } = SoftBreakJoin::new(lines);
2356 if joins.is_empty() {
2357 return joined;
2358 }
2359 let sentence_ends = sentence_abbreviations.map(|abbreviations| {
2360 for &join in &joins {
2363 joined.replace_range(join..=join, "\n");
2364 }
2365 split_into_sentence_ranges(
2366 &joined,
2367 &get_abbreviations(abbreviations),
2368 true,
2369 cjk,
2370 references,
2371 None,
2372 link_text_punctuation,
2373 )
2374 .into_iter()
2375 .map(|(_, end)| end)
2376 .collect::<HashSet<_>>()
2377 });
2378 let mut trimmed = String::with_capacity(joined.len());
2384 let mut copied = 0;
2385 let mut space_pending = false;
2386 let mut sentence_break_pending = false;
2387 let mut spans = code_spans.iter().copied().peekable();
2388 for (k, &join) in joins.iter().enumerate() {
2389 while spans.next_if(|&(_, end)| end <= join).is_some() {}
2390 let inside_code_span = spans.peek().is_some_and(|&(start, _)| start <= join);
2391 let content_end = if inside_code_span {
2392 join
2393 } else {
2394 joined[..join].trim_end_matches([' ', '\t']).len().max(copied)
2395 };
2396 let wrote_text = content_end > copied;
2397 if wrote_text {
2398 if space_pending {
2399 trimmed.push(if sentence_break_pending { '\n' } else { ' ' });
2400 }
2401 trimmed.push_str(&joined[copied..content_end]);
2402 }
2403 let next_line = &joined[join + 1..joins.get(k + 1).copied().unwrap_or(joined.len())];
2404 let next_content = if inside_code_span {
2405 next_line
2406 } else {
2407 next_line.trim_start_matches([' ', '\t'])
2408 };
2409 copied = join + 1 + (next_line.len() - next_content.len());
2410 let break_removed = !inside_code_span
2411 && cjk == CjkSoftBreak::Join
2412 && joined[..content_end]
2413 .chars()
2414 .next_back()
2415 .is_some_and(joins_cjk_soft_break)
2416 && next_content.chars().next().is_some_and(joins_cjk_soft_break);
2417 space_pending = !break_removed || (space_pending && !wrote_text);
2418 sentence_break_pending = sentence_ends.as_ref().is_some_and(|ends| ends.contains(&content_end));
2419 }
2420 if space_pending {
2421 trimmed.push(if sentence_break_pending { '\n' } else { ' ' });
2422 }
2423 trimmed.push_str(&joined[copied..]);
2424 trimmed
2425}
2426
2427fn ends_with_sentence_punct(text: &str) -> bool {
2429 text.ends_with('.') || text.ends_with('!') || text.ends_with('?')
2430}
2431
2432fn ends_cjk_sentence_with_closer(text: &str) -> bool {
2439 let stripped = text.trim_end_matches(|c| is_closing_bracket(c) || is_closing_quote(c));
2440 stripped.len() < text.len() && stripped.chars().next_back().is_some_and(is_cjk_sentence_ending)
2441}
2442
2443fn trim_preserving_hard_break(s: &str) -> String {
2449 let s = s.strip_suffix('\r').unwrap_or(s);
2451
2452 if s.ends_with('\\') {
2454 return s.to_string();
2456 }
2457
2458 if s.ends_with(" ") {
2460 let content_end = s.trim_end().len();
2462 if content_end == 0 {
2463 return String::new();
2465 }
2466 format!("{} ", &s[..content_end])
2468 } else {
2469 s.trim_end().to_string()
2471 }
2472}
2473
2474fn parse_elements(text: &str, options: &ReflowOptions) -> Vec<Element> {
2476 parse_markdown_elements_inner(
2477 text,
2478 options.attr_lists,
2479 options.myst_roles,
2480 options.defined_references.as_ref(),
2481 )
2482}
2483
2484pub fn reflow_line(line: &str, options: &ReflowOptions) -> Vec<String> {
2494 let reflowed = reflow_line_unchecked(line, options);
2495 if preserves_content(line, &reflowed) {
2496 reflowed
2497 } else {
2498 vec![line.to_string()]
2499 }
2500}
2501
2502fn preserves_content(original: &str, reflowed: &[String]) -> bool {
2509 let (original_text, original_breaks) = visible_text_and_breaks(original.chars());
2510 let (reflowed_text, reflowed_breaks) =
2511 visible_text_and_breaks(reflowed.iter().flat_map(|line| line.chars().chain(['\n'])));
2512
2513 original_text == reflowed_text && contains_all(&reflowed_breaks, &original_breaks)
2514}
2515
2516fn visible_text_and_breaks(text: impl Iterator<Item = char>) -> (String, Vec<usize>) {
2519 let mut visible = String::new();
2520 let mut breaks = Vec::new();
2521 let mut count = 0usize;
2522 let mut pending_break = false;
2523
2524 for c in text {
2525 if c.is_whitespace() {
2526 pending_break = count > 0;
2527 } else {
2528 if pending_break {
2529 breaks.push(count);
2530 pending_break = false;
2531 }
2532 visible.push(c);
2533 count += 1;
2534 }
2535 }
2536
2537 (visible, breaks)
2538}
2539
2540fn contains_all(superset: &[usize], subset: &[usize]) -> bool {
2542 let mut candidates = superset.iter();
2543 subset
2544 .iter()
2545 .all(|wanted| candidates.by_ref().any(|found| found == wanted))
2546}
2547
2548fn reflow_line_unchecked(line: &str, options: &ReflowOptions) -> Vec<String> {
2549 let lines = keep_first_line_out_of_backtick_fence(line, reflow_lines(line, options));
2550 keep_first_line_out_of_html_block(lines)
2551}
2552
2553fn keep_first_line_out_of_backtick_fence(source: &str, mut lines: Vec<String>) -> Vec<String> {
2558 let first_source = source.lines().next().unwrap_or("").trim_start_matches([' ', '\t']);
2559 let ticks = first_source.bytes().take_while(|&b| b == b'`').count();
2560 if ticks < 3 || !first_source[ticks..].contains('`') {
2561 return lines;
2562 }
2563 while lines.len() > 1 {
2564 let first = lines[0].trim_start_matches([' ', '\t']);
2565 let ticks = first.bytes().take_while(|&b| b == b'`').count();
2566 if ticks < 3 || first[ticks..].contains('`') {
2567 break;
2568 }
2569 let next = lines.remove(1);
2570 lines[0].push(' ');
2571 lines[0].push_str(next.trim_start_matches([' ', '\t']));
2572 }
2573 lines
2574}
2575
2576fn reflow_lines(line: &str, options: &ReflowOptions) -> Vec<String> {
2577 if options.sentence_pack {
2578 let elements = parse_elements(line, options);
2579 return reflow_elements_sentence_pack(&elements, options);
2580 }
2581
2582 if options.sentence_per_line {
2584 let elements = parse_elements(line, options);
2585 return merge_block_construct_continuations(reflow_elements_sentence_per_line(&elements, options));
2586 }
2587
2588 if options.semantic_line_breaks {
2590 let elements = parse_elements(line, options);
2591 return merge_block_construct_continuations(reflow_elements_semantic(&elements, options));
2592 }
2593
2594 if options.line_length == 0 || line_fits(line, options) {
2597 return vec![line.to_string()];
2598 }
2599
2600 let elements = parse_elements(line, options);
2602
2603 merge_block_construct_continuations(reflow_elements(&elements, options))
2605}
2606
2607#[derive(Debug, Clone)]
2609enum Element {
2610 Text(String),
2612 Link(String),
2614 ReferenceLink(String),
2616 EmptyReferenceLink(String),
2618 ShortcutReference(String),
2620 InlineImage(String),
2622 ReferenceImage(String),
2624 EmptyReferenceImage(String),
2626 LinkedImage(String),
2628 FootnoteReference(String),
2630 Strikethrough {
2632 content: String,
2633 double: bool,
2635 },
2636 WikiLink(String),
2638 InlineMath(String),
2640 DisplayMath(String),
2642 EmojiShortcode(String),
2644 Autolink(String),
2646 HtmlTag(String),
2648 HtmlEntity(String),
2650 HugoShortcode(String),
2652 AttrList(String),
2654 MystRole(String),
2658 Code { content: String, marker: String },
2660 Bold {
2662 content: String,
2663 underscore: bool,
2665 },
2666 Italic {
2668 content: String,
2669 underscore: bool,
2671 },
2672}
2673
2674impl Element {
2675 fn opens_with_bracket(&self) -> bool {
2680 matches!(
2681 self,
2682 Element::Link(_)
2683 | Element::ReferenceLink(_)
2684 | Element::EmptyReferenceLink(_)
2685 | Element::ShortcutReference(_)
2686 | Element::FootnoteReference(_)
2687 | Element::InlineImage(_)
2688 | Element::ReferenceImage(_)
2689 | Element::EmptyReferenceImage(_)
2690 | Element::LinkedImage(_)
2691 | Element::WikiLink(_)
2692 )
2693 }
2694}
2695
2696impl std::fmt::Display for Element {
2697 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2698 match self {
2699 Element::Text(s) => write!(f, "{s}"),
2700 Element::Link(s) => write!(f, "{s}"),
2701 Element::ReferenceLink(s) => write!(f, "{s}"),
2702 Element::EmptyReferenceLink(s) => write!(f, "{s}"),
2703 Element::ShortcutReference(s) => write!(f, "{s}"),
2704 Element::InlineImage(s) => write!(f, "{s}"),
2705 Element::ReferenceImage(s) => write!(f, "{s}"),
2706 Element::EmptyReferenceImage(s) => write!(f, "{s}"),
2707 Element::LinkedImage(s) => write!(f, "{s}"),
2708 Element::FootnoteReference(s) => write!(f, "{s}"),
2709 Element::Strikethrough { content, double } => {
2710 let marker = if *double { "~~" } else { "~" };
2711 write!(f, "{marker}{content}{marker}")
2712 }
2713 Element::WikiLink(s) => write!(f, "[[{s}]]"),
2714 Element::InlineMath(s) => write!(f, "${s}$"),
2715 Element::DisplayMath(s) => write!(f, "$${s}$$"),
2716 Element::EmojiShortcode(s) => write!(f, ":{s}:"),
2717 Element::Autolink(s) => write!(f, "{s}"),
2718 Element::HtmlTag(s) => write!(f, "{s}"),
2719 Element::HtmlEntity(s) => write!(f, "{s}"),
2720 Element::HugoShortcode(s) => write!(f, "{s}"),
2721 Element::AttrList(s) => write!(f, "{s}"),
2722 Element::MystRole(s) => write!(f, "{s}"),
2723 Element::Code { content, marker } => write!(f, "{marker}{content}{marker}"),
2724 Element::Bold { content, underscore } => {
2725 if *underscore {
2726 write!(f, "__{content}__")
2727 } else {
2728 write!(f, "**{content}**")
2729 }
2730 }
2731 Element::Italic { content, underscore } => {
2732 if *underscore {
2733 write!(f, "_{content}_")
2734 } else {
2735 write!(f, "*{content}*")
2736 }
2737 }
2738 }
2739 }
2740}
2741
2742impl Element {
2743 fn display_len(&self, mode: ReflowLengthMode) -> usize {
2744 match self {
2745 Element::Text(s)
2746 | Element::Link(s)
2747 | Element::ReferenceLink(s)
2748 | Element::EmptyReferenceLink(s)
2749 | Element::ShortcutReference(s)
2750 | Element::InlineImage(s)
2751 | Element::ReferenceImage(s)
2752 | Element::EmptyReferenceImage(s)
2753 | Element::LinkedImage(s)
2754 | Element::FootnoteReference(s)
2755 | Element::Autolink(s)
2756 | Element::HtmlTag(s)
2757 | Element::HtmlEntity(s)
2758 | Element::HugoShortcode(s)
2759 | Element::AttrList(s)
2760 | Element::MystRole(s) => display_len(s, mode),
2761 Element::WikiLink(s) => display_len(s, mode) + 4,
2762 Element::InlineMath(s) => display_len(s, mode) + 2,
2763 Element::DisplayMath(s) => display_len(s, mode) + 4,
2764 Element::EmojiShortcode(s) => display_len(s, mode) + 2,
2765 Element::Strikethrough { content, double } => display_len(content, mode) + if *double { 4 } else { 2 },
2766 Element::Code { content, marker } => display_len(content, mode) + display_len(marker, mode) * 2,
2767 Element::Bold { content, .. } => display_len(content, mode) + 4,
2768 Element::Italic { content, .. } => display_len(content, mode) + 2,
2769 }
2770 }
2771
2772 fn exempt_width(&self, mode: ReflowLengthMode, exemptions: LengthExemptions) -> LineWidth {
2783 let full = self.display_len(mode);
2784 let mut width = LineWidth::plain(full);
2785 match self {
2786 Element::Link(s) | Element::LinkedImage(s) if exemptions.link_urls => {
2787 if let Some(text) = bracketed_text(s, 0) {
2788 width.link_exempt = (2 + display_len(text, mode)).min(full);
2789 }
2790 }
2791 Element::InlineImage(s) if exemptions.link_urls => {
2792 if let Some(alt) = bracketed_text(s, 1) {
2793 width.link_exempt = (3 + display_len(alt, mode)).min(full);
2794 }
2795 }
2796 Element::Code { .. } if exemptions.code_spans => width.code_exempt = 0,
2797 _ => {}
2798 }
2799 width
2800 }
2801}
2802
2803pub(crate) fn bracketed_text(s: &str, open: usize) -> Option<&str> {
2810 let bytes = s.as_bytes();
2811 if bytes.get(open) != Some(&b'[') {
2812 return None;
2813 }
2814 let mut depth = 0usize;
2815 let mut in_code_span = false;
2816 let mut escaped = false;
2817 for (i, &byte) in bytes.iter().enumerate().skip(open + 1) {
2818 if escaped {
2819 escaped = false;
2820 continue;
2821 }
2822 match byte {
2823 b'\\' => escaped = true,
2824 b'`' => in_code_span = !in_code_span,
2825 b'[' if !in_code_span => depth += 1,
2826 b']' if !in_code_span => match depth.checked_sub(1) {
2827 Some(next) => depth = next,
2828 None => return s.get(open + 1..i),
2829 },
2830 _ => {}
2831 }
2832 }
2833 None
2834}
2835
2836#[derive(Debug, Clone)]
2838struct EmphasisSpan {
2839 start: usize,
2841 end: usize,
2843 content: String,
2845 is_strong: bool,
2847 is_strikethrough: bool,
2849 uses_underscore: bool,
2851 strikethrough_double: bool,
2854}
2855
2856fn extract_emphasis_and_code_spans(text: &str) -> (Vec<EmphasisSpan>, Vec<CodeSpan>) {
2866 let has_emphasis = text.contains(['*', '_', '~']);
2868 let has_code = text.contains('`');
2869 if !has_emphasis && !has_code {
2870 return (Vec::new(), Vec::new());
2871 }
2872
2873 let mut emphasis_spans = Vec::new();
2874 let mut code_spans = Vec::new();
2875
2876 let mut options = Options::empty();
2877 if has_emphasis {
2878 options.insert(Options::ENABLE_STRIKETHROUGH);
2879 }
2880
2881 let mut emphasis_stack: Vec<(usize, bool)> = Vec::new(); let mut strong_stack: Vec<(usize, bool)> = Vec::new();
2884 let mut strikethrough_stack: Vec<usize> = Vec::new();
2885
2886 let parser = Parser::new_ext(text, options).into_offset_iter();
2887
2888 for (event, range) in parser {
2889 match event {
2890 Event::Code(_) => {
2891 code_spans.push(CodeSpan {
2892 start: range.start,
2893 end: range.end,
2894 });
2895 }
2896 Event::Start(Tag::Emphasis) => {
2897 let uses_underscore = text.get(range.start..range.start + 1) == Some("_");
2899 emphasis_stack.push((range.start, uses_underscore));
2900 }
2901 Event::End(TagEnd::Emphasis) => {
2902 if let Some((start_byte, uses_underscore)) = emphasis_stack.pop() {
2903 let content_start = start_byte + 1;
2904 let content_end = range.end - 1;
2905 if content_end > content_start
2906 && let Some(content) = text.get(content_start..content_end)
2907 {
2908 emphasis_spans.push(EmphasisSpan {
2909 start: start_byte,
2910 end: range.end,
2911 content: content.to_string(),
2912 is_strong: false,
2913 is_strikethrough: false,
2914 uses_underscore,
2915 strikethrough_double: false,
2916 });
2917 }
2918 }
2919 }
2920 Event::Start(Tag::Strong) => {
2921 let uses_underscore = text.get(range.start..range.start + 2) == Some("__");
2922 strong_stack.push((range.start, uses_underscore));
2923 }
2924 Event::End(TagEnd::Strong) => {
2925 if let Some((start_byte, uses_underscore)) = strong_stack.pop() {
2926 let content_start = start_byte + 2;
2927 let content_end = range.end - 2;
2928 if content_end > content_start
2929 && let Some(content) = text.get(content_start..content_end)
2930 {
2931 emphasis_spans.push(EmphasisSpan {
2932 start: start_byte,
2933 end: range.end,
2934 content: content.to_string(),
2935 is_strong: true,
2936 is_strikethrough: false,
2937 uses_underscore,
2938 strikethrough_double: false,
2939 });
2940 }
2941 }
2942 }
2943 Event::Start(Tag::Strikethrough) => {
2944 strikethrough_stack.push(range.start);
2945 }
2946 Event::End(TagEnd::Strikethrough) => {
2947 if let Some(start_byte) = strikethrough_stack.pop() {
2948 let double = text.get(start_byte..start_byte + 2) == Some("~~");
2949 let marker_len = if double { 2 } else { 1 };
2950 let content_start = start_byte + marker_len;
2951 let content_end = range.end - marker_len;
2952 if content_end > content_start
2953 && let Some(content) = text.get(content_start..content_end)
2954 {
2955 emphasis_spans.push(EmphasisSpan {
2956 start: start_byte,
2957 end: range.end,
2958 content: content.to_string(),
2959 is_strong: false,
2960 is_strikethrough: true,
2961 uses_underscore: false,
2962 strikethrough_double: double,
2963 });
2964 }
2965 }
2966 }
2967 _ => {}
2968 }
2969 }
2970
2971 emphasis_spans.sort_by_key(|s| s.start);
2972 (emphasis_spans, code_spans)
2973}
2974
2975#[derive(Debug, Clone)]
2976struct CodeSpan {
2977 start: usize,
2978 end: usize,
2979}
2980
2981#[derive(Debug, Clone)]
2982struct LinkSpan {
2983 start: usize,
2984 end: usize,
2985 link_type: Option<LinkType>,
2986 is_image: bool,
2987 is_footnote: bool,
2988 depth: usize,
2991}
2992
2993fn extract_link_spans(text: &str, defined_references: Option<&HashSet<String>>) -> Vec<LinkSpan> {
2997 let mut spans = all_link_spans(text, defined_references);
2998 spans.retain(|span| span.depth == 0);
2999 spans
3000}
3001
3002fn all_link_spans(text: &str, defined_references: Option<&HashSet<String>>) -> Vec<LinkSpan> {
3005 if !text.contains('[') {
3008 return Vec::new();
3009 }
3010
3011 let mut spans = Vec::new();
3012 let mut options = Options::empty();
3013 options.insert(Options::ENABLE_FOOTNOTES);
3014
3015 let resolve = move |link: BrokenLink<'_>| -> Option<(CowStr<'_>, CowStr<'_>)> {
3032 let atomic = match link.link_type {
3037 LinkType::Shortcut | LinkType::ShortcutUnknown => match defined_references {
3038 Some(defs) => defs.contains(&normalize_reference_label(link.reference.as_ref())),
3039 None => true,
3040 },
3041 _ => true,
3042 };
3043 atomic.then_some((CowStr::Borrowed(""), CowStr::Borrowed("")))
3044 };
3045 let parser = Parser::new_with_broken_link_callback(text, options, Some(resolve)).into_offset_iter();
3046 let mut stack = Vec::new();
3047
3048 for (event, range) in parser {
3049 match event {
3050 Event::Start(Tag::Link { link_type, .. }) => {
3051 stack.push((range.start, Some(link_type), false));
3052 }
3053 Event::Start(Tag::Image { link_type, .. }) => {
3054 stack.push((range.start, Some(link_type), true));
3055 }
3056 Event::End(TagEnd::Link | TagEnd::Image) => {
3057 if let Some((start_byte, link_type, is_image)) = stack.pop() {
3058 let mut end = range.end;
3059 if matches!(link_type, Some(LinkType::Collapsed) | Some(LinkType::CollapsedUnknown))
3060 && text[end..].starts_with("[]")
3061 {
3062 end += 2;
3063 }
3064 spans.push(LinkSpan {
3065 start: start_byte,
3066 end,
3067 link_type,
3068 is_image,
3069 is_footnote: false,
3070 depth: stack.len(),
3071 });
3072 }
3073 }
3074 Event::FootnoteReference(_) => {
3075 spans.push(LinkSpan {
3076 start: range.start,
3077 end: range.end,
3078 link_type: None,
3079 is_image: false,
3080 is_footnote: true,
3081 depth: stack.len(),
3082 });
3083 }
3084 _ => {}
3085 }
3086 }
3087
3088 spans.sort_by_key(|s| s.start);
3089 spans
3090}
3091
3092fn myst_role_len_at(text: &str, absolute_pos: usize, code_spans: &[CodeSpan]) -> Option<usize> {
3100 let bytes = text.as_bytes();
3101 if bytes.first() != Some(&b'{') {
3102 return None;
3103 }
3104
3105 let mut j = 1;
3107 match bytes.get(j) {
3108 Some(&b) if b.is_ascii_alphabetic() || b == b'_' => {}
3109 _ => return None,
3110 }
3111 while let Some(&b) = bytes.get(j) {
3112 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b':' | b'.') {
3113 j += 1;
3114 } else {
3115 break;
3116 }
3117 }
3118 if bytes.get(j) != Some(&b'}') {
3119 return None;
3120 }
3121 j += 1; let code_span_start = absolute_pos + j;
3125 if let Ok(idx) = code_spans.binary_search_by_key(&code_span_start, |span| span.start) {
3126 let span = &code_spans[idx];
3127 let code_span_len = span.end - span.start;
3128 return Some(j + code_span_len);
3129 }
3130
3131 None
3132}
3133
3134fn inline_math_len_at_start(s: &str) -> Option<usize> {
3141 let bytes = s.as_bytes();
3142 if bytes.first() != Some(&b'$') || bytes.get(1) == Some(&b'$') {
3144 return None;
3145 }
3146 let close = 1 + s[1..].find('$')?;
3149 if bytes.get(close + 1) == Some(&b'$') {
3151 return None;
3152 }
3153 Some(close + 1)
3154}
3155
3156#[derive(Clone, Copy, Debug)]
3158struct PatternMatch {
3159 start: usize,
3160 end: usize,
3161}
3162
3163fn inline_html_comment(text: &str) -> Option<(usize, usize)> {
3169 let start = text.find("<!--")?;
3170 let body = &text[start + 4..];
3171 let end = if body.starts_with('>') {
3172 start + 5
3173 } else if body.starts_with("->") {
3174 start + 6
3175 } else {
3176 start + 4 + body.find("-->")? + 3
3177 };
3178 Some((start, end))
3179}
3180
3181#[derive(Clone, Copy)]
3195enum PatternCache {
3196 Unsearched,
3197 NotFound,
3198 Found(PatternMatch),
3199}
3200
3201impl PatternCache {
3202 fn earliest_in(
3206 &mut self,
3207 remaining: &str,
3208 cursor: usize,
3209 find: impl FnOnce(&str) -> Option<(usize, usize)>,
3210 ) -> Option<(usize, usize)> {
3211 let stale = match self {
3212 PatternCache::Found(pm) => pm.start < cursor,
3213 PatternCache::NotFound => false,
3214 PatternCache::Unsearched => true,
3215 };
3216 if stale {
3217 *self = match find(remaining) {
3218 Some((start, end)) => PatternCache::Found(PatternMatch {
3219 start: cursor + start,
3220 end: cursor + end,
3221 }),
3222 None => PatternCache::NotFound,
3223 };
3224 }
3225 match self {
3226 PatternCache::Found(pm) => Some((pm.start - cursor, pm.end - cursor)),
3227 _ => None,
3228 }
3229 }
3230}
3231
3232fn parse_markdown_elements_inner(
3243 text: &str,
3244 attr_lists: bool,
3245 myst_roles: bool,
3246 defined_references: Option<&HashSet<String>>,
3247) -> Vec<Element> {
3248 let mut elements = Vec::new();
3249 let mut remaining = text;
3250
3251 let (emphasis_spans, code_spans) = extract_emphasis_and_code_spans(text);
3256 let link_spans = extract_link_spans(text, defined_references);
3257
3258 let mut cached_wiki_link = PatternCache::Unsearched;
3261 let mut cached_display_math = PatternCache::Unsearched;
3262 let mut cached_inline_math = PatternCache::Unsearched;
3263 let mut cached_emoji = PatternCache::Unsearched;
3264 let mut cached_html_entity = PatternCache::Unsearched;
3265 let mut cached_hugo_shortcode = PatternCache::Unsearched;
3266 let mut cached_html_tag = PatternCache::Unsearched;
3267 let mut cached_next_curly = PatternCache::Unsearched;
3268
3269 let mut link_span_idx = 0usize;
3273 let mut emphasis_span_idx = 0usize;
3274 let mut code_span_idx = 0usize;
3275
3276 while !remaining.is_empty() {
3277 let current_offset = text.len() - remaining.len();
3279 let mut earliest_match: Option<(usize, usize, &str)> = None;
3282
3283 while link_span_idx < link_spans.len() && link_spans[link_span_idx].start < current_offset {
3285 link_span_idx += 1;
3286 }
3287 let next_link: Option<&LinkSpan> = link_spans.get(link_span_idx);
3288
3289 if let Some(span) = next_link {
3290 let pos_in_remaining = span.start - current_offset;
3291 if earliest_match
3292 .as_ref()
3293 .is_none_or(|(start, _, _)| pos_in_remaining < *start)
3294 {
3295 let match_end = span.end - current_offset;
3296 earliest_match = Some((pos_in_remaining, match_end, "link_span"));
3297 }
3298 }
3299
3300 if let Some((start, end)) = cached_wiki_link.earliest_in(remaining, current_offset, |suffix| {
3302 WIKI_LINK_REGEX.find(suffix).map(|m| (m.start(), m.end()))
3303 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
3304 {
3305 earliest_match = Some((start, end, "wiki_link"));
3306 }
3307
3308 if let Some((start, end)) = cached_display_math.earliest_in(remaining, current_offset, |suffix| {
3310 DISPLAY_MATH_REGEX.find(suffix).map(|m| (m.start(), m.end()))
3311 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
3312 {
3313 earliest_match = Some((start, end, "display_math"));
3314 }
3315
3316 let inline_math_probe = if current_offset > 0 && text.as_bytes()[current_offset - 1] == b'$' {
3330 inline_math_len_at_start(remaining).map(|len| (0, len))
3331 } else {
3332 None
3333 };
3334 if let Some((start, end)) = inline_math_probe.or_else(|| {
3335 cached_inline_math.earliest_in(remaining, current_offset, |suffix| {
3336 INLINE_MATH_REGEX
3337 .find(suffix)
3338 .ok()
3339 .flatten()
3340 .map(|m| (m.start(), m.end()))
3341 })
3342 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
3343 {
3344 earliest_match = Some((start, end, "inline_math"));
3345 }
3346
3347 if let Some((start, end)) = cached_emoji.earliest_in(remaining, current_offset, |suffix| {
3349 EMOJI_SHORTCODE_REGEX.find(suffix).map(|m| (m.start(), m.end()))
3350 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
3351 {
3352 earliest_match = Some((start, end, "emoji"));
3353 }
3354
3355 if let Some((start, end)) = cached_html_entity.earliest_in(remaining, current_offset, |suffix| {
3357 HTML_ENTITY_REGEX.find(suffix).map(|m| (m.start(), m.end()))
3358 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
3359 {
3360 earliest_match = Some((start, end, "html_entity"));
3361 }
3362
3363 if let Some((start, end)) = cached_hugo_shortcode.earliest_in(remaining, current_offset, |suffix| {
3366 crate::utils::shortcode_utils::shortcode_ranges(suffix).next()
3367 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
3368 {
3369 earliest_match = Some((start, end, "hugo_shortcode"));
3370 }
3371
3372 if let Some((start, end)) = cached_html_tag.earliest_in(remaining, current_offset, |suffix| {
3380 let comment = inline_html_comment(suffix);
3381 let mut from = 0;
3382 while let Some(m) = HTML_TAG_PATTERN.find(&suffix[from..]) {
3383 let (tag_start, tag_end) = (from + m.start(), from + m.end());
3384 let tag = &suffix[tag_start..tag_end];
3385 let is_url_autolink = tag.starts_with("<http://")
3387 || tag.starts_with("<https://")
3388 || tag.starts_with("<mailto:")
3389 || tag.starts_with("<ftp://")
3390 || tag.starts_with("<ftps://");
3391 let is_email_autolink = {
3394 let content = tag.trim_start_matches('<').trim_end_matches('>');
3395 EMAIL_PATTERN.is_match(content)
3396 };
3397 if is_url_autolink || is_email_autolink {
3398 from = tag_end;
3399 } else {
3400 return match comment {
3401 Some(comment) if comment.0 < tag_start => Some(comment),
3402 _ => Some((tag_start, tag_end)),
3403 };
3404 }
3405 }
3406 comment
3407 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
3408 {
3409 earliest_match = Some((start, end, "html_tag"));
3410 }
3411
3412 let mut next_special = remaining.len();
3414 let mut special_type = "";
3415 let mut pulldown_emphasis: Option<&EmphasisSpan> = None;
3416 let mut attr_list_len: usize = 0;
3417 let mut myst_role_len: usize = 0;
3418
3419 while code_span_idx < code_spans.len() && code_spans[code_span_idx].start < current_offset {
3421 code_span_idx += 1;
3422 }
3423 let next_code_span: Option<&CodeSpan> = code_spans.get(code_span_idx);
3424 if let Some(span) = next_code_span {
3425 let pos_in_remaining = span.start - current_offset;
3426 if pos_in_remaining < next_special {
3427 next_special = pos_in_remaining;
3428 special_type = "pulldown_code";
3429 }
3430 }
3431
3432 let next_curly_pos = cached_next_curly
3435 .earliest_in(remaining, current_offset, |suffix| {
3436 suffix.find('{').map(|pos| (pos, pos + 1))
3437 })
3438 .map(|(start, _)| start);
3439
3440 if myst_roles
3445 && let Some(pos) = next_curly_pos
3446 && pos < next_special
3447 && let Some(role_len) = myst_role_len_at(&remaining[pos..], current_offset + pos, &code_spans)
3448 {
3449 next_special = pos;
3450 special_type = "myst_role";
3451 myst_role_len = role_len;
3452 }
3453
3454 if attr_lists
3456 && let Some(pos) = next_curly_pos
3457 && pos < next_special
3458 && let Some(m) = ATTR_LIST_PATTERN.find(&remaining[pos..])
3459 && m.start() == 0
3460 {
3461 next_special = pos;
3462 special_type = "attr_list";
3463 attr_list_len = m.end();
3464 }
3465
3466 while emphasis_span_idx < emphasis_spans.len() && emphasis_spans[emphasis_span_idx].start < current_offset {
3468 emphasis_span_idx += 1;
3469 }
3470 if let Some(span) = emphasis_spans.get(emphasis_span_idx) {
3471 let pos_in_remaining = span.start - current_offset;
3472 if pos_in_remaining < next_special {
3473 next_special = pos_in_remaining;
3474 special_type = "pulldown_emphasis";
3475 pulldown_emphasis = Some(span);
3476 }
3477 }
3478
3479 let should_process_markdown_link = if let Some((pos, _, _)) = earliest_match {
3481 pos < next_special
3482 } else {
3483 false
3484 };
3485
3486 if should_process_markdown_link {
3487 let (pos, match_end, pattern_type) = earliest_match.unwrap();
3488
3489 if pos > 0 {
3491 elements.push(Element::Text(remaining[..pos].to_string()));
3492 }
3493
3494 match pattern_type {
3496 "link_span" => {
3497 let span = next_link.unwrap();
3498 let raw_text = remaining[pos..match_end].to_string();
3499 if span.is_footnote {
3500 elements.push(Element::FootnoteReference(raw_text));
3501 } else if span.is_image {
3502 match span.link_type {
3503 Some(LinkType::Inline) => elements.push(Element::InlineImage(raw_text)),
3504 Some(LinkType::Reference)
3507 | Some(LinkType::ReferenceUnknown)
3508 | Some(LinkType::Shortcut)
3509 | Some(LinkType::ShortcutUnknown) => elements.push(Element::ReferenceImage(raw_text)),
3510 Some(LinkType::Collapsed) | Some(LinkType::CollapsedUnknown) => {
3511 elements.push(Element::EmptyReferenceImage(raw_text))
3512 }
3513 _ => elements.push(Element::InlineImage(raw_text)),
3514 }
3515 } else {
3516 match span.link_type {
3517 Some(LinkType::Inline) => {
3518 if raw_text.starts_with('[') && raw_text.contains("![") {
3519 elements.push(Element::LinkedImage(raw_text));
3520 } else {
3521 elements.push(Element::Link(raw_text));
3522 }
3523 }
3524 Some(LinkType::Reference) | Some(LinkType::ReferenceUnknown) => {
3527 elements.push(Element::ReferenceLink(raw_text))
3528 }
3529 Some(LinkType::Collapsed) | Some(LinkType::CollapsedUnknown) => {
3530 elements.push(Element::EmptyReferenceLink(raw_text))
3531 }
3532 Some(LinkType::Shortcut) | Some(LinkType::ShortcutUnknown) => {
3533 elements.push(Element::ShortcutReference(raw_text))
3534 }
3535 Some(LinkType::Autolink) | Some(LinkType::Email) => {
3536 elements.push(Element::Autolink(raw_text))
3537 }
3538 _ => elements.push(Element::Link(raw_text)),
3539 }
3540 }
3541 remaining = &remaining[match_end..];
3542 }
3543 "wiki_link" => {
3544 if let Some(caps) = WIKI_LINK_REGEX.captures(remaining) {
3545 let content = caps.get(1).map_or("", |m| m.as_str());
3546 elements.push(Element::WikiLink(content.to_string()));
3547 remaining = &remaining[match_end..];
3548 } else {
3549 elements.push(Element::Text("[[".to_string()));
3550 remaining = &remaining[2..];
3551 }
3552 }
3553 "display_math" => {
3554 if let Some(caps) = DISPLAY_MATH_REGEX.captures(remaining) {
3555 let math = caps.get(1).map_or("", |m| m.as_str());
3556 elements.push(Element::DisplayMath(math.to_string()));
3557 remaining = &remaining[match_end..];
3558 } else {
3559 elements.push(Element::Text("$$".to_string()));
3560 remaining = &remaining[2..];
3561 }
3562 }
3563 "inline_math" => {
3564 if let Ok(Some(caps)) = INLINE_MATH_REGEX.captures(remaining) {
3565 let math = caps.get(1).map_or("", |m| m.as_str());
3566 elements.push(Element::InlineMath(math.to_string()));
3567 remaining = &remaining[match_end..];
3568 } else {
3569 elements.push(Element::Text("$".to_string()));
3570 remaining = &remaining[1..];
3571 }
3572 }
3573 "emoji" => {
3574 if let Some(caps) = EMOJI_SHORTCODE_REGEX.captures(remaining) {
3575 let emoji = caps.get(1).map_or("", |m| m.as_str());
3576 elements.push(Element::EmojiShortcode(emoji.to_string()));
3577 remaining = &remaining[match_end..];
3578 } else {
3579 elements.push(Element::Text(":".to_string()));
3580 remaining = &remaining[1..];
3581 }
3582 }
3583 "html_entity" => {
3584 elements.push(Element::HtmlEntity(remaining[pos..match_end].to_string()));
3586 remaining = &remaining[match_end..];
3587 }
3588 "hugo_shortcode" => {
3589 elements.push(Element::HugoShortcode(remaining[pos..match_end].to_string()));
3591 remaining = &remaining[match_end..];
3592 }
3593 "html_tag" => {
3594 elements.push(Element::HtmlTag(remaining[pos..match_end].to_string()));
3596 remaining = &remaining[match_end..];
3597 }
3598 _ => unreachable!("unknown pattern type: {}", pattern_type),
3599 }
3600 } else {
3601 if next_special > 0 && next_special < remaining.len() {
3605 elements.push(Element::Text(remaining[..next_special].to_string()));
3606 remaining = &remaining[next_special..];
3607 }
3608
3609 match special_type {
3611 "pulldown_code" => {
3612 let span = next_code_span.unwrap();
3613 let span_len = span.end - span.start;
3614 let code_raw = &remaining[..span_len];
3615 if let Some((content, marker)) = decompose_code_span(code_raw) {
3616 elements.push(Element::Code {
3617 content: content.to_string(),
3618 marker: marker.to_string(),
3619 });
3620 } else {
3621 elements.push(Element::Text(code_raw.to_string()));
3622 }
3623 remaining = &remaining[span_len..];
3624 }
3625 "attr_list" => {
3626 elements.push(Element::AttrList(remaining[..attr_list_len].to_string()));
3627 remaining = &remaining[attr_list_len..];
3628 }
3629 "myst_role" => {
3630 elements.push(Element::MystRole(remaining[..myst_role_len].to_string()));
3631 remaining = &remaining[myst_role_len..];
3632 }
3633 "pulldown_emphasis" => {
3634 let span = pulldown_emphasis.expect("pulldown_emphasis must be set");
3636 let span_len = span.end - span.start;
3637 if span.is_strikethrough {
3638 elements.push(Element::Strikethrough {
3639 content: span.content.clone(),
3640 double: span.strikethrough_double,
3641 });
3642 } else if span.is_strong {
3643 elements.push(Element::Bold {
3644 content: span.content.clone(),
3645 underscore: span.uses_underscore,
3646 });
3647 } else {
3648 elements.push(Element::Italic {
3649 content: span.content.clone(),
3650 underscore: span.uses_underscore,
3651 });
3652 }
3653 remaining = &remaining[span_len..];
3654 }
3655 _ => {
3656 elements.push(Element::Text(remaining.to_string()));
3658 break;
3659 }
3660 }
3661 }
3662 }
3663
3664 let mut merged_elements = Vec::new();
3666 for el in elements {
3667 match el {
3668 Element::Text(s) => {
3669 if let Some(Element::Text(last_s)) = merged_elements.last_mut() {
3670 last_s.push_str(&s);
3671 } else {
3672 merged_elements.push(Element::Text(s));
3673 }
3674 }
3675 other => merged_elements.push(other),
3676 }
3677 }
3678 merged_elements
3679}
3680
3681fn source_gap_before(elements: &[Element], idx: usize) -> &str {
3696 let Some(Element::Text(previous)) = idx.checked_sub(1).map(|prev| &elements[prev]) else {
3697 return "";
3698 };
3699
3700 let gap = &previous[previous.trim_end_matches(char::is_whitespace).len()..];
3701 if gap.is_empty() {
3702 ""
3703 } else if gap.contains(is_significant_whitespace) {
3704 gap
3705 } else {
3706 " "
3707 }
3708}
3709
3710fn push_source_gap(current_line: &mut String, gap: &str) {
3713 if !gap.is_empty() && !current_line.is_empty() && !current_line.ends_with(char::is_whitespace) {
3714 current_line.push_str(gap);
3715 }
3716}
3717
3718fn is_setext_or_thematic(text: &str) -> bool {
3724 let mut marker = 0u8;
3725 let mut count = 0usize;
3726 let mut has_space = false;
3727 for &b in text.as_bytes() {
3728 match b {
3729 b' ' | b'\t' => has_space = true,
3730 b'-' | b'=' | b'*' | b'_' => {
3731 if marker == 0 {
3732 marker = b;
3733 } else if b != marker {
3734 return false;
3735 }
3736 count += 1;
3737 }
3738 _ => return false,
3739 }
3740 }
3741 match marker {
3742 b'=' => !has_space,
3743 b'-' => !has_space || count >= 3,
3744 b'*' | b'_' => count >= 3,
3745 _ => false,
3746 }
3747}
3748
3749pub(crate) fn is_table_delimiter_row(text: &str) -> bool {
3756 let text = text.trim_matches([' ', '\t']);
3757 if !text.contains(['|', ':']) {
3758 return false;
3759 }
3760 let text = text.strip_prefix('|').unwrap_or(text);
3761 let text = text.strip_suffix('|').unwrap_or(text);
3762 text.split('|').all(|cell| {
3763 let cell = cell.trim_matches([' ', '\t']);
3764 let cell = cell.strip_prefix(':').unwrap_or(cell);
3765 let cell = cell.strip_suffix(':').unwrap_or(cell);
3766 !cell.is_empty() && cell.bytes().all(|b| b == b'-')
3767 })
3768}
3769
3770fn starts_block_construct(text: &str) -> bool {
3786 let text = text.trim_start();
3787 let bytes = text.as_bytes();
3788 let Some(&first) = bytes.first() else {
3789 return false;
3790 };
3791 let marker_then_boundary = |len: usize| bytes.len() == len || bytes[len] == b' ' || bytes[len] == b'\t';
3792 match first {
3793 b'>' => true,
3795 b'-' => marker_then_boundary(1) || is_setext_or_thematic(text) || is_table_delimiter_row(text),
3796 b'*' | b'+' => marker_then_boundary(1) || is_setext_or_thematic(text),
3797 b'_' | b'=' => is_setext_or_thematic(text),
3798 b':' => true,
3800 b'|' => true,
3801 b'#' => {
3802 let hashes = bytes.iter().take_while(|&&b| b == b'#').count();
3807 hashes <= 6
3808 }
3809 b'`' => bytes.iter().take_while(|&&b| b == b'`').count() >= 3,
3810 b'~' => bytes.iter().take_while(|&&b| b == b'~').count() >= 3,
3811 b'0'..=b'9' => {
3818 let digits = bytes.iter().take_while(|b| b.is_ascii_digit()).count();
3819 digits <= 9
3820 && text[..digits].trim_start_matches('0') == "1"
3821 && bytes.len() > digits + 1
3822 && (bytes[digits] == b'.' || bytes[digits] == b')')
3823 && (bytes[digits + 1] == b' ' || bytes[digits + 1] == b'\t')
3824 }
3825 b'[' => {
3833 let mut escaped = false;
3834 let mut label_close = None;
3835 for (i, &b) in bytes.iter().enumerate().skip(1) {
3836 if escaped {
3837 escaped = false;
3838 } else if b == b'\\' {
3839 escaped = true;
3840 } else if b == b']' {
3841 label_close = Some(i);
3842 break;
3843 }
3844 }
3845 label_close.is_some_and(|i| bytes.get(i + 1) == Some(&b':'))
3846 }
3847 b'<' => {
3851 crate::utils::html_block::parse_html_block_start(text).is_some()
3852 || crate::utils::html_block::opens_untagged_html_block(text)
3853 }
3854 _ => false,
3855 }
3856}
3857
3858fn ends_with_unescaped_backslash(line: &str) -> bool {
3861 line.bytes().rev().take_while(|&b| b == b'\\').count() % 2 == 1
3862}
3863
3864fn merge_block_construct_continuations(lines: Vec<String>) -> Vec<String> {
3877 let mut merged: Vec<String> = Vec::with_capacity(lines.len());
3878 for line in lines {
3879 merged.push(line);
3880 while merged.len() > 1
3884 && (starts_block_construct(merged.last().expect("non-empty"))
3885 || ends_with_unescaped_backslash(&merged[merged.len() - 2]))
3886 {
3887 let last = merged.pop().expect("non-empty");
3888 let prev = merged.last_mut().expect("len > 1");
3889 prev.push(' ');
3890 prev.push_str(last.trim_start());
3891 }
3892 }
3893 merged
3894}
3895
3896fn keep_first_line_out_of_html_block(mut lines: Vec<String>) -> Vec<String> {
3906 let Some(first) = lines.first() else {
3907 return lines;
3908 };
3909 if !crate::utils::html_block::opens_tag_line_html_block(first.trim_start()) {
3910 return lines;
3911 }
3912 if lines.len() > 1 {
3913 let next = lines.remove(1);
3914 lines[0].push(' ');
3915 lines[0].push_str(next.trim_start());
3916 return lines;
3917 }
3918 let tag = lines.remove(0);
3919 let indent = tag.len() - tag.trim_start().len();
3920 let tag_len = crate::utils::html_block::complete_tag_len(&tag[indent..]).unwrap_or(0);
3921 let mut quote = None;
3922 let mut split = None;
3923 let mut quoted_split = None;
3924 for (offset, byte) in tag.bytes().enumerate().skip(indent).take(tag_len) {
3925 let breakable = matches!(byte, b' ' | b'\t') && !starts_block_construct(&tag[offset..]);
3926 match (quote, byte) {
3927 (None, b'"' | b'\'') => quote = Some(byte),
3928 (Some(open), _) if open == byte => quote = None,
3929 (None, _) if breakable => split = Some(offset),
3930 (Some(_), _) if breakable => quoted_split = Some(offset),
3931 _ => {}
3932 }
3933 }
3934 match split.or(quoted_split) {
3935 Some(offset) => vec![
3936 tag[..offset].trim_end().to_string(),
3937 tag[offset..].trim_start().to_string(),
3938 ],
3939 None => vec![tag],
3940 }
3941}
3942
3943fn elements_source_text(elements: &[Element]) -> (String, Vec<(usize, usize)>) {
3951 let mut text = String::new();
3952 let mut ranges = Vec::with_capacity(elements.len());
3953 for (idx, element) in elements.iter().enumerate() {
3954 let gap = source_gap_before(elements, idx);
3955 let piece = match element {
3956 Element::Text(content) => content.clone(),
3958 Element::Italic { content, underscore } => {
3959 wrap_emphasis(content, if *underscore { "_" } else { "*" }, &mut text, gap)
3960 }
3961 Element::Bold { content, underscore } => {
3962 wrap_emphasis(content, if *underscore { "__" } else { "**" }, &mut text, gap)
3963 }
3964 Element::Strikethrough { content, double } => {
3965 wrap_emphasis(content, if *double { "~~" } else { "~" }, &mut text, gap)
3966 }
3967 _ => {
3968 push_source_gap(&mut text, gap);
3969 element.to_string()
3970 }
3971 };
3972 let start = text.len();
3973 text.push_str(&piece);
3974 ranges.push((start, text.len()));
3975 }
3976 (text, ranges)
3977}
3978
3979fn reflow_elements_sentence_per_line(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
3981 let abbreviations = get_abbreviations(&options.abbreviations);
3982 let require_sentence_capital = options.require_sentence_capital;
3983 let mut lines = Vec::new();
3984
3985 let (paragraph_text, piece_ranges) = elements_source_text(elements);
3993 let paragraph = sentence_structure_with_links(
3994 ¶graph_text,
3995 options.defined_references.as_ref(),
3996 &abbreviations,
3997 options.link_text_punctuation,
3998 options.semantic_line_breaks,
3999 );
4000 let structure_from = |base: usize| ParagraphStructure {
4001 text: ¶graph_text,
4002 structure: ¶graph,
4003 base,
4004 };
4005 let mut line: Option<usize> = None;
4013 let absorb = |line_start: usize, (piece_start, piece_end): (usize, usize)| {
4021 let in_front = line_start <= piece_start;
4022 debug_assert!(
4023 in_front,
4024 "line under construction begins at {line_start}, past the element at {piece_start} of {paragraph_text:?}"
4025 );
4026 if in_front {
4027 (line_start, piece_end, Some(structure_from(line_start)))
4028 } else {
4029 (piece_start, piece_end, None)
4030 }
4031 };
4032
4033 for (idx, element) in elements.iter().enumerate() {
4034 let (line_start, line_end, structure) = absorb(line.unwrap_or(piece_ranges[idx].0), piece_ranges[idx]);
4035 if !paragraph.parentheticals.is_empty() && line.is_some() && idx > 0 {
4040 let previous_end = piece_ranges[idx - 1].1;
4041 let past = paragraph
4042 .parentheticals
4043 .partition_point(|&(start, _)| start < previous_end);
4044 if past > 0 {
4045 let group_end = paragraph.parentheticals[past - 1].1;
4046 if previous_end < group_end && line_end < group_end {
4047 line = Some(line_start);
4048 continue;
4049 }
4050 }
4051 }
4052 let combined = ¶graph_text[line_start..line_end];
4053
4054 let splits = matches!(
4060 element,
4061 Element::Text(_) | Element::Italic { .. } | Element::Bold { .. } | Element::Strikethrough { .. }
4062 );
4063
4064 if splits {
4065 let sentences = match structure {
4068 Some(structure) => split_into_sentence_ranges(
4069 combined,
4070 &abbreviations,
4071 require_sentence_capital,
4072 options.cjk_soft_break,
4073 options.defined_references.as_ref(),
4074 Some(structure),
4075 options.link_text_punctuation,
4076 ),
4077 None => trim_range(combined, 0, combined.len()).into_iter().collect(),
4078 };
4079
4080 let glued_to_next = piece_ranges
4091 .get(idx + 1)
4092 .is_some_and(|&(next_start, _)| !paragraph_text[..next_start].ends_with(is_breakable_whitespace));
4093 let next_bracketed = elements
4094 .get(idx + 1)
4095 .filter(|next| next.opens_with_bracket())
4096 .map(|_| piece_ranges[idx + 1]);
4097 let closes_before_next = |sentence: &str| -> bool {
4098 let sentence_end = line_start + combined.trim_end_matches(is_breakable_whitespace).len();
4099 if glued_to_next || inside_parenthetical(sentence_end, ¶graph.parentheticals) {
4100 return false;
4101 }
4102 let Some((next_start, next_end)) = next_bracketed else {
4103 return true;
4104 };
4105 let probe_start = sentence_end - sentence.len();
4108 let probe = ¶graph_text[probe_start..next_end];
4109 let next_str = ¶graph_text[next_start..next_end];
4110 let probe_sentences = split_into_sentences_with_set(
4111 probe,
4112 &abbreviations,
4113 require_sentence_capital,
4114 options.cjk_soft_break,
4115 options.defined_references.as_ref(),
4116 Some(structure_from(probe_start)),
4117 options.link_text_punctuation,
4118 );
4119 probe_sentences.last().is_some_and(|last| last == next_str)
4120 };
4121
4122 if sentences.len() > 1 {
4123 let mut pending_start = None;
4129 let last = sentences.len() - 1;
4130 for (i, &(start, end)) in sentences.iter().enumerate() {
4131 let pending = &combined[*pending_start.get_or_insert(start)..end];
4132
4133 let closed = i < last || (ends_with_sentence_punct(pending) && closes_before_next(pending));
4138 if closed && !text_ends_with_abbreviation(pending, &abbreviations) {
4139 lines.push(pending.to_string());
4140 pending_start = None;
4141 }
4142 }
4143 line = pending_start.map(|held| line_start + held);
4144 } else {
4145 let trimmed = combined.trim_matches(is_breakable_whitespace);
4147
4148 if trimmed.is_empty() {
4152 continue;
4153 }
4154
4155 let ends_with_sentence_punct = ends_with_sentence_punct(trimmed);
4156
4157 if ends_with_sentence_punct
4158 && !text_ends_with_abbreviation(trimmed, &abbreviations)
4159 && closes_before_next(trimmed)
4160 {
4161 lines.push(combined.trim_matches(is_breakable_whitespace).to_string());
4164 line = None;
4165 } else {
4166 line = Some(line_start);
4168 }
4169 }
4170 } else {
4171 line = Some(line_start);
4174 }
4175 }
4176
4177 if let Some(line_start) = line {
4185 let tail = ¶graph_text[line_start..];
4189 lines.extend(split_into_sentences_with_set(
4190 tail,
4191 &abbreviations,
4192 require_sentence_capital,
4193 options.cjk_soft_break,
4194 options.defined_references.as_ref(),
4195 Some(structure_from(line_start)),
4196 options.link_text_punctuation,
4197 ));
4198 }
4199 lines
4200}
4201
4202#[derive(Clone)]
4203struct SentencePackSeparator {
4204 text: String,
4205 preserve_inline: bool,
4206}
4207
4208struct SentencePackUnit {
4209 text: String,
4210 separator_before: SentencePackSeparator,
4211}
4212
4213fn sentence_pack_separators(
4221 elements: &[Element],
4222 sentences: &[String],
4223 options: &ReflowOptions,
4224) -> Vec<SentencePackSeparator> {
4225 let mut separators = vec![
4226 SentencePackSeparator {
4227 text: " ".to_string(),
4228 preserve_inline: false,
4229 };
4230 sentences.len().saturating_sub(1)
4231 ];
4232 if options.cjk_soft_break != CjkSoftBreak::Join {
4233 return separators;
4234 }
4235
4236 let (source, _) = elements_source_text(elements);
4237 let mut cursor = 0;
4238 for (idx, pair) in sentences.windows(2).enumerate() {
4239 let Some(current_offset) = source[cursor..].find(&pair[0]) else {
4240 continue;
4241 };
4242 let current_end = cursor + current_offset + pair[0].len();
4243 let Some(next_offset) = source[current_end..].find(&pair[1]) else {
4244 cursor = current_end;
4245 continue;
4246 };
4247 let next_start = current_end + next_offset;
4248 cursor = next_start;
4249
4250 if pair[0].chars().next_back().is_some_and(joins_cjk_soft_break)
4251 && pair[1].chars().next().is_some_and(joins_cjk_soft_break)
4252 {
4253 let text = source[current_end..next_start].to_string();
4254 separators[idx] = SentencePackSeparator {
4255 preserve_inline: !text.is_empty(),
4256 text,
4257 };
4258 }
4259 }
4260 separators
4261}
4262
4263fn sentence_pack_units(elements: &[Element], options: &ReflowOptions) -> Vec<SentencePackUnit> {
4265 let sentences = reflow_elements_sentence_per_line(elements, options);
4266 let separators = sentence_pack_separators(elements, &sentences, options);
4267 let mut units: Vec<SentencePackUnit> = Vec::with_capacity(sentences.len());
4268
4269 for (idx, text) in sentences.into_iter().enumerate() {
4270 let separator_before = idx.checked_sub(1).map_or(
4271 SentencePackSeparator {
4272 text: String::new(),
4273 preserve_inline: false,
4274 },
4275 |separator| separators[separator].clone(),
4276 );
4277 units.push(SentencePackUnit { text, separator_before });
4278
4279 while units.len() > 1 && starts_block_construct(&units.last().expect("non-empty").text) {
4280 let last = units.pop().expect("non-empty");
4281 let previous = &mut units.last_mut().expect("len > 1").text;
4282 previous.push(' ');
4283 previous.push_str(last.text.trim_start());
4284 }
4285 }
4286 units
4287}
4288
4289fn sentence_pack_groups(elements: &[Element], options: &ReflowOptions) -> Vec<SentencePackUnit> {
4291 let units = sentence_pack_units(elements, options);
4292 let mut groups: Vec<SentencePackUnit> = Vec::with_capacity(units.len());
4293
4294 for unit in units {
4295 if unit.separator_before.preserve_inline
4296 && let Some(previous) = groups.last_mut()
4297 {
4298 previous.text.push_str(&unit.separator_before.text);
4299 previous.text.push_str(&unit.text);
4300 continue;
4301 }
4302 groups.push(unit);
4303 }
4304 groups
4305}
4306
4307fn reflow_elements_sentence_pack(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
4309 let mut lines: Vec<String> = Vec::new();
4310 for unit in sentence_pack_groups(elements, options) {
4311 let budget = if lines.len() == 1 {
4312 options.first_line_length.unwrap_or(options.line_length)
4313 } else {
4314 options.line_length
4315 };
4316 if let Some(current) = lines.last_mut() {
4317 let previous_len = current.len();
4318 current.push_str(&unit.separator_before.text);
4319 current.push_str(&unit.text);
4320 if budget == 0 || budget == usize::MAX || line_width(current, options) <= budget {
4321 continue;
4322 }
4323 current.truncate(previous_len);
4324 }
4325 lines.push(unit.text);
4326 }
4327 lines
4328}
4329
4330fn wrap_emphasis(content: &str, marker: &str, current_line: &mut String, gap: &str) -> String {
4334 push_source_gap(current_line, gap);
4335 format!("{marker}{content}{marker}")
4336}
4337
4338const BREAK_WORDS: &[&str] = &[
4342 "and",
4343 "or",
4344 "but",
4345 "nor",
4346 "yet",
4347 "so",
4348 "for",
4349 "which",
4350 "that",
4351 "because",
4352 "when",
4353 "if",
4354 "while",
4355 "where",
4356 "although",
4357 "though",
4358 "unless",
4359 "since",
4360 "after",
4361 "before",
4362 "until",
4363 "as",
4364 "once",
4365 "whether",
4366 "however",
4367 "therefore",
4368 "moreover",
4369 "furthermore",
4370 "nevertheless",
4371 "whereas",
4372];
4373
4374fn is_clause_punctuation(c: char) -> bool {
4376 matches!(c, ',' | ';' | ':' | '\u{2014}') }
4378
4379fn clause_break_allowed_after(chars: &[char], i: usize) -> bool {
4390 match chars.get(i + 1) {
4391 None => true,
4392 Some(next) => is_breakable_whitespace(*next),
4393 }
4394}
4395
4396fn paren_group_end<'a>(slice: &'a str, element_spans: &[ElementSpan], offset: usize) -> Option<(usize, &'a str)> {
4410 debug_assert!(slice.starts_with('('));
4411 let mut depth: i32 = 0;
4412 for (local_byte, c) in slice.char_indices() {
4413 let global_byte = offset + local_byte;
4414 if depth > 0 && is_inside_element(global_byte, element_spans) {
4419 continue;
4420 }
4421 match c {
4422 '(' => depth += 1,
4423 ')' => {
4424 depth -= 1;
4425 if depth == 0 {
4426 let end = local_byte + 1;
4427 let inner = &slice[1..local_byte];
4428 return Some((end, inner));
4429 }
4430 }
4431 _ => {}
4432 }
4433 }
4434 None
4435}
4436
4437fn split_at_parenthetical(
4454 text: &str,
4455 line_length: usize,
4456 element_spans: &[ElementSpan],
4457 length_mode: ReflowLengthMode,
4458) -> Option<(String, String)> {
4459 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
4460
4461 if text.starts_with('(')
4463 && let Some((end_local, inner)) = paren_group_end(text, element_spans, 0)
4464 && inner.contains(' ')
4465 {
4466 let mut first_end = end_local;
4473 loop {
4474 first_end += text[first_end..]
4475 .char_indices()
4476 .take_while(|(_, c)| !is_breakable_whitespace(*c))
4477 .last()
4478 .map_or(0, |(idx, c)| idx + c.len_utf8());
4479 match element_containing(first_end, element_spans) {
4480 Some(span) => first_end = span.end,
4481 None => break,
4482 }
4483 }
4484 let rest_start = first_end;
4485 let first = &text[..first_end];
4486 if measure(first, 0, element_spans, length_mode).fits(line_length) {
4489 let rest = text[rest_start..].trim_start();
4490 if !rest.is_empty() {
4491 return Some((first.to_string(), rest.to_string()));
4492 }
4493 }
4494 }
4495
4496 let mut best_open_byte: Option<usize> = None;
4498 let mut pos = 0usize;
4499 while pos < text.len() {
4500 if text.as_bytes()[pos] != b'(' {
4502 let c = text[pos..].chars().next().unwrap();
4503 pos += c.len_utf8();
4504 continue;
4505 }
4506 if is_inside_element(pos, element_spans) {
4508 pos += 1;
4509 continue;
4510 }
4511 if let Some((end_local, inner)) = paren_group_end(&text[pos..], element_spans, pos) {
4512 let first = text[..pos].trim_end_matches(is_breakable_whitespace);
4513 let first_len = measure(first, 0, element_spans, length_mode).effective();
4514 if first.len() < pos
4517 && !first.is_empty()
4518 && first_len >= min_first_len
4519 && first_len <= line_length
4520 && inner.contains(' ')
4521 && best_open_byte.is_none_or(|prev| pos > prev)
4522 {
4523 best_open_byte = Some(pos);
4524 }
4525 pos += end_local;
4526 } else {
4527 pos += 1;
4528 }
4529 }
4530
4531 let open_byte = best_open_byte?;
4532 let first = text[..open_byte].trim_end_matches(is_breakable_whitespace).to_string();
4533 let rest = text[open_byte..].to_string();
4534 if first.is_empty() || rest.trim().is_empty() {
4535 return None;
4536 }
4537 Some((first, rest))
4538}
4539
4540#[derive(Clone, Copy, Debug, PartialEq, Eq)]
4548struct ElementSpan {
4549 start: usize,
4550 end: usize,
4551 full: usize,
4552 link_saving: usize,
4555 code_saving: usize,
4557 is_hard: bool,
4559}
4560
4561impl ElementSpan {
4562 fn new(start: usize, len: usize, full: usize, width: LineWidth, is_hard: bool) -> Self {
4565 Self {
4566 start,
4567 end: start + len,
4568 full,
4569 link_saving: full - width.link_exempt,
4570 code_saving: full - width.code_exempt,
4571 is_hard,
4572 }
4573 }
4574
4575 fn contains(&self, pos: usize) -> bool {
4576 pos > self.start && pos < self.end
4577 }
4578
4579 fn within(&self, start: usize, end: usize) -> bool {
4580 self.start >= start && self.end <= end
4581 }
4582
4583 fn exempt_width(&self) -> LineWidth {
4584 LineWidth {
4585 link_exempt: self.full - self.link_saving,
4586 code_exempt: self.full - self.code_saving,
4587 }
4588 }
4589}
4590
4591fn link_text_parts(element: &Element, break_link_text: bool) -> Option<(&str, &str, &str)> {
4597 if !break_link_text {
4598 return None;
4599 }
4600 let (raw, open) = match element {
4601 Element::Link(raw)
4602 | Element::ReferenceLink(raw)
4603 | Element::EmptyReferenceLink(raw)
4604 | Element::ShortcutReference(raw) => (raw.as_str(), 0),
4605 Element::InlineImage(raw) | Element::ReferenceImage(raw) | Element::EmptyReferenceImage(raw) => {
4606 (raw.as_str(), 1)
4607 }
4608 _ => return None,
4609 };
4610 let inner = bracketed_text(raw, open)?;
4611 Some((&raw[..=open], inner, &raw[open + 1 + inner.len()..]))
4612}
4613
4614fn element_is_hard(element: &Element, break_link_text: bool) -> bool {
4622 match element {
4623 Element::Bold { content, .. } | Element::Italic { content, .. } | Element::Strikethrough { content, .. } => {
4624 content.contains(['[', '`', '<', '$', '{'])
4625 }
4626 _ => match link_text_parts(element, break_link_text) {
4627 Some((_, inner, suffix)) => {
4628 inner.contains(['[', '`', '<', '$', '{']) || suffix.chars().any(char::is_whitespace)
4629 }
4630 None => true,
4631 },
4632 }
4633}
4634
4635fn compute_element_spans(
4641 elements: &[Element],
4642 mode: ReflowLengthMode,
4643 exemptions: LengthExemptions,
4644 break_link_text: bool,
4645) -> Vec<ElementSpan> {
4646 let mut spans = Vec::new();
4647 let mut offset = 0;
4648 for element in elements {
4649 let len = element.display_len(ReflowLengthMode::Bytes);
4650 if !matches!(element, Element::Text(_)) {
4651 let full = element.display_len(mode);
4652 let width = element.exempt_width(mode, exemptions);
4653 spans.push(ElementSpan::new(
4654 offset,
4655 len,
4656 full,
4657 width,
4658 element_is_hard(element, break_link_text),
4659 ));
4660 }
4661 offset += len;
4662 }
4663 spans
4664}
4665
4666fn measure(text: &str, offset: usize, spans: &[ElementSpan], mode: ReflowLengthMode) -> LineWidth {
4674 let full = display_len(text, mode);
4675 let end = offset + text.len();
4676 let mut width = LineWidth::plain(full);
4677 for span in spans.iter().filter(|span| span.within(offset, end)) {
4678 width.link_exempt -= span.link_saving;
4679 width.code_exempt -= span.code_saving;
4680 }
4681 width
4682}
4683
4684fn line_width_components(line: &str, options: &ReflowOptions) -> LineWidth {
4689 let raw = display_len(line, options.length_mode);
4690 if !options.length_exemptions.any() {
4691 return LineWidth::plain(raw);
4692 }
4693 let elements = parse_markdown_elements_inner(
4694 line,
4695 options.attr_lists,
4696 options.myst_roles,
4697 options.defined_references.as_ref(),
4698 );
4699 let spans = compute_element_spans(
4700 &elements,
4701 options.length_mode,
4702 options.length_exemptions,
4703 options.break_link_text,
4704 );
4705 measure(line, 0, &spans, options.length_mode)
4706}
4707
4708fn line_width(line: &str, options: &ReflowOptions) -> usize {
4710 line_width_components(line, options).effective()
4711}
4712
4713fn line_fits(line: &str, options: &ReflowOptions) -> bool {
4719 display_len(line, options.length_mode) <= options.line_length || line_width(line, options) <= options.line_length
4720}
4721
4722fn element_containing(pos: usize, spans: &[ElementSpan]) -> Option<ElementSpan> {
4726 let past = spans.partition_point(|span| span.start < pos);
4727 past.checked_sub(1)
4728 .map(|index| spans[index])
4729 .filter(|span| span.contains(pos))
4730}
4731
4732fn is_inside_element(pos: usize, spans: &[ElementSpan]) -> bool {
4734 element_containing(pos, spans).is_some()
4735}
4736
4737const MIN_SPLIT_RATIO: f64 = 0.3;
4740
4741fn split_at_clause_punctuation(
4745 text: &str,
4746 line_length: usize,
4747 element_spans: &[ElementSpan],
4748 length_mode: ReflowLengthMode,
4749) -> Option<(String, String)> {
4750 let chars: Vec<char> = text.chars().collect();
4751 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
4752
4753 let mut width_acc = LineWidth::default();
4759 let mut search_end_char = 0;
4760 let mut byte = 0usize;
4761 let mut idx = 0usize;
4762 while idx < chars.len() {
4763 let (advance_chars, advance_bytes, width) = match element_spans.iter().find(|s| s.start == byte) {
4764 Some(span) => {
4765 let source = &text[span.start..span.end];
4766 (
4767 source.chars().count(),
4768 source.len(),
4769 measure(source, span.start, element_spans, length_mode),
4770 )
4771 }
4772 None => {
4773 let c = chars[idx];
4774 (
4775 1,
4776 c.len_utf8(),
4777 LineWidth::plain(display_len(&c.to_string(), length_mode)),
4778 )
4779 }
4780 };
4781 if !(width_acc + width).fits(line_length) {
4782 break;
4783 }
4784 width_acc += width;
4785 byte += advance_bytes;
4786 idx += advance_chars;
4787 search_end_char = idx;
4788 }
4789
4790 let mut paren_depth: i32 = 0;
4797 let mut best_pos = None;
4798 for i in (0..search_end_char).rev() {
4799 let byte_start: usize = chars[..i].iter().map(|c| c.len_utf8()).sum();
4801 let byte_after: usize = byte_start + chars[i].len_utf8();
4803
4804 if !is_inside_element(byte_start, element_spans) {
4805 match chars[i] {
4806 ')' => paren_depth += 1,
4807 '(' => paren_depth = paren_depth.saturating_sub(1),
4808 _ => {}
4809 }
4810 }
4811
4812 if paren_depth == 0
4813 && is_clause_punctuation(chars[i])
4814 && clause_break_allowed_after(&chars, i)
4815 && !is_inside_element(byte_after, element_spans)
4816 {
4817 best_pos = Some(i);
4818 break;
4819 }
4820 }
4821
4822 let pos = best_pos?;
4823
4824 let first: String = chars[..=pos].iter().collect();
4826 if measure(&first, 0, element_spans, length_mode).effective() < min_first_len {
4827 return None;
4828 }
4829
4830 let rest: String = chars[pos + 1..].iter().collect();
4832 let rest = rest.trim_start().to_string();
4833
4834 if rest.is_empty() {
4835 return None;
4836 }
4837
4838 Some((first, rest))
4839}
4840
4841fn paren_depth_map(text: &str, element_spans: &[ElementSpan]) -> Vec<i32> {
4848 let mut map = vec![0i32; text.len()];
4849 let mut depth = 0i32;
4850 for (byte, c) in text.char_indices() {
4851 if !is_inside_element(byte, element_spans) {
4852 match c {
4853 '(' => depth += 1,
4854 ')' => depth = depth.saturating_sub(1),
4855 _ => {}
4856 }
4857 }
4858 let end = (byte + c.len_utf8()).min(map.len());
4860 for slot in &mut map[byte..end] {
4861 *slot = depth;
4862 }
4863 }
4864 map
4865}
4866
4867fn is_standalone_parenthetical(line: &str) -> bool {
4876 let trimmed = line.trim();
4877 if !trimmed.starts_with('(') {
4878 return false;
4879 }
4880 let Some(close) = trimmed.rfind(')') else {
4883 return false;
4884 };
4885 if trimmed[close + 1..].contains(char::is_whitespace) {
4886 return false;
4887 }
4888 let core = &trimmed[..=close];
4889 let inner = &core[1..core.len() - 1];
4891 if !inner.contains(' ') {
4892 return false;
4893 }
4894 let mut depth = 0i32;
4896 for c in core.chars() {
4897 match c {
4898 '(' => depth += 1,
4899 ')' => depth -= 1,
4900 _ => {}
4901 }
4902 if depth < 0 {
4903 return false;
4904 }
4905 }
4906 depth == 0
4907}
4908
4909fn split_at_break_word(
4913 text: &str,
4914 line_length: usize,
4915 element_spans: &[ElementSpan],
4916 length_mode: ReflowLengthMode,
4917 custom_break_words: &[String],
4918) -> Option<(String, String)> {
4919 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
4920 let mut best_split = None;
4921
4922 let depth_map = paren_depth_map(text, element_spans);
4925
4926 let mut offset = 0;
4929 for token in text.split_inclusive(' ') {
4930 let Some(word) = token.strip_suffix(' ') else {
4931 break;
4932 };
4933 let position = offset;
4934 offset += token.len();
4935 if word.is_empty()
4936 || !(BREAK_WORDS.iter().any(|default| word.eq_ignore_ascii_case(default))
4937 || custom_break_words
4938 .iter()
4939 .any(|custom| unicase::UniCase::new(word) == unicase::UniCase::new(custom.as_str())))
4940 {
4941 continue;
4942 }
4943 let first_part = text[..position].trim_end();
4944 let first_part_len = measure(first_part, 0, element_spans, length_mode).effective();
4945 let inside_paren = depth_map.get(position).is_some_and(|&depth| depth > 0);
4946 if first_part_len >= min_first_len
4947 && first_part_len <= line_length
4948 && !is_inside_element(position, element_spans)
4949 && !inside_paren
4950 {
4951 best_split = Some(position);
4953 }
4954 }
4955
4956 let byte_start = best_split?;
4957
4958 let first = text[..byte_start].trim_end().to_string();
4959 let rest = text[byte_start..].to_string();
4960
4961 if first.is_empty() || rest.trim().is_empty() {
4962 return None;
4963 }
4964
4965 Some((first, rest))
4966}
4967
4968fn replaces_whitespace(text: &str, first: &str, rest: &str, element_spans: &[ElementSpan]) -> bool {
4979 if !text.starts_with(first) || !text.ends_with(rest) {
4980 return false;
4981 }
4982 let gap_end = text.len() - rest.len();
4983 gap_end > first.len()
4984 && text[first.len()..gap_end].chars().all(is_breakable_whitespace)
4985 && !element_spans
4986 .iter()
4987 .any(|span| first.len() < span.end && span.start < gap_end)
4988}
4989
4990fn cascade_split_line(text: &str, options: &ReflowOptions) -> Vec<String> {
5001 let line_length = options.line_length;
5002 let length_mode = options.length_mode;
5003 let attr_lists = options.attr_lists;
5004 let myst_roles = options.myst_roles;
5005 let defined_references = options.defined_references.as_ref();
5006 if line_length == 0 || display_len(text, length_mode) <= line_length {
5007 return vec![text.to_string()];
5008 }
5009
5010 let elements = parse_markdown_elements_inner(text, attr_lists, myst_roles, defined_references);
5011 let element_spans = compute_element_spans(
5012 &elements,
5013 length_mode,
5014 options.length_exemptions,
5015 options.break_link_text,
5016 );
5017
5018 if measure(text, 0, &element_spans, length_mode).fits(line_length) {
5021 return vec![text.to_string()];
5022 }
5023
5024 let rebased_spans = |start: usize| -> Vec<ElementSpan> {
5028 if start == 0 {
5029 return element_spans.clone();
5030 }
5031 element_spans
5032 .iter()
5033 .filter(|span| span.end > start)
5034 .map(|span| ElementSpan {
5035 start: span.start.saturating_sub(start),
5036 end: span.end.saturating_sub(start),
5037 ..*span
5038 })
5039 .collect()
5040 };
5041
5042 let mut result = Vec::new();
5043 let mut start = 0usize;
5044
5045 loop {
5046 let remaining = &text[start..];
5047 let spans = rebased_spans(start);
5048 if measure(remaining, 0, &spans, length_mode).fits(line_length) {
5049 result.push(remaining.to_string());
5050 return result;
5051 }
5052
5053 let at_whitespace = |candidate: Option<(String, String)>| {
5062 candidate.filter(|(first, rest)| replaces_whitespace(remaining, first, rest, &spans))
5063 };
5064 let split = at_whitespace(split_at_parenthetical(remaining, line_length, &spans, length_mode))
5065 .or_else(|| at_whitespace(split_at_clause_punctuation(remaining, line_length, &spans, length_mode)))
5066 .or_else(|| {
5067 at_whitespace(split_at_break_word(
5068 remaining,
5069 line_length,
5070 &spans,
5071 length_mode,
5072 &options.break_words,
5073 ))
5074 });
5075
5076 if let Some((first, rest)) = split {
5077 let consumed = remaining.len().saturating_sub(rest.len());
5078 if consumed == 0 {
5081 break;
5082 }
5083 result.push(first);
5084 start += consumed;
5085 continue;
5086 }
5087
5088 break;
5090 }
5091
5092 let mut fallback_options = options.clone();
5094 fallback_options.break_on_sentences = false;
5095 fallback_options.preserve_breaks = false;
5096 fallback_options.sentence_per_line = false;
5097 fallback_options.semantic_line_breaks = false;
5098 fallback_options.require_sentence_capital = true;
5099 fallback_options.max_list_continuation_indent = None;
5100 fallback_options.defined_references = None;
5101 let remaining = &text[start..];
5102 let tail_elements = if start == 0 {
5103 elements
5104 } else {
5105 parse_markdown_elements_inner(remaining, attr_lists, myst_roles, defined_references)
5106 };
5107 result.extend(reflow_elements(&tail_elements, &fallback_options));
5108 result
5109}
5110
5111fn cascade_semantic_sentence(text: &str, options: &ReflowOptions) -> Vec<String> {
5115 let (Some(open), Some(close)) = (text.find('('), text.rfind(')')) else {
5116 return cascade_split_line(text, options);
5117 };
5118 if open >= close || !text[open..close].contains(['.', '!', '?', '。', '!', '?']) {
5119 return cascade_split_line(text, options);
5120 }
5121 let abbreviations = get_abbreviations(&options.abbreviations);
5122 let mut structure = sentence_structure_with_links(
5123 text,
5124 options.defined_references.as_ref(),
5125 &abbreviations,
5126 options.link_text_punctuation,
5127 true,
5128 );
5129 let protected = structure.parentheticals.len();
5130 structure
5131 .parentheticals
5132 .retain(|&(start, end)| line_fits(&text[start..end], options));
5133 if structure.parentheticals.len() == protected {
5134 return cascade_split_line(text, options);
5135 }
5136
5137 split_into_sentence_ranges(
5138 text,
5139 &abbreviations,
5140 options.require_sentence_capital,
5141 options.cjk_soft_break,
5142 options.defined_references.as_ref(),
5143 Some(ParagraphStructure {
5144 text,
5145 structure: &structure,
5146 base: 0,
5147 }),
5148 options.link_text_punctuation,
5149 )
5150 .into_iter()
5151 .flat_map(|(start, end)| cascade_split_line(&text[start..end], options))
5152 .collect()
5153}
5154
5155fn reflow_elements_semantic(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
5159 let sentence_lines = reflow_elements_sentence_per_line(elements, options);
5161
5162 if options.line_length == 0 {
5165 return sentence_lines;
5166 }
5167
5168 let mut result = Vec::new();
5169 let mut sentence_starts = Vec::new();
5170 for line in sentence_lines {
5171 if options.link_text_punctuation == LinkTextPunctuation::Trailing {
5172 sentence_starts.push(result.len());
5173 }
5174 if line_fits(&line, options) {
5175 result.push(line);
5176 } else {
5177 result.extend(cascade_semantic_sentence(&line, options));
5178 }
5179 }
5180
5181 let min_line_len = ((options.line_length as f64) * MIN_SPLIT_RATIO) as usize;
5184 let mut merged: Vec<String> = Vec::with_capacity(result.len());
5185 for (index, line) in result.into_iter().enumerate() {
5186 if !merged.is_empty()
5189 && sentence_starts.binary_search(&index).is_err()
5190 && line_width(&line, options) < min_line_len
5191 && !line.trim().is_empty()
5192 {
5193 if is_standalone_parenthetical(&line) {
5196 merged.push(line);
5197 continue;
5198 }
5199
5200 let prev_ends_at_sentence = {
5202 let trimmed = merged.last().unwrap().trim_end();
5203 trimmed
5204 .chars()
5205 .rev()
5206 .find(|&c| !(is_closing_quote(c) || is_closing_bracket(c)))
5207 .is_some_and(|c| matches!(c, '.' | '!' | '?') || is_cjk_sentence_ending(c))
5208 };
5209
5210 if !prev_ends_at_sentence {
5211 let prev = merged.last_mut().unwrap();
5212 let combined = format!("{prev} {line}");
5213 if line_fits(&combined, options) {
5215 *prev = combined;
5216 continue;
5217 }
5218 }
5219 }
5220 merged.push(line);
5221 }
5222 merged
5223}
5224
5225fn rfind_safe_space(
5236 line: &str,
5237 element_spans: &[ElementSpan],
5238 options: &ReflowOptions,
5239 relax_soft_spans: bool,
5240) -> Option<usize> {
5241 line.char_indices().rev().map(|(pos, _)| pos).find(|&pos| {
5242 line.as_bytes()[pos] == b' '
5243 && !is_inside_element_filtered(pos, element_spans, options, relax_soft_spans)
5244 && !starts_block_construct(&line[pos + 1..])
5245 && !ends_with_unescaped_backslash(line[..pos].trim_end_matches(is_breakable_whitespace))
5246 })
5247}
5248
5249fn is_inside_element_filtered(
5250 pos: usize,
5251 spans: &[ElementSpan],
5252 options: &ReflowOptions,
5253 relax_soft_spans: bool,
5254) -> bool {
5255 spans.iter().any(|span| {
5256 span.contains(pos)
5257 && (!relax_soft_spans
5258 || span.is_hard
5259 || (options.atomic_spans && span.exempt_width().fits(options.line_length)))
5260 })
5261}
5262
5263#[derive(Clone, Copy)]
5268struct Attached<'a> {
5269 text: &'a str,
5270 width: LineWidth,
5271 separator: &'a str,
5272}
5273
5274fn break_before_attached(
5291 lines: &mut Vec<String>,
5292 current_line: &mut String,
5293 current_width: &mut LineWidth,
5294 element_spans: &mut Vec<ElementSpan>,
5295 attach: Attached<'_>,
5296 options: &ReflowOptions,
5297) -> Option<usize> {
5298 let length_mode = options.length_mode;
5299 let last_space = rfind_safe_space(current_line, element_spans, options, false)
5300 .or_else(|| rfind_safe_space(current_line, element_spans, options, true))?;
5301 let before = current_line[..last_space]
5302 .trim_end_matches(is_breakable_whitespace)
5303 .to_string();
5304 let after = current_line[last_space + 1..].to_string();
5305 let after_width = measure(&after, last_space + 1, element_spans, length_mode);
5306 lines.push(before);
5307 let carried = after.len();
5308 let Attached { text, width, separator } = attach;
5309 *current_line = format!("{after}{separator}{text}");
5310 *current_width = after_width + LineWidth::plain(display_len(separator, length_mode)) + width;
5311 rebase_spans_after_break(element_spans, last_space + 1);
5312 Some(carried)
5313}
5314
5315fn rebase_spans_after_break(element_spans: &mut Vec<ElementSpan>, carried_start: usize) {
5324 element_spans.retain(|span| span.end > carried_start);
5325 for span in element_spans.iter_mut() {
5326 span.start = span.start.saturating_sub(carried_start);
5327 span.end -= carried_start;
5328 }
5329}
5330
5331fn reflow_elements(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
5333 let mut lines = Vec::new();
5334 let mut current_line = String::new();
5335 let mut current_width = LineWidth::default();
5338 let mut current_line_element_spans: Vec<ElementSpan> = Vec::new();
5340 let length_mode = options.length_mode;
5341 let exemptions = options.length_exemptions;
5342
5343 for (idx, element) in elements.iter().enumerate() {
5344 let element_len = element.display_len(length_mode);
5345 let element_width = element.exempt_width(length_mode, exemptions);
5346 let is_hard = element_is_hard(element, options.break_link_text);
5347
5348 let is_adjacent_to_prev = if idx > 0 {
5357 match (&elements[idx - 1], element) {
5358 (Element::Text(t), _) => !t.is_empty() && !t.ends_with(is_breakable_whitespace),
5359 (_, Element::Text(t)) => !t.is_empty() && !t.starts_with(is_breakable_whitespace),
5360 _ => true,
5361 }
5362 } else {
5363 false
5364 };
5365
5366 if let Element::Text(text) = element {
5368 let has_leading_space = text.starts_with(is_breakable_whitespace);
5370 let words: Vec<&str> = split_breakable_words(text).collect();
5372
5373 for (i, word) in words.iter().enumerate() {
5374 let word_width = LineWidth::plain(display_len(word, length_mode));
5376 let is_trailing_punct = word.chars().all(|c| {
5382 matches!(c, ',' | '.' | ':' | ';' | '!' | '?' | ')' | ']' | '}') || is_significant_whitespace(c)
5383 });
5384
5385 let is_first_adjacent = i == 0 && is_adjacent_to_prev;
5388
5389 if is_first_adjacent {
5390 if !(current_width + word_width).fits(options.line_length)
5392 && !current_width.is_empty()
5393 && break_before_attached(
5394 &mut lines,
5395 &mut current_line,
5396 &mut current_width,
5397 &mut current_line_element_spans,
5398 Attached {
5399 text: word,
5400 width: word_width,
5401 separator: "",
5402 },
5403 options,
5404 )
5405 .is_some()
5406 {
5407 } else {
5412 current_line.push_str(word);
5413 current_width += word_width;
5414 }
5415 } else if !current_width.is_empty()
5416 && !(current_width + LineWidth::plain(1) + word_width).fits(options.line_length)
5417 {
5418 if is_trailing_punct {
5419 if break_before_attached(
5426 &mut lines,
5427 &mut current_line,
5428 &mut current_width,
5429 &mut current_line_element_spans,
5430 Attached {
5431 text: word,
5432 width: word_width,
5433 separator: " ",
5434 },
5435 options,
5436 )
5437 .is_none()
5438 {
5439 current_line.push(' ');
5440 current_line.push_str(word);
5441 current_width += LineWidth::plain(1) + word_width;
5442 }
5443 } else if !starts_block_construct(word)
5444 && !ends_with_unescaped_backslash(current_line.trim_end_matches(is_breakable_whitespace))
5445 {
5446 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
5448 current_line = word.to_string();
5449 current_width = word_width;
5450 current_line_element_spans.clear();
5451 } else if break_before_attached(
5452 &mut lines,
5453 &mut current_line,
5454 &mut current_width,
5455 &mut current_line_element_spans,
5456 Attached {
5457 text: word,
5458 width: word_width,
5459 separator: " ",
5460 },
5461 options,
5462 )
5463 .is_some()
5464 {
5465 } else {
5471 if i > 0 || has_leading_space {
5474 current_line.push(' ');
5475 current_width += LineWidth::plain(1);
5476 }
5477 current_line.push_str(word);
5478 current_width += word_width;
5479 }
5480 } else {
5481 let add_space = !current_width.is_empty() && (i > 0 || has_leading_space);
5493 if add_space {
5494 current_line.push(' ');
5495 current_width += LineWidth::plain(1);
5496 }
5497 current_line.push_str(word);
5498 current_width += word_width;
5499 }
5500 }
5501 } else {
5502 let link_parts = link_text_parts(element, options.break_link_text);
5503 let span_info = match element {
5504 Element::Italic { content, underscore } => {
5505 let marker = if *underscore { "_" } else { "*" };
5506 Some((content.as_str(), marker, marker, false))
5507 }
5508 Element::Bold { content, underscore } => {
5509 let marker = if *underscore { "__" } else { "**" };
5510 Some((content.as_str(), marker, marker, false))
5511 }
5512 Element::Strikethrough { content, double } => {
5513 let marker = if *double { "~~" } else { "~" };
5514 Some((content.as_str(), marker, marker, false))
5515 }
5516 Element::Code { content, marker } => Some((content.as_str(), marker.as_str(), marker.as_str(), true)),
5517 _ => link_parts.map(|(prefix, inner, suffix)| (inner, prefix, suffix, false)),
5518 };
5519 let is_link = link_parts.is_some();
5520
5521 let breakable: Option<Vec<&str>> = match span_info {
5528 Some((content, _, suffix, is_code)) => {
5529 if is_code {
5530 (!options.atomic_spans && options.wrap_code_spans && code_span_wraps_losslessly(content))
5531 .then(|| split_breakable_words(content).collect())
5532 } else if is_link {
5533 (!options.atomic_spans || !element_width.fits(options.line_length))
5534 .then(|| {
5535 link_text_break_units(
5536 content,
5537 suffix,
5538 options.line_length,
5539 length_mode,
5540 options.defined_references.as_ref(),
5541 options.attr_lists,
5542 )
5543 })
5544 .flatten()
5545 } else {
5546 (!options.atomic_spans || element_len > options.line_length)
5547 .then(|| breakable_units(content, options.defined_references.as_ref(), options.attr_lists))
5548 .flatten()
5549 }
5550 }
5551 None => None,
5552 };
5553
5554 if let Some(words) = breakable {
5555 let (_, prefix, suffix, is_code) = span_info.expect("breakable implies a span");
5556 let n = words.len();
5557 if n == 0 {
5558 let full = format!("{prefix}{suffix}");
5560 let full_width = LineWidth::plain(display_len(&full, length_mode));
5561 if !is_adjacent_to_prev && !current_width.is_empty() {
5562 current_line.push(' ');
5563 current_width += LineWidth::plain(1);
5564 }
5565 current_line.push_str(&full);
5566 current_width += full_width;
5567 } else {
5568 let suffix_span_width = LineWidth::plain(display_len(suffix, length_mode));
5574
5575 for (i, word) in words.iter().enumerate() {
5576 let is_first = i == 0;
5577 let is_last = i == n - 1;
5578
5579 let space_start = if is_first && is_code && word.starts_with('`') {
5580 " "
5581 } else {
5582 ""
5583 };
5584 let space_end = if is_last && is_code && word.ends_with('`') {
5585 " "
5586 } else {
5587 ""
5588 };
5589
5590 let word_str: String = match (is_first, is_last) {
5591 (true, true) => format!("{prefix}{space_start}{word}{space_end}{suffix}"),
5592 (true, false) => format!("{prefix}{space_start}{word}"),
5593 (false, true) => format!("{word}{space_end}{suffix}"),
5594 (false, false) => word.to_string(),
5595 };
5596 let word_elements = parse_elements(&word_str, options);
5597 let word_spans =
5598 compute_element_spans(&word_elements, length_mode, exemptions, options.break_link_text);
5599 let word_width = measure(&word_str, 0, &word_spans, length_mode);
5600
5601 let needs_space = if is_first {
5602 !is_adjacent_to_prev && !current_width.is_empty()
5603 } else {
5604 !current_width.is_empty()
5605 };
5606
5607 if needs_space
5608 && !(current_width + LineWidth::plain(1) + word_width).fits(options.line_length)
5609 && !starts_block_construct(&word_str)
5610 {
5611 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
5612 current_line = word_str;
5613 current_width = word_width;
5614 current_line_element_spans.clear();
5615 for span in word_spans {
5616 current_line_element_spans.push(span);
5617 }
5618 if is_link && is_last {
5619 current_line_element_spans.push(ElementSpan::new(
5620 word.len(),
5621 suffix.len(),
5622 display_len(suffix, length_mode),
5623 suffix_span_width,
5624 true,
5625 ));
5626 }
5627 } else {
5628 let mut start_pos = current_line.len();
5629 if needs_space {
5630 current_line.push(' ');
5631 current_width += LineWidth::plain(1);
5632 start_pos += 1;
5633 }
5634 current_line.push_str(&word_str);
5635 current_width += word_width;
5636 for mut span in word_spans {
5637 span.start += start_pos;
5638 span.end += start_pos;
5639 current_line_element_spans.push(span);
5640 }
5641 if is_link && is_last {
5642 current_line_element_spans.push(ElementSpan::new(
5643 start_pos + word.len(),
5644 suffix.len(),
5645 display_len(suffix, length_mode),
5646 suffix_span_width,
5647 true,
5648 ));
5649 }
5650 }
5651 }
5652 }
5653 } else {
5654 let element_str = format!("{element}");
5657
5658 if is_adjacent_to_prev {
5659 if !(current_width + element_width).fits(options.line_length)
5661 && let Some(carried) = break_before_attached(
5662 &mut lines,
5663 &mut current_line,
5664 &mut current_width,
5665 &mut current_line_element_spans,
5666 Attached {
5667 text: &element_str,
5668 width: element_width,
5669 separator: "",
5670 },
5671 options,
5672 )
5673 {
5674 current_line_element_spans.push(ElementSpan::new(
5678 carried,
5679 element_str.len(),
5680 element_len,
5681 element_width,
5682 is_hard,
5683 ));
5684 } else {
5685 let start = current_line.len();
5686 current_line.push_str(&element_str);
5687 current_width += element_width;
5688 current_line_element_spans.push(ElementSpan::new(
5689 start,
5690 element_str.len(),
5691 element_len,
5692 element_width,
5693 is_hard,
5694 ));
5695 }
5696 } else if !current_width.is_empty()
5697 && !(current_width + LineWidth::plain(1) + element_width).fits(options.line_length)
5698 {
5699 if !starts_block_construct(&element_str) {
5700 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
5702 current_line.clone_from(&element_str);
5703 current_width = element_width;
5704 current_line_element_spans.clear();
5705 current_line_element_spans.push(ElementSpan::new(
5706 0,
5707 element_str.len(),
5708 element_len,
5709 element_width,
5710 is_hard,
5711 ));
5712 } else if let Some(carried) = break_before_attached(
5713 &mut lines,
5714 &mut current_line,
5715 &mut current_width,
5716 &mut current_line_element_spans,
5717 Attached {
5718 text: &element_str,
5719 width: element_width,
5720 separator: " ",
5721 },
5722 options,
5723 ) {
5724 let start = carried + 1;
5728 current_line_element_spans.push(ElementSpan::new(
5729 start,
5730 element_str.len(),
5731 element_len,
5732 element_width,
5733 is_hard,
5734 ));
5735 } else {
5736 let ends_with_opener =
5739 current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
5740 if !ends_with_opener {
5741 current_line.push(' ');
5742 current_width += LineWidth::plain(1);
5743 }
5744 let start = current_line.len();
5745 current_line.push_str(&element_str);
5746 current_width += element_width;
5747 current_line_element_spans.push(ElementSpan::new(
5748 start,
5749 element_str.len(),
5750 element_len,
5751 element_width,
5752 is_hard,
5753 ));
5754 }
5755 } else {
5756 let ends_with_opener =
5758 current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
5759 if !current_width.is_empty() && !ends_with_opener {
5760 current_line.push(' ');
5761 current_width += LineWidth::plain(1);
5762 }
5763 let start = current_line.len();
5764 current_line.push_str(&element_str);
5765 current_width += element_width;
5766 current_line_element_spans.push(ElementSpan::new(
5767 start,
5768 element_str.len(),
5769 element_len,
5770 element_width,
5771 is_hard,
5772 ));
5773 }
5774 }
5775 }
5776 }
5777
5778 if !current_line.is_empty() {
5780 lines.push(current_line.trim_end_matches(is_breakable_whitespace).to_string());
5781 }
5782
5783 lines
5784}
5785
5786pub fn reflow_markdown(content: &str, options: &ReflowOptions) -> String {
5788 let lines: Vec<&str> = content.lines().collect();
5789 let inside_code_span = lines_touching_multiline_code_span(content);
5791 let mut result = Vec::new();
5792 let mut i = 0;
5793
5794 while i < lines.len() {
5795 let line = lines[i];
5796 let trimmed = line.trim();
5797
5798 if trimmed.is_empty() {
5800 result.push(String::new());
5801 i += 1;
5802 continue;
5803 }
5804
5805 if trimmed.starts_with('#') {
5807 result.push(line.to_string());
5808 i += 1;
5809 continue;
5810 }
5811
5812 if trimmed.starts_with(":::") {
5814 result.push(line.to_string());
5815 i += 1;
5816 continue;
5817 }
5818
5819 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
5821 result.push(line.to_string());
5822 i += 1;
5823 while i < lines.len() {
5825 result.push(lines[i].to_string());
5826 if lines[i].trim().starts_with("```") || lines[i].trim().starts_with("~~~") {
5827 i += 1;
5828 break;
5829 }
5830 i += 1;
5831 }
5832 continue;
5833 }
5834
5835 if calculate_indentation_width_default(line) >= 4 {
5837 result.push(line.to_string());
5839 i += 1;
5840 while i < lines.len() {
5841 let next_line = lines[i];
5842 if calculate_indentation_width_default(next_line) >= 4 || next_line.trim().is_empty() {
5844 result.push(next_line.to_string());
5845 i += 1;
5846 } else {
5847 break;
5848 }
5849 }
5850 continue;
5851 }
5852
5853 if trimmed.starts_with('>') {
5855 let gt_pos = line.find('>').expect("'>' must exist since trimmed.starts_with('>')");
5858 let quote_prefix = line[0..=gt_pos].to_string();
5859 let quote_content = &line[quote_prefix.len()..].trim_start();
5860
5861 let reflowed = reflow_line(quote_content, options);
5862 for reflowed_line in &reflowed {
5863 result.push(format!("{quote_prefix} {reflowed_line}"));
5864 }
5865 i += 1;
5866 continue;
5867 }
5868
5869 if is_horizontal_rule(trimmed) {
5871 result.push(line.to_string());
5872 i += 1;
5873 continue;
5874 }
5875
5876 if is_unordered_list_marker(trimmed) || is_numbered_list_item(trimmed) {
5878 let indent = line.len() - line.trim_start().len();
5880 let indent_str = " ".repeat(indent);
5881
5882 let mut marker_end = indent;
5885 let mut content_start = indent;
5886
5887 if trimmed.chars().next().is_some_and(char::is_numeric) {
5888 if let Some(period_pos) = line[indent..].find('.') {
5890 marker_end = indent + period_pos + 1; content_start = marker_end;
5892 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
5896 content_start += 1;
5897 }
5898 }
5899 } else {
5900 marker_end = indent + 1; content_start = marker_end;
5903 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
5907 content_start += 1;
5908 }
5909 }
5910
5911 let min_continuation_indent = content_start;
5913
5914 let rest = &line[content_start..];
5917 if rest.starts_with("[ ] ") || rest.starts_with("[x] ") || rest.starts_with("[X] ") {
5918 marker_end = content_start + 3; content_start += 4; }
5921
5922 let marker = &line[indent..marker_end];
5923
5924 let mut list_content = vec![trim_preserving_hard_break(&line[content_start..])];
5927 i += 1;
5928
5929 while i < lines.len() {
5933 let next_line = lines[i];
5934 let next_trimmed = next_line.trim();
5935
5936 if is_block_boundary(next_trimmed) {
5938 break;
5939 }
5940
5941 let next_indent = next_line.len() - next_line.trim_start().len();
5943 if next_indent >= min_continuation_indent {
5944 let trimmed_start = next_line.trim_start();
5947 list_content.push(trim_preserving_hard_break(trimmed_start));
5948 i += 1;
5949 } else {
5950 break;
5952 }
5953 }
5954
5955 let combined_content = if options.preserve_breaks {
5958 list_content[0].clone()
5959 } else {
5960 let has_hard_breaks = list_content.iter().any(|line| has_hard_break(line));
5962 if has_hard_breaks {
5963 list_content.join("\n")
5965 } else {
5966 join_reflow_lines(&list_content, options)
5967 }
5968 };
5969
5970 let trimmed_marker = marker;
5972 let continuation_spaces = if let Some(max_indent) = options.max_list_continuation_indent {
5973 indent + (content_start - indent).min(max_indent)
5976 } else {
5977 content_start
5978 };
5979
5980 let prefix_length = indent + trimmed_marker.len() + 1;
5982
5983 let adjusted_options = ReflowOptions {
5985 line_length: options.line_length.saturating_sub(prefix_length),
5986 ..options.clone()
5987 };
5988
5989 let reflowed = reflow_line(&combined_content, &adjusted_options);
5990 for (j, reflowed_line) in reflowed.iter().enumerate() {
5991 if j == 0 {
5992 result.push(format!("{indent_str}{trimmed_marker} {reflowed_line}"));
5993 } else {
5994 let continuation_indent = " ".repeat(continuation_spaces);
5996 result.push(format!("{continuation_indent}{reflowed_line}"));
5997 }
5998 }
5999 continue;
6000 }
6001
6002 if crate::utils::table_utils::TableUtils::is_potential_table_row(line) {
6004 result.push(line.to_string());
6005 i += 1;
6006 continue;
6007 }
6008
6009 if trimmed.starts_with('[') && line.contains("]:") {
6011 result.push(line.to_string());
6012 i += 1;
6013 continue;
6014 }
6015
6016 if is_definition_list_marker(trimmed) && has_block_line_above(&lines, i) {
6022 result.push(line.to_string());
6023 i += 1;
6024 continue;
6025 }
6026
6027 let mut is_single_line_paragraph = true;
6029 if i + 1 < lines.len() {
6030 let next_trimmed = lines[i + 1].trim();
6031 if !is_block_boundary(next_trimmed) {
6033 is_single_line_paragraph = false;
6034 }
6035 }
6036
6037 if is_single_line_paragraph && line_fits(line, options) {
6039 result.push(line.to_string());
6040 i += 1;
6041 continue;
6042 }
6043
6044 let mut paragraph_parts: Vec<(String, bool)> = Vec::new();
6048 let mut current_part = vec![line];
6049 i += 1;
6050
6051 if options.preserve_breaks {
6053 let hard_break_type = if line.strip_suffix('\r').unwrap_or(line).ends_with('\\') {
6055 Some("\\")
6056 } else if line.ends_with(" ") {
6057 Some(" ")
6058 } else {
6059 None
6060 };
6061 let reflowed = reflow_line(line, options);
6062
6063 if let Some(break_marker) = hard_break_type {
6065 if !reflowed.is_empty() {
6066 let mut reflowed_with_break = reflowed;
6067 let last_idx = reflowed_with_break.len() - 1;
6068 if !has_hard_break(&reflowed_with_break[last_idx]) {
6069 reflowed_with_break[last_idx].push_str(break_marker);
6070 }
6071 result.extend(reflowed_with_break);
6072 }
6073 } else {
6074 result.extend(reflowed);
6075 }
6076 } else {
6077 let paragraph_start = i - 1;
6091 let paragraph_scan = options.sentence_per_line.then(|| {
6092 let mut paragraph_end = i;
6093 while paragraph_end < lines.len() && !is_block_boundary(lines[paragraph_end].trim()) {
6094 paragraph_end += 1;
6095 }
6096 let joined_paragraph = lines[paragraph_start..paragraph_end].join(" ");
6097 let joined_atomic = sentence_structure(&joined_paragraph, options.defined_references.as_ref()).atomic;
6098 let mut line_starts = Vec::with_capacity(paragraph_end - paragraph_start);
6099 let mut line_offset = 0;
6100 for paragraph_line in &lines[paragraph_start..paragraph_end] {
6101 line_starts.push(line_offset);
6102 line_offset += paragraph_line.len() + 1;
6103 }
6104 (joined_atomic, line_starts)
6105 });
6106
6107 while i < lines.len() {
6108 let prev_line = if !current_part.is_empty() {
6109 current_part.last().unwrap()
6110 } else {
6111 ""
6112 };
6113 let next_line = lines[i];
6114 let next_trimmed = next_line.trim();
6115
6116 if is_block_boundary(next_trimmed) {
6118 break;
6119 }
6120
6121 let prev_trimmed = prev_line.trim();
6124 let abbreviations = get_abbreviations(&options.abbreviations);
6125 let ends_with_sentence = (prev_trimmed.ends_with('.')
6126 || prev_trimmed.ends_with('!')
6127 || prev_trimmed.ends_with('?')
6128 || prev_trimmed.ends_with(".*")
6129 || prev_trimmed.ends_with("!*")
6130 || prev_trimmed.ends_with("?*")
6131 || prev_trimmed.ends_with("._")
6132 || prev_trimmed.ends_with("!_")
6133 || prev_trimmed.ends_with("?_")
6134 || prev_trimmed.ends_with(".\"")
6136 || prev_trimmed.ends_with("!\"")
6137 || prev_trimmed.ends_with("?\"")
6138 || prev_trimmed.ends_with(".'")
6139 || prev_trimmed.ends_with("!'")
6140 || prev_trimmed.ends_with("?'")
6141 || prev_trimmed.ends_with(".\u{201D}")
6142 || prev_trimmed.ends_with("!\u{201D}")
6143 || prev_trimmed.ends_with("?\u{201D}")
6144 || prev_trimmed.ends_with(".\u{2019}")
6145 || prev_trimmed.ends_with("!\u{2019}")
6146 || prev_trimmed.ends_with("?\u{2019}")
6147 || ends_cjk_sentence_with_closer(prev_trimmed))
6152 && !text_ends_with_abbreviation(
6153 prev_trimmed.trim_end_matches(['*', '_', '"', '\'', '\u{201D}', '\u{2019}']),
6154 &abbreviations,
6155 );
6156
6157 let inside_construct = paragraph_scan.as_ref().is_some_and(|(joined_atomic, line_starts)| {
6160 let join_offset = line_starts[i - paragraph_start] - 1;
6161 joined_atomic
6162 .iter()
6163 .any(|&(start, end)| start <= join_offset && join_offset < end)
6164 });
6165
6166 let ends_at_hard_break = has_hard_break(prev_line);
6173 if ends_at_hard_break
6174 || (is_self_contained_display_math_line(prev_line) && !inside_code_span[i - 1])
6175 || (is_self_contained_display_math_line(next_line) && !inside_code_span[i])
6176 || (options.sentence_per_line && ends_with_sentence && !inside_construct)
6177 {
6178 paragraph_parts.push((join_reflow_lines(¤t_part, options), ends_at_hard_break));
6180 current_part = vec![next_line];
6181 } else {
6182 current_part.push(next_line);
6183 }
6184 i += 1;
6185 }
6186
6187 if !current_part.is_empty() {
6189 if current_part.len() == 1 {
6190 paragraph_parts.push((current_part[0].to_string(), false));
6192 } else {
6193 paragraph_parts.push((join_reflow_lines(¤t_part, options), false));
6194 }
6195 }
6196
6197 for (j, (part, ends_at_hard_break)) in paragraph_parts.iter().enumerate() {
6199 let reflowed = reflow_line(part, options);
6200 result.extend(reflowed);
6201
6202 if *ends_at_hard_break
6208 && j < paragraph_parts.len() - 1
6209 && !result.is_empty()
6210 && !options.sentence_per_line
6211 {
6212 let last_idx = result.len() - 1;
6213 if !has_hard_break(&result[last_idx]) {
6214 result[last_idx].push_str(" ");
6215 }
6216 }
6217 }
6218 }
6219 }
6220
6221 let mut result_text = result.join("\n");
6224 if content.ends_with('\n') {
6225 result_text.push('\n');
6226 }
6227 result_text
6228}
6229
6230#[derive(Debug, Clone)]
6232pub struct ParagraphReflow {
6233 pub start_byte: usize,
6235 pub end_byte: usize,
6237 pub reflowed_text: String,
6239}
6240
6241#[derive(Debug, Clone)]
6247pub struct BlockquoteLineData {
6248 pub(crate) content: String,
6250 pub(crate) is_explicit: bool,
6252 pub(crate) prefix: Option<String>,
6254}
6255
6256impl BlockquoteLineData {
6257 pub fn explicit(content: String, prefix: String) -> Self {
6259 Self {
6260 content,
6261 is_explicit: true,
6262 prefix: Some(prefix),
6263 }
6264 }
6265
6266 pub fn lazy(content: String) -> Self {
6268 Self {
6269 content,
6270 is_explicit: false,
6271 prefix: None,
6272 }
6273 }
6274}
6275
6276#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6278pub enum BlockquoteContinuationStyle {
6279 Explicit,
6280 Lazy,
6281}
6282
6283pub fn blockquote_continuation_style(lines: &[BlockquoteLineData]) -> BlockquoteContinuationStyle {
6291 let mut explicit_count = 0usize;
6292 let mut lazy_count = 0usize;
6293
6294 for line in lines.iter().skip(1) {
6295 if line.is_explicit {
6296 explicit_count += 1;
6297 } else {
6298 lazy_count += 1;
6299 }
6300 }
6301
6302 if explicit_count > 0 && lazy_count == 0 {
6303 BlockquoteContinuationStyle::Explicit
6304 } else if lazy_count > 0 && explicit_count == 0 {
6305 BlockquoteContinuationStyle::Lazy
6306 } else if explicit_count >= lazy_count {
6307 BlockquoteContinuationStyle::Explicit
6308 } else {
6309 BlockquoteContinuationStyle::Lazy
6310 }
6311}
6312
6313pub fn dominant_blockquote_prefix(lines: &[BlockquoteLineData], fallback: &str) -> String {
6318 let mut counts: std::collections::HashMap<String, (usize, usize)> = std::collections::HashMap::new();
6319
6320 for (idx, line) in lines.iter().enumerate() {
6321 let Some(prefix) = line.prefix.as_ref() else {
6322 continue;
6323 };
6324 counts
6325 .entry(prefix.clone())
6326 .and_modify(|entry| entry.0 += 1)
6327 .or_insert((1, idx));
6328 }
6329
6330 counts
6331 .into_iter()
6332 .max_by(|(_, (count_a, first_idx_a)), (_, (count_b, first_idx_b))| {
6333 count_a.cmp(count_b).then_with(|| first_idx_b.cmp(first_idx_a))
6334 })
6335 .map_or_else(|| fallback.to_string(), |(prefix, _)| prefix)
6336}
6337
6338pub(crate) fn should_force_explicit_blockquote_line(content_line: &str) -> bool {
6343 let trimmed = content_line.trim_start();
6344 trimmed.starts_with('>')
6345 || trimmed.starts_with('#')
6346 || trimmed.starts_with("```")
6347 || trimmed.starts_with("~~~")
6348 || is_unordered_list_marker(trimmed)
6349 || is_numbered_list_item(trimmed)
6350 || is_horizontal_rule(trimmed)
6351 || is_definition_list_marker(trimmed)
6352 || (trimmed.starts_with('[') && trimmed.contains("]:"))
6353 || trimmed.starts_with(":::")
6354 || (trimmed.starts_with('<')
6355 && !trimmed.starts_with("<http")
6356 && !trimmed.starts_with("<https")
6357 && !trimmed.starts_with("<mailto:"))
6358}
6359
6360pub fn reflow_blockquote_content(
6369 lines: &[BlockquoteLineData],
6370 explicit_prefix: &str,
6371 continuation_style: BlockquoteContinuationStyle,
6372 options: &ReflowOptions,
6373) -> Vec<String> {
6374 let content_strs: Vec<&str> = lines.iter().map(|l| l.content.as_str()).collect();
6375 let ends_in_code_span = lines_ending_in_code_span(&content_strs);
6376 let segments = split_into_segments_strs(&content_strs, &ends_in_code_span);
6377 let mut reflowed_content_lines: Vec<String> = Vec::new();
6378
6379 for segment in segments {
6380 let hard_break_type = segment.last().and_then(|&(line, in_code_span)| {
6381 let line = line.strip_suffix('\r').unwrap_or(line);
6382 if in_code_span {
6383 None
6384 } else if line.ends_with('\\') {
6385 Some("\\")
6386 } else if line.ends_with(" ") {
6387 Some(" ")
6388 } else {
6389 None
6390 }
6391 });
6392
6393 let pieces: Vec<&str> = segment
6394 .iter()
6395 .map(|&(line, in_code_span)| {
6396 if in_code_span {
6397 line
6398 } else if let Some(l) = line.strip_suffix('\\') {
6399 l.trim_end_matches(is_breakable_whitespace)
6400 } else if let Some(l) = line.strip_suffix(" ") {
6401 l.trim_end_matches(is_breakable_whitespace)
6402 } else {
6403 line.trim_end_matches(is_breakable_whitespace)
6404 }
6405 })
6406 .collect();
6407
6408 let segment_text = join_reflow_lines(&pieces, options);
6409 let segment_text = trim_breakable_whitespace(&segment_text);
6410 if segment_text.is_empty() {
6411 continue;
6412 }
6413
6414 let mut segment_options = options.clone();
6415 if options.sentence_pack
6416 && !reflowed_content_lines.is_empty()
6417 && !should_force_explicit_blockquote_line(segment_text)
6418 {
6419 segment_options.first_line_length = None;
6420 }
6421 let mut reflowed = reflow_line(segment_text, &segment_options);
6422 if let Some(break_marker) = hard_break_type
6423 && !reflowed.is_empty()
6424 {
6425 let last_idx = reflowed.len() - 1;
6426 if !has_hard_break(&reflowed[last_idx]) {
6427 reflowed[last_idx].push_str(break_marker);
6428 }
6429 }
6430 reflowed_content_lines.extend(reflowed);
6431 }
6432
6433 let mut styled_lines: Vec<String> = Vec::new();
6434 for (idx, line) in reflowed_content_lines.iter().enumerate() {
6435 let force_explicit = idx == 0
6436 || continuation_style == BlockquoteContinuationStyle::Explicit
6437 || should_force_explicit_blockquote_line(line);
6438 if force_explicit {
6439 styled_lines.push(format!("{explicit_prefix}{line}"));
6440 } else {
6441 styled_lines.push(line.clone());
6442 }
6443 }
6444
6445 styled_lines
6446}
6447
6448fn is_blockquote_content_boundary(content: &str) -> bool {
6449 let trimmed = content.trim();
6450 trimmed.is_empty()
6451 || is_block_boundary(trimmed)
6452 || crate::utils::table_utils::TableUtils::is_potential_table_row(content)
6453 || trimmed.starts_with(":::")
6454 || crate::utils::is_template_directive_only(content)
6455 || is_standalone_attr_list(content)
6456 || is_snippet_block_delimiter(content)
6457}
6458
6459fn split_into_segments_strs<'a>(lines: &[&'a str], ends_in_code_span: &[bool]) -> Vec<Vec<(&'a str, bool)>> {
6463 let mut segments = Vec::new();
6464 let mut current = Vec::new();
6465 let inside_code_span = lines_touching_multiline_code_span(&lines.join("\n"));
6469
6470 for (idx, &line) in lines.iter().enumerate() {
6471 let is_display_math =
6476 is_self_contained_display_math_line(line) && !inside_code_span.get(idx).is_some_and(|&inside| inside);
6477 if is_display_math && !current.is_empty() {
6478 segments.push(std::mem::take(&mut current));
6479 }
6480 current.push((line, ends_in_code_span[idx]));
6481 if (has_hard_break(line) && !ends_in_code_span[idx]) || is_display_math {
6482 segments.push(std::mem::take(&mut current));
6483 }
6484 }
6485
6486 if !current.is_empty() {
6487 segments.push(current);
6488 }
6489
6490 segments
6491}
6492
6493fn reflow_blockquote_paragraph_at_line(
6494 content: &str,
6495 lines: &[&str],
6496 target_idx: usize,
6497 options: &ReflowOptions,
6498) -> Option<ParagraphReflow> {
6499 let mut anchor_idx = target_idx;
6500 let mut target_level = if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[target_idx]) {
6501 parsed.nesting_level
6502 } else {
6503 let mut found = None;
6504 let mut idx = target_idx;
6505 loop {
6506 if lines[idx].trim().is_empty() {
6507 break;
6508 }
6509 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[idx]) {
6510 found = Some((idx, parsed.nesting_level));
6511 break;
6512 }
6513 if idx == 0 {
6514 break;
6515 }
6516 idx -= 1;
6517 }
6518 let (idx, level) = found?;
6519 anchor_idx = idx;
6520 level
6521 };
6522
6523 let mut para_start = anchor_idx;
6525 while para_start > 0 {
6526 let prev_idx = para_start - 1;
6527 let prev_line = lines[prev_idx];
6528
6529 if prev_line.trim().is_empty() {
6530 break;
6531 }
6532
6533 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(prev_line) {
6534 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
6535 break;
6536 }
6537 para_start = prev_idx;
6538 continue;
6539 }
6540
6541 let prev_lazy = prev_line.trim_start();
6542 if is_blockquote_content_boundary(prev_lazy) {
6543 break;
6544 }
6545 para_start = prev_idx;
6546 }
6547
6548 while para_start < lines.len() {
6550 let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[para_start]) else {
6551 para_start += 1;
6552 continue;
6553 };
6554 target_level = parsed.nesting_level;
6555 break;
6556 }
6557
6558 if para_start >= lines.len() || para_start > target_idx {
6559 return None;
6560 }
6561
6562 let mut collected: Vec<(usize, BlockquoteLineData)> = Vec::new();
6565 let mut idx = para_start;
6566 while idx < lines.len() {
6567 if !collected.is_empty() && has_hard_break(&collected[collected.len() - 1].1.content) {
6568 break;
6569 }
6570
6571 let line = lines[idx];
6572 if line.trim().is_empty() {
6573 break;
6574 }
6575
6576 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(line) {
6577 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
6578 break;
6579 }
6580 collected.push((
6581 idx,
6582 BlockquoteLineData::explicit(trim_preserving_hard_break(parsed.content), parsed.prefix.to_string()),
6583 ));
6584 idx += 1;
6585 continue;
6586 }
6587
6588 let lazy_content = line.trim_start();
6589 if is_blockquote_content_boundary(lazy_content) {
6590 break;
6591 }
6592
6593 collected.push((idx, BlockquoteLineData::lazy(trim_preserving_hard_break(lazy_content))));
6594 idx += 1;
6595 }
6596
6597 if collected.is_empty() {
6598 return None;
6599 }
6600
6601 let para_end = collected[collected.len() - 1].0;
6602 if target_idx < para_start || target_idx > para_end {
6603 return None;
6604 }
6605
6606 let line_data: Vec<BlockquoteLineData> = collected.iter().map(|(_, d)| d.clone()).collect();
6607
6608 let fallback_prefix = line_data
6609 .iter()
6610 .find_map(|d| d.prefix.clone())
6611 .unwrap_or_else(|| "> ".to_string());
6612 let explicit_prefix = dominant_blockquote_prefix(&line_data, &fallback_prefix);
6613 let continuation_style = blockquote_continuation_style(&line_data);
6614
6615 let adjusted_line_length = options
6616 .line_length
6617 .saturating_sub(display_len(&explicit_prefix, options.length_mode))
6618 .max(1);
6619
6620 let adjusted_options = if options.sentence_pack && continuation_style == BlockquoteContinuationStyle::Lazy {
6621 ReflowOptions {
6622 first_line_length: if options.line_length == 0 || options.line_length == usize::MAX {
6623 None
6624 } else {
6625 Some(adjusted_line_length)
6626 },
6627 ..options.clone()
6628 }
6629 } else {
6630 ReflowOptions {
6631 line_length: adjusted_line_length,
6632 first_line_length: None,
6633 ..options.clone()
6634 }
6635 };
6636
6637 let styled_lines = reflow_blockquote_content(&line_data, &explicit_prefix, continuation_style, &adjusted_options);
6638
6639 if styled_lines.is_empty() {
6640 return None;
6641 }
6642
6643 let mut start_byte = 0;
6645 for line in lines.iter().take(para_start) {
6646 start_byte += line.len() + 1;
6647 }
6648
6649 let mut end_byte = start_byte;
6650 for line in lines.iter().take(para_end + 1).skip(para_start) {
6651 end_byte += line.len() + 1;
6652 }
6653
6654 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
6655 if !includes_trailing_newline {
6656 end_byte -= 1;
6657 }
6658
6659 let reflowed_joined = styled_lines.join("\n");
6660 let reflowed_text = if includes_trailing_newline {
6661 if reflowed_joined.ends_with('\n') {
6662 reflowed_joined
6663 } else {
6664 format!("{reflowed_joined}\n")
6665 }
6666 } else if reflowed_joined.ends_with('\n') {
6667 reflowed_joined.trim_end_matches('\n').to_string()
6668 } else {
6669 reflowed_joined
6670 };
6671
6672 Some(ParagraphReflow {
6673 start_byte,
6674 end_byte,
6675 reflowed_text,
6676 })
6677}
6678
6679pub fn reflow_paragraph_at_line(content: &str, line_number: usize, line_length: usize) -> Option<ParagraphReflow> {
6697 reflow_paragraph_at_line_with_mode(content, line_number, line_length, ReflowLengthMode::default())
6698}
6699
6700pub fn reflow_paragraph_at_line_with_mode(
6702 content: &str,
6703 line_number: usize,
6704 line_length: usize,
6705 length_mode: ReflowLengthMode,
6706) -> Option<ParagraphReflow> {
6707 let options = ReflowOptions {
6708 line_length,
6709 length_mode,
6710 ..Default::default()
6711 };
6712 reflow_paragraph_at_line_with_options(content, line_number, &options)
6713}
6714
6715pub fn reflow_paragraph_at_line_with_options(
6726 content: &str,
6727 line_number: usize,
6728 options: &ReflowOptions,
6729) -> Option<ParagraphReflow> {
6730 if line_number == 0 {
6731 return None;
6732 }
6733
6734 let lines: Vec<&str> = content.lines().collect();
6735
6736 if line_number > lines.len() {
6738 return None;
6739 }
6740
6741 let target_idx = line_number - 1; let target_line = lines[target_idx];
6743 let trimmed = target_line.trim();
6744
6745 if let Some(blockquote_reflow) = reflow_blockquote_paragraph_at_line(content, &lines, target_idx, options) {
6748 return Some(blockquote_reflow);
6749 }
6750
6751 if is_paragraph_boundary(trimmed, target_line) {
6753 return None;
6754 }
6755
6756 let mut para_start = target_idx;
6758 while para_start > 0 {
6759 let prev_idx = para_start - 1;
6760 let prev_line = lines[prev_idx];
6761 let prev_trimmed = prev_line.trim();
6762
6763 if is_paragraph_boundary(prev_trimmed, prev_line) {
6765 break;
6766 }
6767
6768 para_start = prev_idx;
6769 }
6770
6771 let mut para_end = target_idx;
6773 while para_end + 1 < lines.len() {
6774 let next_idx = para_end + 1;
6775 let next_line = lines[next_idx];
6776 let next_trimmed = next_line.trim();
6777
6778 if is_paragraph_boundary(next_trimmed, next_line) {
6780 break;
6781 }
6782
6783 para_end = next_idx;
6784 }
6785
6786 let paragraph_lines = &lines[para_start..=para_end];
6788
6789 let mut start_byte = 0;
6791 for line in lines.iter().take(para_start) {
6792 start_byte += line.len() + 1; }
6794
6795 let mut end_byte = start_byte;
6796 for line in paragraph_lines {
6797 end_byte += line.len() + 1; }
6799
6800 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
6803
6804 if !includes_trailing_newline {
6806 end_byte -= 1;
6807 }
6808
6809 let paragraph_text = paragraph_lines.join("\n");
6811
6812 let reflowed = reflow_markdown(¶graph_text, options);
6814
6815 let reflowed_text = if includes_trailing_newline {
6819 if reflowed.ends_with('\n') {
6821 reflowed
6822 } else {
6823 format!("{reflowed}\n")
6824 }
6825 } else {
6826 if reflowed.ends_with('\n') {
6828 reflowed.trim_end_matches('\n').to_string()
6829 } else {
6830 reflowed
6831 }
6832 };
6833
6834 Some(ParagraphReflow {
6835 start_byte,
6836 end_byte,
6837 reflowed_text,
6838 })
6839}
6840fn decompose_code_span(raw: &str) -> Option<(&str, &str)> {
6846 let marker_len = raw.bytes().take_while(|&b| b == b'`').count();
6847 if marker_len == 0 {
6848 return None;
6849 }
6850 let marker = &raw[..marker_len];
6851 if raw.len() < marker_len * 2 {
6852 return None;
6853 }
6854 let content = &raw[marker_len..raw.len() - marker_len];
6855 Some((content, marker))
6856}
6857
6858#[cfg(test)]
6859mod tests {
6860 use super::*;
6861
6862 #[test]
6863 fn source_sentence_breaks_respect_custom_abbreviations_and_atomic_constructs() {
6864 let custom = Some(vec!["Widget".to_string()]);
6865 assert_eq!(
6866 join_sentence_lines(
6867 &["Use Widget.", "then retry."],
6868 CjkSoftBreak::Space,
6869 &custom,
6870 LinkTextPunctuation::Ignore,
6871 None,
6872 ),
6873 "Use Widget. then retry."
6874 );
6875 for lines in [
6876 vec!["Read `literal.", "text` here."],
6877 vec here."],
6878 vec!["Read <span title=\"literal.", "text\"> here."],
6879 ] {
6880 assert_eq!(
6881 join_sentence_lines(&lines, CjkSoftBreak::Space, &None, LinkTextPunctuation::Ignore, None),
6882 lines.join(" ")
6883 );
6884 }
6885 assert_eq!(
6886 join_sentence_lines(
6887 &["First sentence.", "lowercase follows."],
6888 CjkSoftBreak::Space,
6889 &None,
6890 LinkTextPunctuation::Ignore,
6891 None,
6892 ),
6893 "First sentence.\nlowercase follows."
6894 );
6895 }
6896
6897 #[test]
6901 fn preserves_content_accepts_whitespace_changes_and_rejects_the_rest() {
6902 let accepted: &[(&str, &[&str])] = &[
6903 ("one two three", &["one two three"]),
6904 ("one two three", &["one two", "three"]),
6905 ("one two three", &["one", "two", "three"]),
6906 ("one two ", &["one two"]),
6908 ("日本語のテキスト", &["日本語の", "テキスト"]),
6910 ("_First. Second._", &["_First.", "Second._"]),
6912 ];
6913 for (original, reflowed) in accepted {
6914 let reflowed: Vec<String> = reflowed.iter().map(ToString::to_string).collect();
6915 assert!(
6916 preserves_content(original, &reflowed),
6917 "{original:?} -> {reflowed:?} only moves whitespace"
6918 );
6919 }
6920
6921 let rejected: &[(&str, &[&str])] = &[
6922 ("one two three", &["one two"]),
6924 ("one two", &["one two three"]),
6926 ("one two", &["two one"]),
6928 ("_First. Second._", &["_First._", "_Second._"]),
6930 ("alpha and beta", &["alpha", "andbeta"]),
6932 ("mot suivant : autre", &["mot suivant: autre"]),
6934 ];
6935 for (original, reflowed) in rejected {
6936 let reflowed: Vec<String> = reflowed.iter().map(ToString::to_string).collect();
6937 assert!(
6938 !preserves_content(original, &reflowed),
6939 "{original:?} -> {reflowed:?} changes the text, not just its line breaks"
6940 );
6941 }
6942 }
6943
6944 #[test]
6947 fn nested_structure_reads_past_indentation_starting_the_text() {
6948 for indent in [" ", "\t", " "] {
6949 let text = format!("{indent}run `cargo test` now");
6950 let start = text.find('`').unwrap();
6951 let end = text.rfind('`').unwrap() + 1;
6952 assert_eq!(
6953 nested_structure(&text, None, false).code_spans,
6954 vec![(start, end)],
6955 "{text:?}"
6956 );
6957 }
6958 assert!(code_span_runs_into_indentation(&[" `cargo test", " --all`"]));
6959 }
6960
6961 #[test]
6964 fn lines_touching_multiline_code_span_reads_an_indented_code_block_as_code() {
6965 assert_eq!(
6966 lines_touching_multiline_code_span(" `a\n $$x`$$\n"),
6967 [false, false]
6968 );
6969 assert_eq!(lines_touching_multiline_code_span("Run `a\n$$x`$$\n"), [true, true]);
6970 }
6971
6972 #[test]
6974 fn reflow_line_falls_back_to_the_input_when_content_would_change() {
6975 let options = ReflowOptions {
6976 line_length: 40,
6977 ..Default::default()
6978 };
6979 let line = "one two three four five six seven eight nine ten";
6980
6981 assert!(preserves_content(line, &reflow_line(line, &options)));
6982 assert_eq!(reflow_line(line, &options), reflow_line_unchecked(line, &options));
6983 }
6984
6985 #[test]
6986 fn cascade_split_line_handles_a_very_long_line_without_overflowing() {
6987 let words: Vec<String> = (0..4000).map(|i| format!("word{i}")).collect();
6993 let line = words.join(" ");
6994
6995 let options = ReflowOptions {
6996 line_length: 80,
6997 length_mode: ReflowLengthMode::Chars,
6998 ..Default::default()
6999 };
7000 let out = cascade_split_line(&line, &options);
7001
7002 assert!(out.len() > 1, "a very long line should split into many lines");
7003 for segment in &out {
7004 assert!(
7005 display_len(segment, ReflowLengthMode::Chars) <= 80 || !segment.contains(' '),
7006 "each wrapped line should fit the width (or be a single unbreakable token)"
7007 );
7008 }
7009 let rejoined = out.join(" ");
7011 let original_words: Vec<&str> = line.split(' ').collect();
7012 let result_words: Vec<&str> = rejoined.split_whitespace().collect();
7013 assert_eq!(original_words, result_words, "reflow must preserve all words in order");
7014 }
7015
7016 #[test]
7021 fn test_helper_function_text_ends_with_abbreviation() {
7022 let abbreviations = get_abbreviations(&None);
7024
7025 assert!(text_ends_with_abbreviation("Dr.", &abbreviations));
7027 assert!(text_ends_with_abbreviation("word Dr.", &abbreviations));
7028 assert!(text_ends_with_abbreviation("e.g.", &abbreviations));
7029 assert!(text_ends_with_abbreviation("i.e.", &abbreviations));
7030 assert!(text_ends_with_abbreviation("Mr.", &abbreviations));
7031 assert!(text_ends_with_abbreviation("Mrs.", &abbreviations));
7032 assert!(text_ends_with_abbreviation("Ms.", &abbreviations));
7033 assert!(text_ends_with_abbreviation("Prof.", &abbreviations));
7034
7035 assert!(!text_ends_with_abbreviation("etc.", &abbreviations));
7037 assert!(!text_ends_with_abbreviation("paradigms.", &abbreviations));
7038 assert!(!text_ends_with_abbreviation("programs.", &abbreviations));
7039 assert!(!text_ends_with_abbreviation("items.", &abbreviations));
7040 assert!(!text_ends_with_abbreviation("systems.", &abbreviations));
7041 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)); }
7047
7048 #[test]
7049 fn test_footnote_after_period_splits_sentence() {
7050 let text = "First sentence.[^1] Second sentence.";
7054 let sentences = split_into_sentences(text, None, true, CjkSoftBreak::Space);
7055 assert_eq!(
7056 sentences,
7057 vec!["First sentence.[^1]".to_string(), "Second sentence.".to_string()],
7058 "footnote glued to the period should keep the boundary and stay attached to the first sentence"
7059 );
7060 }
7061
7062 #[test]
7063 fn test_multiple_consecutive_footnotes_after_period_splits_sentence() {
7064 let text = "Notes here.[^1][^2] Second sentence.";
7066 let sentences = split_into_sentences(text, None, true, CjkSoftBreak::Space);
7067 assert_eq!(
7068 sentences,
7069 vec!["Notes here.[^1][^2]".to_string(), "Second sentence.".to_string()]
7070 );
7071 }
7072
7073 #[test]
7074 fn test_footnote_before_period_still_splits_sentence() {
7075 let text = "Annotation here[^1]. Second sentence.";
7079 let sentences = split_into_sentences(text, None, true, CjkSoftBreak::Space);
7080 assert_eq!(
7081 sentences,
7082 vec!["Annotation here[^1].".to_string(), "Second sentence.".to_string()]
7083 );
7084 }
7085
7086 #[test]
7087 fn test_mid_sentence_footnote_does_not_split() {
7088 let text = "The system word[^1] more words. Next sentence.";
7091 let sentences = split_into_sentences(text, None, true, CjkSoftBreak::Space);
7092 assert_eq!(
7093 sentences,
7094 vec![
7095 "The system word[^1] more words.".to_string(),
7096 "Next sentence.".to_string()
7097 ]
7098 );
7099 }
7100
7101 #[test]
7102 fn test_bare_numeric_bracket_after_period_does_not_split() {
7103 let text = "Citation here.[1] Second sentence.";
7106 let sentences = split_into_sentences(text, None, true, CjkSoftBreak::Space);
7107 assert_eq!(
7108 sentences,
7109 vec![text.to_string()],
7110 "a bare numeric bracket must not be treated as a sentence boundary"
7111 );
7112 }
7113
7114 #[test]
7115 fn test_footnote_glued_to_following_word_does_not_split() {
7116 let text = "First sentence.[^1]Continued glued text.";
7119 let sentences = split_into_sentences(text, None, true, CjkSoftBreak::Space);
7120 assert_eq!(sentences, vec![text.to_string()]);
7121 }
7122
7123 #[test]
7124 fn test_footnote_at_end_of_text_is_preserved() {
7125 let text = "Sentence.[^1]";
7128 let sentences = split_into_sentences(text, None, true, CjkSoftBreak::Space);
7129 assert_eq!(sentences, vec![text.to_string()]);
7130 }
7131
7132 #[test]
7133 fn test_abbreviation_before_footnote_does_not_split() {
7134 let text = "See the notes, e.g.[^1] this one.";
7137 let sentences = split_into_sentences(text, None, true, CjkSoftBreak::Space);
7138 assert_eq!(
7139 sentences,
7140 vec![text.to_string()],
7141 "e.g. is an abbreviation, not a sentence boundary"
7142 );
7143 }
7144
7145 #[test]
7146 fn sentence_boundary_never_falls_inside_an_atomic_construct() {
7147 let cases = [
7153 "Prefix [link. Still link](https://example.com) tail. Next sentence.",
7154 "Prefix [target](<https://example.com/First. Second>) tail. Next sentence.",
7155 "Prefix [text](url \"Title. More\") tail. Next sentence.",
7156 "Prefix  tail. Next sentence.",
7157 "Prefix [ref text. More][ref] tail. Next sentence.",
7158 "Prefix [collapsed. More][] tail. Next sentence.",
7159 "Prefix [[Page name. Title]] tail. Next sentence.",
7160 "Prefix $x. Y$ tail. Next sentence.",
7161 "Prefix $$x. Y$$ tail. Next sentence.",
7162 "Prefix <span title=\"A. B\">x</span> tail. Next sentence.",
7163 "Prefix `code. Still code` tail. Next sentence.",
7164 ];
7165 for text in cases {
7166 let sentences = split_into_sentences(text, None, true, CjkSoftBreak::Space);
7167 let (head, tail) = text.rsplit_once(" tail. ").expect("case has a tail");
7168 assert_eq!(
7169 sentences,
7170 vec![format!("{head} tail."), tail.to_string()],
7171 "input {text:?}"
7172 );
7173 }
7174
7175 let text = "Prefix [shortcut. More] tail. Next sentence.";
7179 let whole = vec![
7180 "Prefix [shortcut. More] tail.".to_string(),
7181 "Next sentence.".to_string(),
7182 ];
7183 let defined = HashSet::from(["shortcut. more".to_string()]);
7184 assert_eq!(
7185 split_into_sentences(text, Some(&defined), true, CjkSoftBreak::Space),
7186 whole
7187 );
7188 assert_eq!(split_into_sentences(text, None, true, CjkSoftBreak::Space), whole);
7189 assert_eq!(
7190 split_into_sentences(text, Some(&HashSet::new()), true, CjkSoftBreak::Space),
7191 vec!["Prefix [shortcut.", "More] tail.", "Next sentence."]
7192 );
7193 }
7194
7195 #[test]
7196 fn a_sentence_may_open_with_a_link_or_image() {
7197 for text in [
7202 "Opening sentence. [First. Second](https://example.com)",
7203 "Opening sentence. ",
7204 "Opening sentence. [[First. Second]]",
7205 "Opening sentence. [[first-note|First. Second]]",
7206 "Opening sentence. [Ref link][ref]",
7207 "Opening sentence. [](url) continues.",
7210 "Opening sentence. [][ref] continues.",
7211 "Opening sentence. [![First image][img]](url) continues.",
7214 "Opening sentence. [![First image][]](url) continues.",
7215 "Opening sentence. [![First image][img]][ref] continues.",
7216 ] {
7217 let (head, tail) = text.split_once(". ").expect("case has a boundary");
7218 assert_eq!(
7219 split_into_sentences(text, None, true, CjkSoftBreak::Space),
7220 vec![format!("{head}."), tail.to_string()],
7221 "input {text:?}"
7222 );
7223 }
7224 let text = "Opening sentence. [![First image]](url) continues.";
7227 let defined = HashSet::from(["first image".to_string()]);
7228 assert_eq!(
7229 split_into_sentences(text, Some(&defined), true, CjkSoftBreak::Space),
7230 vec!["Opening sentence.", "[![First image]](url) continues."]
7231 );
7232 assert_eq!(
7233 split_into_sentences(text, Some(&HashSet::new()), true, CjkSoftBreak::Space),
7234 vec![text.to_string()],
7235 "an undefined shortcut is bracketed text, and `!` opens no sentence"
7236 );
7237 assert_eq!(
7240 split_into_sentences(
7241 "Opening sentence. [](url) continues.",
7242 None,
7243 true,
7244 CjkSoftBreak::Space
7245 ),
7246 vec](url) continues."]
7247 );
7248 let defined = HashSet::from(["smith 2020".to_string()]);
7251 assert_eq!(
7252 split_into_sentences(
7253 "Claim ends here. [Smith 2020] more text.",
7254 Some(&defined),
7255 true,
7256 CjkSoftBreak::Space
7257 ),
7258 vec!["Claim ends here.", "[Smith 2020] more text."]
7259 );
7260 let none_defined = HashSet::new();
7266 for text in [
7267 "Opening sentence. [first link](https://example.com) continues.",
7268 "Opening sentence. [[first note]] continues.",
7269 "Opening sentence. [[First Note|first note]] continues.",
7270 "Opening sentence. [[Page continues.",
7271 "Opening sentence. [[First] stray]] continues.",
7272 "Opening sentence.  continues.",
7273 "Opening sentence. [1] is the citation.",
7274 "Opening sentence. [First](unterminated",
7275 "Opening sentence. [First][unterminated",
7276 "Opening sentence. [First] (aside) continues.",
7277 "Claim ends here. [Smith 2020]",
7278 "Claim ends here. [Smith 2020] more text.",
7279 "See the RFC. [RFC] More text.",
7280 "Claim ends here. [^Note] more text.",
7281 ] {
7282 assert_eq!(
7283 split_into_sentences(text, Some(&none_defined), true, CjkSoftBreak::Space),
7284 vec![text.to_string()],
7285 "input {text:?}"
7286 );
7287 }
7288 }
7289
7290 #[test]
7291 fn link_opener_is_read_off_the_parse() {
7292 let len = |text: &str, defs: Option<&HashSet<String>>| {
7295 let chars: Vec<char> = text.chars().collect();
7296 let char_offsets = char_byte_offsets(&chars);
7297 let NestedStructure { links, .. } = sentence_structure(text, defs);
7298 let st = SentenceText {
7299 text,
7300 chars: &chars,
7301 char_offsets: &char_offsets,
7302 links: &links,
7303 code_spans: &[],
7304 markers: &[],
7305 marker_closers: &[],
7306 paragraph: None,
7307 cjk_soft_break: CjkSoftBreak::Space,
7308 };
7309 st.link_end_at(0).map_or(0, |end| link_opener_len(&chars, 0, end))
7310 };
7311 let none = HashSet::new();
7312 assert_eq!(len("[text](url)", Some(&none)), 1);
7313 assert_eq!(
7314 len("[text][ref]", Some(&none)),
7315 1,
7316 "a full reference is a link whether or not defined"
7317 );
7318 assert_eq!(len("[text][]", Some(&none)), 1);
7319 assert_eq!(len("", Some(&none)), 2);
7320 assert_eq!(len("[[wiki]]", Some(&none)), 2);
7321 assert_eq!(
7322 len("[[wiki|shown]]", Some(&none)),
7323 7,
7324 "the displayed text starts after the alias pipe"
7325 );
7326 assert_eq!(len("![[img.png|100]]", Some(&none)), 11);
7327 assert_eq!(len("[[wiki|a|b]]", Some(&none)), 7, "the first pipe starts the alias");
7328 assert_eq!(
7329 len("[[wiki|shown]] [[a|b]]", Some(&none)),
7330 7,
7331 "a pipe past the closing `]]` is not this alias"
7332 );
7333 assert_eq!(
7334 len("[a \\] b](url)", Some(&none)),
7335 1,
7336 "an escaped bracket does not close the text"
7337 );
7338 assert_eq!(
7339 len("[](url)", Some(&none)),
7340 1,
7341 "the outer opener is skipped first"
7342 );
7343 for text in [
7347 "[^1]",
7348 "[text](unterminated",
7349 "[text][unterminated",
7350 "[text] (url)",
7351 "[[wiki",
7352 "[[wiki]",
7353 "[[First] stray]]",
7354 "[Smith 2020]",
7355 "[Smith 2020] (see also)",
7356 "[unclosed",
7357 "!bang",
7358 "text",
7359 ] {
7360 assert_eq!(len(text, Some(&none)), 0, "input {text:?}");
7361 }
7362 let smith = HashSet::from(["smith 2020".to_string()]);
7365 assert_eq!(len("[Smith 2020]", Some(&smith)), 1);
7366 assert_eq!(len("[Smith 2020]", None), 1);
7367 }
7368
7369 #[test]
7370 fn sentence_per_line_reflow_breaks_before_a_bracket_only_where_the_check_counts() {
7371 let defined = HashSet::from(["spec".to_string()]);
7379 let options = ReflowOptions {
7380 line_length: 120,
7381 sentence_per_line: true,
7382 defined_references: Some(defined.clone()),
7383 ..Default::default()
7384 };
7385 for (text, expected) in [
7386 (
7387 "Claim ends here. [Smith](https://example.com) more text. Second sentence.",
7388 vec more text.",
7391 "Second sentence.",
7392 ],
7393 ),
7394 (
7395 "Wow! [smith](https://example.com) more text. Second sentence.",
7396 vec more text.", "Second sentence."],
7397 ),
7398 (
7399 "Claim ends here. [smith](https://example.com) more text. Second sentence.",
7400 vec more text.",
7402 "Second sentence.",
7403 ],
7404 ),
7405 (
7406 "Claim ends here. [smith][ref] more text. Second sentence.",
7407 vec!["Claim ends here. [smith][ref] more text.", "Second sentence."],
7408 ),
7409 (
7410 "Claim ends here.  more text. Second sentence.",
7411 vec more text.", "Second sentence."],
7412 ),
7413 (
7414 "Claim ends here.[Link](https://example.com) more text. Second sentence.",
7415 vec more text.",
7417 "Second sentence.",
7418 ],
7419 ),
7420 (
7421 "See the RFC. [RFC] More text. Second sentence.",
7422 vec!["See the RFC. [RFC] More text.", "Second sentence."],
7423 ),
7424 (
7425 "See the spec. [Spec] More text. Second sentence.",
7426 vec!["See the spec.", "[Spec] More text.", "Second sentence."],
7427 ),
7428 (
7429 "See the spec. [spec] more text. Second sentence.",
7430 vec!["See the spec. [spec] more text.", "Second sentence."],
7431 ),
7432 (
7433 "Claim ends here. [[page|Second sentence]] continues. Third sentence.",
7434 vec![
7435 "Claim ends here.",
7436 "[[page|Second sentence]] continues.",
7437 "Third sentence.",
7438 ],
7439 ),
7440 (
7441 "Claim ends here. [[Page|second sentence]] continues. Third sentence.",
7442 vec![
7443 "Claim ends here. [[Page|second sentence]] continues.",
7444 "Third sentence.",
7445 ],
7446 ),
7447 ] {
7448 let lines = reflow_line(text, &options);
7449 assert_eq!(lines, expected, "input {text:?}");
7450 assert_eq!(
7453 split_into_sentences(text, Some(&defined), true, CjkSoftBreak::Space).len(),
7454 expected.len(),
7455 "check count for {text:?}"
7456 );
7457 for line in &lines {
7458 assert_eq!(
7459 split_into_sentences(line, Some(&defined), true, CjkSoftBreak::Space).len(),
7460 1,
7461 "line {line:?} of {text:?}"
7462 );
7463 }
7464 }
7465 }
7466
7467 #[test]
7468 fn sentence_per_line_reflow_holds_atomic_constructs_whole() {
7469 let options = ReflowOptions {
7473 line_length: 80,
7474 sentence_per_line: true,
7475 ..Default::default()
7476 };
7477 let lines = reflow_line(
7478 "Prefix `code. Still code` and [link. Still link](https://example.com) tail. Next sentence.",
7479 &options,
7480 );
7481 assert_eq!(
7482 lines,
7483 vec tail.".to_string(),
7485 "Next sentence.".to_string(),
7486 ]
7487 );
7488
7489 let lines = reflow_line(
7490 "Prefix  and [target](<https://example.com/First. Second>) tail. Next sentence.",
7491 &options,
7492 );
7493 assert_eq!(
7494 lines,
7495 vec and [target](<https://example.com/First. Second>) tail.".to_string(),
7497 "Next sentence.".to_string(),
7498 ]
7499 );
7500
7501 let lines = reflow_line("First one. Then [link](url) second. Third one.", &options);
7504 assert_eq!(
7505 lines,
7506 vec second.".to_string(),
7509 "Third one.".to_string(),
7510 ]
7511 );
7512 }
7513
7514 #[test]
7515 fn test_is_unordered_list_marker() {
7516 assert!(is_unordered_list_marker("- item"));
7518 assert!(is_unordered_list_marker("* item"));
7519 assert!(is_unordered_list_marker("+ item"));
7520 assert!(is_unordered_list_marker("-")); assert!(is_unordered_list_marker("*"));
7522 assert!(is_unordered_list_marker("+"));
7523
7524 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")); }
7535
7536 #[test]
7537 fn test_is_block_boundary() {
7538 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"));
7560 assert!(!is_block_boundary("*emphasis*")); assert!(!is_block_boundary("[link](url)")); assert!(!is_block_boundary("some words here"));
7563 }
7564
7565 #[test]
7566 fn test_definition_list_boundary_in_single_line_paragraph() {
7567 let options = ReflowOptions {
7570 line_length: 80,
7571 ..Default::default()
7572 };
7573 let input = "Term\n: Definition of the term";
7574 let result = reflow_markdown(input, &options);
7575 assert!(
7577 result.contains(": Definition"),
7578 "Definition list item should not be merged into previous line. Got: {result:?}"
7579 );
7580 let lines: Vec<&str> = result.lines().collect();
7581 assert_eq!(lines.len(), 2, "Should remain two separate lines. Got: {lines:?}");
7582 assert_eq!(lines[0], "Term");
7583 assert_eq!(lines[1], ": Definition of the term");
7584 }
7585
7586 #[test]
7587 fn test_is_paragraph_boundary() {
7588 assert!(is_paragraph_boundary("# Heading", "# Heading"));
7590 assert!(is_paragraph_boundary("- item", "- item"));
7591 assert!(is_paragraph_boundary(":::", ":::"));
7592 assert!(is_paragraph_boundary(": definition", ": definition"));
7593
7594 assert!(is_paragraph_boundary("code", " code"));
7596 assert!(is_paragraph_boundary("code", "\tcode"));
7597
7598 assert!(is_paragraph_boundary("| a | b |", "| a | b |"));
7600 assert!(is_paragraph_boundary("a | b", "a | b")); assert!(!is_paragraph_boundary("regular text", "regular text"));
7604 assert!(!is_paragraph_boundary("text", " text")); }
7606
7607 #[test]
7608 fn test_div_marker_boundary_in_reflow_paragraph_at_line() {
7609 let content = "Some paragraph text here.\n\n::: {.callout-note}\nThis is a callout.\n:::\n";
7612 let result = reflow_paragraph_at_line(content, 3, 80);
7614 assert!(result.is_none(), "Div marker line should not be reflowed");
7615 }
7616
7617 #[test]
7618 fn starts_block_construct_detects_block_openers() {
7619 for case in ["- item", "-", "* item", "*", "+ item", "+", "-\titem"] {
7621 assert!(starts_block_construct(case), "bullet: {case:?}");
7622 }
7623 for case in ["1. item", "1) item", "01. x", "000000001. x", "1.\titem"] {
7626 assert!(starts_block_construct(case), "ordered: {case:?}");
7627 }
7628 for case in ["> quote", ">quote", ">"] {
7630 assert!(starts_block_construct(case), "blockquote: {case:?}");
7631 }
7632 for case in ["# heading", "###### h6", "#", "##"] {
7634 assert!(starts_block_construct(case), "heading: {case:?}");
7635 }
7636 for case in [
7639 "#hashtag",
7640 "#help-desk.",
7641 "#123",
7642 "##tag",
7643 "######x",
7644 "#\u{1F600}",
7645 "#*x",
7646 "#_x",
7647 ] {
7648 assert!(starts_block_construct(case), "heading missing space: {case:?}");
7649 }
7650 for case in ["```", "```rust", "````", "~~~", "~~~text"] {
7652 assert!(starts_block_construct(case), "fence: {case:?}");
7653 }
7654 for case in ["---", "--", "===", "=", "***", "___", "_ _ _", "- - -"] {
7656 assert!(starts_block_construct(case), "setext/thematic: {case:?}");
7657 }
7658 for case in [": definition", ":\tdefinition", ":", ": ", "::: note"] {
7661 assert!(starts_block_construct(case), "definition list: {case:?}");
7662 }
7663 for case in [
7666 "[^1]: text",
7667 "[^note]:",
7668 "[ref]: http://example.com",
7669 "[wat]: url follows",
7670 ] {
7671 assert!(starts_block_construct(case), "definition: {case:?}");
7672 }
7673 for case in ["<div>content", "</div>", "<p>text", "<table>", "<pre>code", "<h1>x"] {
7675 assert!(starts_block_construct(case), "html block: {case:?}");
7676 }
7677 for case in ["<!-- note -->", "<!-->", "<?php", "<!DOCTYPE html>", "<![CDATA[x]]>"] {
7679 assert!(starts_block_construct(case), "untagged html block: {case:?}");
7680 }
7681 for case in [
7684 "--- | ---",
7685 "------ | - |",
7686 "-|-",
7687 "--:|:--",
7688 "- | -",
7689 "---|",
7690 "-- |\t:-:",
7691 ] {
7692 assert!(starts_block_construct(case), "table delimiter row: {case:?}");
7693 }
7694 }
7695
7696 #[test]
7697 fn starts_block_construct_allows_ordinary_prose() {
7698 for case in [
7699 "",
7700 "word",
7701 "-5 degrees",
7702 "--flag",
7703 "-item",
7704 "####### seven hashes is not a heading",
7705 "#######x",
7706 "a #b",
7707 "x#y",
7708 "7#8",
7709 "1.5 million",
7710 "1234567890. ten digits is not a list marker",
7711 "0000000001. ten digits is not a list marker either",
7712 "2. item",
7715 "7. item",
7716 "0. item",
7717 "42) x",
7718 "123456. item",
7719 "1.",
7720 "1)",
7721 "123456.",
7722 "123456)",
7723 "1.item",
7724 "1:30 pm",
7725 "*emphasis*",
7726 "**bold** text",
7727 "__bold__ text",
7728 "_emphasis_ text",
7729 "`code` span",
7730 "`` double backtick span ``",
7731 "~~strikethrough~~",
7732 "=x",
7733 "== ==",
7734 "(parenthetical)",
7735 "[link](url)",
7736 "[text][ref] more",
7737 "[bracketed] aside",
7738 "[a](b) [ref]: first bracket is a link, not a label",
7739 "[esc\\]: not a close] text",
7740 "<span>inline</span>",
7741 "<b>bold</b>",
7742 "<https://example.com> autolink",
7743 "<mailto:a@b.com>",
7744 "<notarealtag>",
7745 "--flag | grep x",
7746 "-- | it depends",
7747 "--- | -x |",
7748 ] {
7749 assert!(!starts_block_construct(case), "prose: {case:?}");
7750 }
7751 }
7752
7753 #[test]
7754 fn merge_block_construct_continuations_merges_marker_led_lines() {
7755 let lines = vec![
7756 "First sentence?".to_string(),
7757 "- looks like a list item".to_string(),
7758 "Second sentence.".to_string(),
7759 ];
7760 assert_eq!(
7761 merge_block_construct_continuations(lines),
7762 vec![
7763 "First sentence? - looks like a list item".to_string(),
7764 "Second sentence.".to_string(),
7765 ]
7766 );
7767
7768 let lines = vec!["- real list content".to_string(), "continuation".to_string()];
7771 assert_eq!(
7772 merge_block_construct_continuations(lines.clone()),
7773 lines,
7774 "first line must never be merged"
7775 );
7776
7777 let lines = vec!["prose".to_string(), "1.".to_string(), "[ref]:".to_string()];
7780 assert_eq!(
7781 merge_block_construct_continuations(lines),
7782 vec!["prose 1. [ref]:".to_string()],
7783 "a merge that creates an opener must fold again"
7784 );
7785 }
7786
7787 #[test]
7788 fn merge_block_construct_continuations_folds_a_line_after_a_backslash() {
7789 let lines = vec!["Press the \\".to_string(), "key.".to_string()];
7791 assert_eq!(
7792 merge_block_construct_continuations(lines),
7793 vec!["Press the \\ key.".to_string()]
7794 );
7795
7796 let lines = vec!["A path C:\\\\".to_string(), "here.".to_string()];
7799 assert_eq!(merge_block_construct_continuations(lines.clone()), lines);
7800
7801 let lines = vec!["Line one".to_string(), "ends here \\".to_string()];
7804 assert_eq!(merge_block_construct_continuations(lines.clone()), lines);
7805 }
7806
7807 #[test]
7808 fn wrap_never_starts_a_line_with_a_block_marker() {
7809 let options = ReflowOptions {
7810 line_length: 25,
7811 ..Default::default()
7812 };
7813 let lines = reflow_line(
7816 "Some words here and then - a dash clause that wraps around the limit.",
7817 &options,
7818 );
7819 assert_eq!(
7820 lines,
7821 vec![
7822 "Some words here and",
7823 "then - a dash clause that",
7824 "wraps around the limit."
7825 ]
7826 );
7827
7828 for input in [
7830 "Alpha beta gamma delta epsilon - dash clause here to wrap",
7831 "Alpha beta gamma delta epsilon > quote lookalike here to wrap",
7832 "Alpha beta gamma delta epsilon # heading lookalike here to wrap",
7833 "Alpha beta gamma delta epsilon 1. ordered lookalike here to wrap",
7834 "Alpha beta gamma delta epsilon * star clause here to wrap",
7835 "Alpha beta gamma delta epsilon + plus clause here to wrap",
7836 ] {
7837 for width in 10..40 {
7838 let options = ReflowOptions {
7839 line_length: width,
7840 ..Default::default()
7841 };
7842 for line in reflow_line(input, &options) {
7843 assert!(
7844 !starts_block_construct(&line),
7845 "width {width}: wrapped line opens a block construct: {line:?} (input {input:?})"
7846 );
7847 }
7848 }
7849 }
7850 }
7851
7852 #[test]
7855 fn hash_word_never_starts_a_wrapped_line_in_any_mode() {
7856 let options_for = |mode: &str, width: usize| ReflowOptions {
7857 line_length: width,
7858 sentence_per_line: mode == "sentence-per-line",
7859 semantic_line_breaks: mode == "semantic",
7860 ..Default::default()
7861 };
7862 let inputs = [
7863 "Alpha beta gamma delta epsilon #help-desk and the rest here to wrap",
7864 "Alpha beta gamma delta epsilon, ##tag and the rest here to wrap",
7865 "Alpha beta gamma delta fixed in #123, thanks everyone for the report here",
7866 "Is it open? #help-desk is. Really! #123 too. Yes? #1 also.",
7867 "Ask in the channel, #help-desk or elsewhere, and #123 maybe, then more.",
7868 "Questions about this guide? Post them in the channel #help-desk.",
7869 ];
7870 for mode in ["wrap", "sentence-per-line", "semantic"] {
7871 for input in inputs {
7872 for width in 10..60 {
7873 let lines = reflow_line(input, &options_for(mode, width));
7874 for line in &lines {
7875 let hashes = line.bytes().take_while(|&b| b == b'#').count();
7876 assert!(
7877 !starts_block_construct(line) && !(1..=6).contains(&hashes),
7878 "{mode} width {width}: line opens a block construct: {line:?} (input {input:?})"
7879 );
7880 }
7881 let words = |s: &str| s.split_whitespace().map(String::from).collect::<Vec<_>>();
7882 assert_eq!(
7883 words(&lines.join(" ")),
7884 words(input),
7885 "{mode} width {width}: content changed (input {input:?})"
7886 );
7887 }
7888 }
7889 }
7890 }
7891
7892 #[test]
7894 fn hash_controls_stay_where_they_are() {
7895 let options = ReflowOptions {
7896 line_length: 80,
7897 ..Default::default()
7898 };
7899 for input in [
7900 "Issue numbers like # alone and 7#8 and a#b stay mid-line",
7901 "Seven hashes #######y are prose and fixes #12 are fine here",
7902 ] {
7903 assert_eq!(reflow_line(input, &options), vec![input.to_string()]);
7904 }
7905 let options = ReflowOptions {
7907 line_length: 20,
7908 ..Default::default()
7909 };
7910 let lines = reflow_line("aaaa bbbb cccc dddd #######y zzzz", &options);
7911 assert!(
7912 lines.iter().any(|l| l.starts_with("#######y")),
7913 "7+ hashes may start a line: {lines:?}"
7914 );
7915 }
7916
7917 #[test]
7920 fn block_guard_covers_every_line_start_md018_acts_on() {
7921 use crate::config::MarkdownFlavor;
7922 use crate::lint_context::LintContext;
7923 use crate::rule::Rule;
7924 use crate::rules::MD018NoMissingSpaceAtx;
7925
7926 let rule = MD018NoMissingSpaceAtx::new();
7927 let mut acted_on = 0;
7928 for line in [
7929 "#a",
7930 "##a",
7931 "###a",
7932 "####a",
7933 "#####a",
7934 "######a",
7935 "#######a",
7936 "# a",
7937 "## a",
7938 "#",
7939 "##",
7940 "#1",
7941 "#123",
7942 "#help-desk.",
7943 "#*a",
7944 "#_a",
7945 "#\u{FE0F}",
7946 "#\u{1F600}",
7947 "#a#b",
7948 "##\u{00e9}",
7949 "#-x",
7950 "#.x",
7951 "#(x)",
7952 ] {
7953 let content = format!("para\n{line}\n");
7954 let ctx = LintContext::new(&content, MarkdownFlavor::Standard, None);
7955 let acts = ctx.lines[1].heading.is_some()
7956 || ctx.lines[1].atx_missing_space.is_some()
7957 || !rule.check(&ctx).unwrap().is_empty();
7958 if acts {
7959 acted_on += 1;
7960 assert!(
7961 starts_block_construct(line),
7962 "MD018/heading detection acts on {line:?} but the reflow guard allows it"
7963 );
7964 }
7965 }
7966 assert!(acted_on >= 10, "the corpus must exercise the detector, got {acted_on}");
7967 }
7968
7969 #[test]
7970 fn sentence_per_line_keeps_block_markers_mid_line() {
7971 let options = ReflowOptions {
7972 line_length: 80,
7973 sentence_per_line: true,
7974 ..Default::default()
7975 };
7976 let lines = reflow_line(
7979 "Google Calendar (Can't we get rid of this dependency? - I don't really see the need)",
7980 &options,
7981 );
7982 assert_eq!(
7983 lines,
7984 vec!["Google Calendar (Can't we get rid of this dependency? - I don't really see the need)".to_string()]
7985 );
7986
7987 let lines = reflow_line("See section 4? # is the marker we use. Fine.", &options);
7989 assert_eq!(lines, vec!["See section 4? # is the marker we use.", "Fine."]);
7990
7991 let lines = reflow_line("Is this a problem? > I quote someone here.", &options);
7992 assert_eq!(lines, vec!["Is this a problem? > I quote someone here."]);
7993
7994 let lines = reflow_line("Another case! 1. Not a list. More text follows here.", &options);
7995 for line in &lines {
7996 assert!(
7997 !starts_block_construct(line),
7998 "sentence-per-line output opens a block construct: {line:?}"
7999 );
8000 }
8001 }
8002
8003 fn strict_sentence_lines(input: &str, require_sentence_capital: bool) -> Vec<String> {
8005 let options = ReflowOptions {
8006 line_length: 80,
8007 sentence_per_line: true,
8008 require_sentence_capital,
8009 ..Default::default()
8010 };
8011 reflow_line(input, &options)
8012 }
8013
8014 #[test]
8015 fn strict_mode_lets_a_sentence_open_with_a_number() {
8016 for (input, expected) in [
8020 (
8021 "The number of items was 5. 2 of them failed.",
8022 vec!["The number of items was 5.", "2 of them failed."],
8023 ),
8024 (
8025 "Sometimes we have 2. 3 might be here.",
8026 vec!["Sometimes we have 2.", "3 might be here."],
8027 ),
8028 (
8029 "The number of items was 5. 2nd sentence.",
8030 vec!["The number of items was 5.", "2nd sentence."],
8031 ),
8032 (
8033 "Released in 2020. 3 of them failed.",
8034 vec!["Released in 2020.", "3 of them failed."],
8035 ),
8036 (
8037 "First sentence. 2nd sentence.",
8038 vec!["First sentence.", "2nd sentence."],
8039 ),
8040 (
8041 "We met at 6:00 sharp. 6:00 is early.",
8042 vec!["We met at 6:00 sharp.", "6:00 is early."],
8043 ),
8044 ("Pi is 3.14 roughly. Next.", vec!["Pi is 3.14 roughly.", "Next."]),
8045 (
8048 "A \"Is this a test?\" 2020 was memorable.",
8049 vec!["A \"Is this a test?\"", "2020 was memorable."],
8050 ),
8051 ] {
8052 assert_eq!(strict_sentence_lines(input, true), expected, "input {input:?}");
8053 }
8054
8055 for input in [
8058 "The count was 5. and that was all.",
8059 "See fig. 3 for details.",
8060 "See no. 5 in the list.",
8061 "See ch. 12 and vol. 3 for more.",
8062 "A \"Is this a test?\" guide to it.",
8063 ] {
8064 assert_eq!(
8065 strict_sentence_lines(input, true),
8066 vec![input.to_string()],
8067 "input {input:?}"
8068 );
8069 }
8070 }
8071
8072 #[test]
8073 fn sentence_never_opens_with_an_ordered_list_marker() {
8074 for (input, require_capital, expected) in [
8081 (
8082 "Steps: 1. Do this. 2. Do that.",
8083 true,
8084 vec!["Steps: 1.", "Do this. 2.", "Do that."],
8085 ),
8086 (
8087 "First sentence. 1. Do that.",
8088 true,
8089 vec!["First sentence. 1.", "Do that."],
8090 ),
8091 ("Do this! 2. Do that.", true, vec!["Do this! 2.", "Do that."]),
8092 ("Do this. 12) Do that.", true, vec!["Do this. 12) Do that."]),
8093 ("Do this. 2. do that.", true, vec!["Do this. 2. do that."]),
8094 ("Do this. 2. do that.", false, vec!["Do this. 2.", "do that."]),
8095 (
8096 "Twelve. 1234567890. next one here.",
8097 true,
8098 vec!["Twelve. 1234567890. next one here."],
8099 ),
8100 ("Do this. 2 more times.", true, vec!["Do this.", "2 more times."]),
8103 ("How many? 2.", true, vec!["How many?", "2."]),
8104 ("第一句。 2. Do that.", true, vec!["第一句。 2.", "Do that."]),
8106 ("第一句。 2) 第二句。", true, vec!["第一句。 2) 第二句。"]),
8107 ("第一句。 2 more.", true, vec!["第一句。", "2 more."]),
8108 ("第一句。 第二句。", true, vec!["第一句。", "第二句。"]),
8109 ] {
8110 let lines = strict_sentence_lines(input, require_capital);
8111 assert_eq!(lines, expected, "input {input:?}, require capital {require_capital}");
8112 for line in &lines {
8113 let chars: Vec<char> = line.chars().collect();
8114 assert!(
8115 !opens_ordered_list_marker(&chars),
8116 "line opens with an ordered-list marker: {line:?} (input {input:?})"
8117 );
8118 }
8119 }
8120 }
8121
8122 #[test]
8123 fn opens_ordered_list_marker_matches_the_marker_shape() {
8124 let chars = |s: &str| s.chars().collect::<Vec<char>>();
8125 for text in [
8126 "2. x",
8127 "1) x",
8128 "12. x",
8129 "1.\tx",
8130 "2.\nx",
8131 "1)\nx",
8132 "1234567890. x",
8133 "0. x",
8134 ] {
8135 assert!(opens_ordered_list_marker(&chars(text)), "{text:?} is a marker");
8136 }
8137 for text in ["2.x", "2.", "2)", "2 x", "x. y", "", " 2. x", "2.5 x", "-2. x"] {
8138 assert!(!opens_ordered_list_marker(&chars(text)), "{text:?} is not a marker");
8139 }
8140 }
8141
8142 #[test]
8143 fn inline_math_directly_after_display_math_stays_atomic() {
8144 let options = ReflowOptions {
8152 line_length: 8,
8153 ..Default::default()
8154 };
8155 let lines = reflow_line("$$a$$$bb cc dd$ x", &options);
8156 assert_eq!(lines, vec!["$$a$$$bb cc dd$".to_string(), "x".to_string()]);
8157 }
8158
8159 #[test]
8160 fn test_code_span_parsing() {
8161 let elements = parse_markdown_elements_inner("`code`", false, false, None);
8163 assert_eq!(elements.len(), 1);
8164 assert!(matches!(&elements[0], Element::Code { content, marker } if content == "code" && marker == "`"));
8165
8166 let elements = parse_markdown_elements_inner("``code``", false, false, None);
8168 assert_eq!(elements.len(), 1);
8169 assert!(matches!(&elements[0], Element::Code { content, marker } if content == "code" && marker == "``"));
8170
8171 let elements = parse_markdown_elements_inner("``code`inside``", false, false, None);
8173 assert_eq!(elements.len(), 1);
8174 assert!(
8175 matches!(&elements[0], Element::Code { content, marker } if content == "code`inside" && marker == "``")
8176 );
8177
8178 let elements = parse_markdown_elements_inner("`` code ``", false, false, None);
8180 assert_eq!(elements.len(), 1);
8181 assert!(matches!(&elements[0], Element::Code { content, marker } if content == " code " && marker == "``"));
8182
8183 let elements = parse_markdown_elements_inner("`unclosed", false, false, None);
8185 assert_eq!(elements.len(), 1);
8186 assert!(matches!(&elements[0], Element::Text(s) if s == "`unclosed"));
8187
8188 let elements = parse_markdown_elements_inner("`unclosed [link](url)", false, false, None);
8190 assert_eq!(elements.len(), 2);
8192 assert!(matches!(&elements[0], Element::Text(s) if s == "`unclosed "));
8193 assert!(matches!(&elements[1], Element::Link(s) if s == "[link](url)"));
8194 }
8195
8196 #[test]
8197 fn test_reflow_performance_long_input() {
8198 let mut text = String::new();
8201 for i in 1..400 {
8202 let backticks = "`".repeat(i);
8203 text.push_str(&backticks);
8204 text.push(' ');
8205 }
8206
8207 let start = std::time::Instant::now();
8208 let elements = parse_markdown_elements_inner(&text, false, false, None);
8209 let duration = start.elapsed();
8210
8211 assert!(duration.as_millis() < 100, "Parsing took too long: {duration:?}");
8213 assert!(!elements.is_empty());
8214 }
8215
8216 #[test]
8217 fn test_reflow_performance_display_math_heavy() {
8218 let text = "$$a$$".repeat(4000);
8223
8224 let start = std::time::Instant::now();
8225 let elements = parse_markdown_elements_inner(&text, false, false, None);
8226 let duration = start.elapsed();
8227
8228 assert!(duration.as_millis() < 100, "Parsing took too long: {duration:?}");
8229 assert_eq!(elements.len(), 4000);
8230 }
8231
8232 #[test]
8233 fn inline_math_len_at_start_matches_regex_at_slice_start() {
8234 let alphabet = ['$', 'a', ' '];
8239 let mut inputs: Vec<String> = vec![String::new()];
8240 let mut frontier: Vec<String> = vec![String::new()];
8241 for _ in 0..6 {
8242 let mut longer = Vec::new();
8243 for prefix in &frontier {
8244 for ch in alphabet {
8245 let mut s = prefix.clone();
8246 s.push(ch);
8247 longer.push(s);
8248 }
8249 }
8250 inputs.extend(longer.iter().cloned());
8251 frontier = longer;
8252 }
8253 inputs.push("$αβ$x".to_string());
8255 inputs.push("$α$$".to_string());
8256
8257 for s in &inputs {
8258 let expected = INLINE_MATH_REGEX
8259 .find(s)
8260 .ok()
8261 .flatten()
8262 .filter(|m| m.start() == 0)
8263 .map(|m| m.end());
8264 assert_eq!(inline_math_len_at_start(s), expected, "input: {s:?}");
8265 }
8266 }
8267
8268 #[test]
8269 fn inline_math_probe_after_dollar_matches_uncached_parse() {
8270 let cases = [
8276 ("$$a$$$b c$ x", r#"[DisplayMath("a"), InlineMath("b c"), Text(" x")]"#),
8277 (
8278 "$$a$$$b$ $$a$$$b$",
8279 r#"[DisplayMath("a"), InlineMath("b"), Text(" "), DisplayMath("a"), InlineMath("b")]"#,
8280 ),
8281 (
8283 "$$a$$$ x $y z$",
8284 r#"[DisplayMath("a"), InlineMath(" x "), Text("y z$")]"#,
8285 ),
8286 ("$$a$$$$ x", r#"[DisplayMath("a"), Text("$$ x")]"#),
8288 ("$$a$$$$b$$ x", r#"[DisplayMath("a"), DisplayMath("b"), Text(" x")]"#),
8289 (
8291 "$a$$b$$c$$d$ tail",
8292 r#"[Text("$a"), DisplayMath("b"), Text("c$$d$ tail")]"#,
8293 ),
8294 ];
8295 for (input, expected) in cases {
8296 let elements = parse_markdown_elements_inner(input, false, false, None);
8297 assert_eq!(format!("{elements:?}"), expected, "input: {input:?}");
8298 }
8299 }
8300
8301 #[test]
8302 fn test_atomic_spans() {
8303 let text_emphasis = "hello **word1 word2**";
8305
8306 let options_disabled = ReflowOptions {
8307 line_length: 18,
8308 atomic_spans: true,
8309 ..Default::default()
8310 };
8311 let lines_disabled = reflow_line(text_emphasis, &options_disabled);
8312 assert_eq!(lines_disabled, vec!["hello", "**word1 word2**"]);
8313
8314 let options_enabled = ReflowOptions {
8315 line_length: 18,
8316 atomic_spans: false,
8317 ..Default::default()
8318 };
8319 let lines_enabled = reflow_line(text_emphasis, &options_enabled);
8320 assert_eq!(lines_enabled, vec!["hello **word1", "word2**"]);
8321
8322 let text_code = "hello `word1 word2`";
8324
8325 let lines_code_disabled = reflow_line(text_code, &options_disabled);
8326 assert_eq!(lines_code_disabled, vec!["hello", "`word1 word2`"]);
8327
8328 let lines_code_enabled = reflow_line(text_code, &options_enabled);
8329 assert_eq!(lines_code_enabled, vec!["hello `word1", "word2`"]);
8330
8331 let text_code_padding = "hello `` `word1` `word2` ``";
8333 let lines_padding_enabled = reflow_line(text_code_padding, &options_enabled);
8334 assert_eq!(lines_padding_enabled, vec![r#"hello `` `word1`"#, r#"`word2` ``"#]);
8335
8336 let text_attached = "**one two**,"; let options_11 = ReflowOptions {
8341 line_length: 11,
8342 atomic_spans: true,
8343 ..Default::default()
8344 };
8345 assert_eq!(reflow_line(text_attached, &options_11), vec!["**one two**,"]);
8346
8347 let options_10 = ReflowOptions {
8349 line_length: 10,
8350 atomic_spans: true,
8351 ..Default::default()
8352 };
8353 assert_eq!(reflow_line(text_attached, &options_10), vec!["**one", "two**,"]);
8354 }
8355
8356 #[test]
8357 fn test_emphasis_containing_markers_is_not_split() {
8358 let options = ReflowOptions {
8359 line_length: 5,
8360 atomic_spans: false,
8361 ..Default::default()
8362 };
8363 let lines = reflow_line(r#"*foo \*bar*"#, &options);
8365 assert_eq!(lines, vec![r#"*foo \*bar*"#.to_string()]);
8366 }
8367
8368 fn semantic_shape(markdown: &str) -> String {
8373 let mut options = Options::empty();
8374 options.insert(Options::ENABLE_STRIKETHROUGH);
8375 let mut out = String::new();
8376 let push_prose = |out: &mut String, text: &str| {
8377 for c in text.chars() {
8378 if c.is_whitespace() {
8379 if !out.ends_with(char::is_whitespace) {
8380 out.push(' ');
8381 }
8382 } else {
8383 out.push(c);
8384 }
8385 }
8386 };
8387 for event in Parser::new_ext(markdown, options) {
8388 match event {
8389 Event::Text(text) => push_prose(&mut out, &text),
8390 Event::SoftBreak | Event::HardBreak => push_prose(&mut out, " "),
8391 Event::Code(code) => out.push_str(&format!("<code>{code}</code>")),
8393 Event::Start(tag) => out.push_str(&format!("<{tag:?}>")),
8394 Event::End(tag) => out.push_str(&format!("</{tag:?}>")),
8395 other => out.push_str(&format!("{other:?}")),
8396 }
8397 }
8398 out.trim().to_string()
8399 }
8400
8401 #[test]
8402 fn test_wrapping_a_span_never_changes_what_it_parses_to() {
8403 let corpus = [
8407 "_This is a very, very, very, very, very long line with some `code` inside._",
8408 "_alpha beta gamma delta epsilon `a b` zeta eta theta iota kappa lambda_",
8409 "**strong text with `code` and more words than fit on one single line**",
8410 "~~struck text with `code` and more words than fit on one single line~~",
8411 "_emphasis with **nested strong that is quite long** and trailing words_",
8412 "***A doubly nested bold italic span with more words than fit on a line***",
8415 "___Another doubly nested span with more words than fit on a single line___",
8416 "**_mixed strong then emphasis with more words than fit on a single line_**",
8417 "*__mixed emphasis then strong with more words than fit on a single line__*",
8418 "**~~strong strikethrough with more words than fit on a single line here~~**",
8419 "**a * b with a stray marker and plenty more words to pass the budget**",
8422 "_foo `a` bar `b` baz qux quux corge grault garply waldo fred plugh xyzzy_",
8423 "text before _a long emphasis with `code` inside of it here_ and after",
8424 "(_a parenthesized long emphasis with `code` inside of it right here_)",
8425 r#"*foo \*bar baz qux quux corge grault garply waldo fred plugh xyzzy*"#,
8426 "_tab\tseparated `a\tb` words spread out over quite a long emphasis span_",
8427 "_This has [text](<a b c d e f g h i j k>) and trailing words to wrap._",
8430 "_This has [`code`](<a b c d e f g h i j k>) and trailing words to wrap._",
8431 r#"_See [x](https://example.com "a long link title here") and `code` too._"#,
8432 "_A [link with a long label](https://example.com/path) and `code` here._",
8433 "_An image  plus `code` and more text_",
8434 ];
8435 for text in corpus {
8436 let expected = semantic_shape(text);
8437 for line_length in [20, 30, 40, 80] {
8438 for atomic_spans in [true, false] {
8439 let options = ReflowOptions {
8440 line_length,
8441 atomic_spans,
8442 ..Default::default()
8443 };
8444 let wrapped = reflow_line(text, &options).join("\n");
8445 assert_eq!(
8446 semantic_shape(&wrapped),
8447 expected,
8448 "reflow changed the parse of {text:?} at line_length={line_length} \
8449 atomic_spans={atomic_spans}\n wrapped: {wrapped:?}"
8450 );
8451 }
8452 }
8453 }
8454 }
8455
8456 #[test]
8457 fn test_wrapping_a_span_keeps_whole_the_constructs_pulldown_cannot_see() {
8458 let cases = [
8462 (
8463 "_alpha beta gamma [[a wiki link]] delta epsilon zeta eta_",
8464 "[[a wiki link]]",
8465 ),
8466 (
8467 "_alpha beta gamma {{< foo bar >}} delta epsilon zeta eta_",
8468 "{{< foo bar >}}",
8469 ),
8470 ("_alpha beta gamma $a + b$ delta epsilon zeta eta theta_", "$a + b$"),
8471 ("_alpha beta gamma $$a + b$$ delta epsilon zeta eta theta_", "$$a + b$$"),
8472 ];
8473 for (text, construct) in cases {
8474 for line_length in [12, 20, 30] {
8475 for atomic_spans in [true, false] {
8476 let options = ReflowOptions {
8477 line_length,
8478 atomic_spans,
8479 ..Default::default()
8480 };
8481 let wrapped = reflow_line(text, &options).join("\n");
8482 assert!(
8483 wrapped.contains(construct),
8484 "{construct} was broken at line_length={line_length} \
8485 atomic_spans={atomic_spans}: {wrapped:?}"
8486 );
8487 }
8488 }
8489 }
8490 }
8491
8492 #[test]
8493 fn test_overlong_emphasis_with_nested_code_span_wraps() {
8494 let options = ReflowOptions {
8498 line_length: 80,
8499 atomic_spans: true,
8500 ..Default::default()
8501 };
8502 let text = "_This is a very, very, very, very, very, very, very long line that exceeds 80 characters with some `code` inside._";
8503 let lines = reflow_line(text, &options);
8504 assert_eq!(
8505 lines,
8506 vec![
8507 "_This is a very, very, very, very, very, very, very long line that exceeds 80",
8508 "characters with some `code` inside._",
8509 ]
8510 );
8511 }
8512
8513 #[test]
8514 fn test_overlong_emphasis_with_nested_strong_wraps() {
8515 let options = ReflowOptions {
8517 line_length: 80,
8518 atomic_spans: true,
8519 ..Default::default()
8520 };
8521 let text = "_This is a very, very, very, very, very, very, very long line that exceeds 80 characters with some **bold** inside._";
8522 let lines = reflow_line(text, &options);
8523 assert_eq!(
8524 lines,
8525 vec![
8526 "_This is a very, very, very, very, very, very, very long line that exceeds 80",
8527 "characters with some **bold** inside._",
8528 ]
8529 );
8530 }
8531
8532 #[test]
8533 fn test_overlong_doubly_nested_span_wraps() {
8534 let options = ReflowOptions {
8539 line_length: 80,
8540 atomic_spans: true,
8541 ..Default::default()
8542 };
8543 let body = "This is a very, very, very, very, very, very, very, very, very, very long line that is emphasised.";
8544 for (open, close) in [
8545 ("***", "***"),
8546 ("___", "___"),
8547 ("**_", "_**"),
8548 ("*__", "__*"),
8549 ("**~~", "~~**"),
8550 ] {
8551 let text = format!("{open}{body}{close}");
8552 assert!(text.len() > options.line_length, "case must start over budget");
8553 let lines = reflow_line(&text, &options);
8554 assert!(
8555 lines.len() > 1,
8556 "{open}...{close} should wrap but stayed on one line: {lines:?}"
8557 );
8558 assert!(
8559 lines.iter().all(|line| line.len() <= options.line_length),
8560 "{open}...{close} left a line over the budget: {lines:?}"
8561 );
8562 assert_eq!(
8563 lines.join(" "),
8564 text,
8565 "{open}...{close} wrapping must only replace a space with a newline"
8566 );
8567 }
8568 }
8569
8570 #[test]
8571 fn test_overlong_span_with_stray_marker_stays_whole() {
8572 let options = ReflowOptions {
8576 line_length: 40,
8577 atomic_spans: true,
8578 ..Default::default()
8579 };
8580 let text = "**alpha * beta gamma delta epsilon zeta eta theta iota kappa**";
8581 let lines = reflow_line(text, &options);
8582 assert_eq!(lines, vec![text], "stray marker must keep the span whole");
8583 }
8584
8585 #[test]
8586 fn test_overlong_span_never_breaks_inside_a_nested_reference_link() {
8587 let options = ReflowOptions {
8593 line_length: 30,
8594 atomic_spans: true,
8595 defined_references: Some(HashSet::from([
8596 "ref".to_string(),
8597 "one two three four five six seven".to_string(),
8599 ])),
8600 ..Default::default()
8601 };
8602 for (text, link) in [
8603 (
8604 "_**alpha [one two three four five six seven][ref] beta gamma delta**_",
8605 "[one two three four five six seven][ref]",
8606 ),
8607 (
8608 "**alpha [one two three four five six seven][ref] beta gamma delta**",
8609 "[one two three four five six seven][ref]",
8610 ),
8611 (
8612 "_**alpha ![one two three four five six seven][ref] beta gamma delta**_",
8613 "![one two three four five six seven][ref]",
8614 ),
8615 (
8616 "_**alpha [one two three four five six seven][] beta gamma delta**_",
8617 "[one two three four five six seven][]",
8618 ),
8619 (
8620 "_**alpha [one two three four five six seven] beta gamma delta**_",
8621 "[one two three four five six seven]",
8622 ),
8623 ] {
8624 let lines = reflow_line(text, &options);
8625 assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
8626 assert!(
8627 lines.iter().any(|line| line.contains(link)),
8628 "{link} must stay on one line: {lines:?}"
8629 );
8630 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
8631 }
8632 }
8633
8634 #[test]
8635 fn test_overlong_span_breaks_inside_an_undefined_shortcut_reference() {
8636 let options = ReflowOptions {
8640 line_length: 30,
8641 atomic_spans: true,
8642 defined_references: Some(HashSet::new()),
8643 ..Default::default()
8644 };
8645 let text = "_**alpha [one two three four five six seven] beta gamma delta**_";
8646 let lines = reflow_line(text, &options);
8647 assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
8648 assert!(
8649 !lines
8650 .iter()
8651 .any(|line| line.contains("[one two three four five six seven]")),
8652 "an undefined shortcut is prose and should break: {lines:?}"
8653 );
8654 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
8655 }
8656
8657 #[test]
8658 fn test_overlong_span_never_breaks_inside_a_nested_attr_list() {
8659 let attr = "{.highlight key=\"a b c\"}";
8663 let text = format!("_**alpha beta gamma delta epsilon zeta{attr} eta theta iota kappa**_");
8664 let options = ReflowOptions {
8665 line_length: 20,
8666 atomic_spans: true,
8667 attr_lists: true,
8668 ..Default::default()
8669 };
8670 let lines = reflow_line(&text, &options);
8671 assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
8672 assert!(
8673 lines.iter().any(|line| line.contains(attr)),
8674 "attr list must stay on one line: {lines:?}"
8675 );
8676 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
8677
8678 let plain = ReflowOptions {
8681 attr_lists: false,
8682 ..options
8683 };
8684 let lines = reflow_line(&text, &plain);
8685 assert!(
8686 !lines.iter().any(|line| line.contains(attr)),
8687 "without the flavor the braces are prose and should break: {lines:?}"
8688 );
8689 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
8690 }
8691
8692 #[test]
8693 fn test_overlong_emphasis_never_breaks_inside_nested_code_span() {
8694 let options = ReflowOptions {
8698 line_length: 30,
8699 atomic_spans: true,
8700 ..Default::default()
8701 };
8702 let text = "_alpha beta gamma delta epsilon `a b` zeta eta theta iota kappa_";
8703 let lines = reflow_line(text, &options);
8704 assert!(lines.len() > 1, "over-long emphasis should wrap: {lines:?}");
8705 assert!(
8706 lines.iter().any(|line| line.contains("`a b`")),
8707 "nested code span must stay whole with its interior spaces: {lines:?}"
8708 );
8709 for line in &lines {
8710 assert_eq!(
8711 line.matches('`').count() % 2,
8712 0,
8713 "no line may contain half a code span: {line:?}"
8714 );
8715 }
8716 }
8717
8718 #[test]
8719 fn test_definition_list_marker_does_not_start_line() {
8720 let options = ReflowOptions {
8721 line_length: 20,
8722 ..Default::default()
8723 };
8724 let lines = reflow_line("This is a term and : definition here.", &options);
8726 for line in &lines {
8727 assert!(
8728 !line.trim_start().starts_with(": "),
8729 "Wrapped line should not start with definition marker: {line}"
8730 );
8731 }
8732 }
8733
8734 #[test]
8735 fn test_div_marker_does_not_start_line() {
8736 let options = ReflowOptions {
8737 line_length: 20,
8738 ..Default::default()
8739 };
8740 let lines = reflow_line("This is some text with ::: class marker.", &options);
8742 for line in &lines {
8743 assert!(
8744 !line.trim_start().starts_with(":::"),
8745 "Wrapped line should not start with div marker: {line}"
8746 );
8747 }
8748 }
8749
8750 fn reflow_markdown_preserving_rendering(content: &str, options: &ReflowOptions) -> String {
8763 let render = |text: &str| {
8764 let mut parser_options = Options::empty();
8765 parser_options.insert(Options::ENABLE_STRIKETHROUGH);
8766 parser_options.insert(Options::ENABLE_DEFINITION_LIST);
8767 let mut html = String::new();
8768 pulldown_cmark::html::push_html(&mut html, Parser::new_ext(text, parser_options));
8769 html.retain(|c| !c.is_ascii_whitespace());
8770 html
8771 };
8772 let reflowed = reflow_markdown(content, options);
8773 assert_eq!(
8774 render(content),
8775 render(&reflowed),
8776 "rendering changed for input: {content:?}"
8777 );
8778 reflowed
8779 }
8780
8781 #[test]
8789 fn a_paragraph_part_never_ends_inside_an_atomic_construct() {
8790 let options = ReflowOptions {
8791 line_length: 0,
8792 sentence_per_line: true,
8793 ..Default::default()
8794 };
8795
8796 let cases = [
8797 ("[(完成。)\n继续。](url)", "[(完成。) 继续。](url)"),
8799 ("`(完成。)\n继续。`", "`(完成。) 继续。`"),
8801 ("[Done.\nNext](url)", "[Done. Next](url)"),
8804 ("(完成。)\n继续。", "(完成。)\n继续。"),
8806 ("Done. Next", "Done.\nNext"),
8807 ];
8808
8809 for (input, expected) in cases {
8810 assert_eq!(
8811 reflow_markdown_preserving_rendering(input, &options),
8812 expected,
8813 "input: {input:?}"
8814 );
8815 }
8816 }
8817
8818 #[test]
8821 fn a_bare_colon_line_ends_the_paragraph_above_it() {
8822 let options = ReflowOptions {
8823 line_length: 0,
8824 sentence_per_line: true,
8825 ..Default::default()
8826 };
8827
8828 for input in ["文章です。\n:", "Done!\n:", "完成。\n:", "Term\n:\nNext term\n: text"] {
8829 assert_eq!(
8830 reflow_markdown_preserving_rendering(input, &options),
8831 input,
8832 "input: {input:?}"
8833 );
8834 }
8835 }
8836
8837 #[test]
8842 fn a_colon_leading_the_first_line_of_a_paragraph_is_prose() {
8843 let options = ReflowOptions {
8844 line_length: 0,
8845 sentence_per_line: true,
8846 ..Default::default()
8847 };
8848
8849 for (input, expected) in [
8850 (
8851 ":warning: First sentence. Second sentence.",
8852 ":warning: First sentence.\nSecond sentence.",
8853 ),
8854 (
8855 "Term\n\n:warning: First sentence. Second sentence.",
8856 "Term\n\n:warning: First sentence.\nSecond sentence.",
8857 ),
8858 (
8859 "# Heading\n:warning: First sentence. Second sentence.",
8860 "# Heading\n:warning: First sentence.\nSecond sentence.",
8861 ),
8862 ] {
8863 assert_eq!(
8864 reflow_markdown_preserving_rendering(input, &options),
8865 expected,
8866 "input: {input:?}"
8867 );
8868 }
8869 for input in [
8870 "Term\n:warning: First sentence. Second sentence.",
8871 ":warning: First sentence.\n:note: Second sentence.",
8872 "- term\n :warning: First sentence. Second sentence.",
8873 ] {
8874 assert_eq!(
8875 reflow_markdown_preserving_rendering(input, &options),
8876 input,
8877 "input: {input:?}"
8878 );
8879 }
8880 }
8881}
8882
8883#[cfg(test)]
8884mod shortcode_delimiter_tests {
8885 use super::*;
8886
8887 #[test]
8888 fn reflow_preserves_shortcode_arguments_containing_closing_markers() {
8889 let options = ReflowOptions {
8890 line_length: 30,
8891 ..Default::default()
8892 };
8893 for (open, close) in [("{{<", ">}}"), ("{{%", "%}}")] {
8894 for quote in ["`", "\""] {
8895 let tag =
8896 format!("{open} note title={quote}literal {close} friend with some words and more{quote} {close}");
8897 let line = format!("Before {tag} after with more prose words.");
8898 let fixed = reflow_line(&line, &options).join("\n");
8899 assert!(fixed.contains(&tag), "{line:?} -> {fixed:?}");
8900 assert!(fixed.contains('\n'), "surrounding prose should still reflow");
8901 }
8902 }
8903 }
8904}