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, text_ends_with_numbered_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 || text_ends_with_numbered_abbreviation(
1246 &text[..byte_offset_after_punct],
1247 &text[st.byte_at(first_letter_pos)..],
1248 )
1249 {
1250 return None;
1251 }
1252
1253 if single_letter_initial(chars, pos, next_char_pos, first_char) {
1258 return None;
1259 }
1260 }
1261
1262 let elision = pos > 0 && chars[pos - 1] == '.';
1273 let digit_run = pos > 0 && chars[pos - 1].is_numeric();
1274 let bare = space_pos == pos + 1;
1275
1276 let numeric_label = digit_run && bare && {
1281 let mut start = pos;
1282 while start > 0 && chars[start - 1].is_ascii_digit() {
1283 start -= 1;
1284 }
1285 start == 0 || chars[start - 1] == '.' || chars[..start].iter().rev().find(|c| !c.is_whitespace()) == Some(&':')
1286 };
1287 static SENTENCE_FINAL_ABBREVIATIONS: LazyLock<HashSet<String>> =
1291 LazyLock::new(|| ["etc", "inc", "ph.d", "u.s"].into_iter().map(String::from).collect());
1292 let ends_with_optional_abbreviation =
1293 text_ends_with_abbreviation(&text[..byte_offset_after_punct], &SENTENCE_FINAL_ABBREVIATIONS);
1294 if chars[space_pos..next_char_pos].contains(&'\n')
1295 && !inside_quotation
1296 && !elision
1297 && !numeric_label
1298 && !ends_with_optional_abbreviation
1299 {
1300 return Some(space_pos);
1301 }
1302
1303 if st.opens_code_span(first_letter_pos) && !elision && !(digit_run && bare) {
1309 return Some(space_pos);
1310 }
1311
1312 if require_sentence_capital && !opens_sentence_in_strict_mode(first_char) {
1315 return None;
1316 }
1317
1318 Some(space_pos)
1319}
1320
1321fn single_letter_initial(chars: &[char], period: usize, next_word: usize, first_char: char) -> bool {
1324 if period == 0 || !chars[period - 1].is_ascii_uppercase() || (period > 1 && !chars[period - 2].is_whitespace()) {
1325 return false;
1326 }
1327
1328 let before = &chars[..period - 1];
1329 let end = before.iter().rposition(|c| !c.is_whitespace()).map_or(0, |i| i + 1);
1330 if end == 0 {
1331 return true;
1332 }
1333 let before = &before[..end];
1334 let start = before.iter().rposition(|c| c.is_whitespace()).map_or(0, |i| i + 1);
1335 let previous_word = &before[start..];
1336 let previous_ender = previous_word
1337 .iter()
1338 .rev()
1339 .copied()
1340 .find(|&c| !matches!(c, '*' | '_' | '~') && !is_closing_quote(c) && !is_closing_bracket(c));
1341 if previous_word
1344 .iter()
1345 .any(|&c| matches!(c, '.' | '!' | '?' | '<' | '>' | '&') || is_cjk_sentence_ending(c))
1346 || previous_ender.is_some_and(continues_clause)
1347 || !previous_word
1348 .iter()
1349 .find(|c| c.is_alphabetic())
1350 .is_some_and(|c| c.is_lowercase())
1351 || !opens_sentence_in_strict_mode(first_char)
1352 {
1353 return true;
1354 }
1355
1356 chars.get(next_word).is_some_and(char::is_ascii_uppercase)
1359 && chars.get(next_word + 1) == Some(&'.')
1360 && chars.get(next_word + 2).is_none_or(|c| c.is_whitespace())
1361}
1362
1363fn marker_run_end(chars: &[char], from: usize) -> Option<usize> {
1371 let end = marker_run_extent(chars, from);
1372 matches!(chars.get(end), Some(' ' | '\n')).then_some(end)
1373}
1374
1375fn opens_sentence_in_strict_mode(first_char: char) -> bool {
1383 first_char.is_uppercase() || first_char.is_numeric() || is_cjk_char(first_char)
1384}
1385
1386fn continues_clause(c: char) -> bool {
1389 matches!(
1390 c,
1391 ',' | ';' | ':' | '.' | '!' | '?' | '、' | ',' | ';' | ':' | '.' | '。' | '、'
1392 ) || is_cjk_sentence_ending(c)
1393}
1394
1395fn opens_ordered_list_marker(chars: &[char]) -> bool {
1401 let digits = chars.iter().take_while(|c| c.is_ascii_digit()).count();
1402 digits > 0
1403 && matches!(chars.get(digits), Some('.' | ')'))
1404 && matches!(chars.get(digits + 1), Some(' ' | '\t' | '\n'))
1405}
1406
1407fn link_opener_len(chars: &[char], pos: usize, end: usize) -> usize {
1413 let open = if chars[pos] == '!' { pos + 1 } else { pos };
1414 let body = open + 1;
1415 if chars.get(body) != Some(&'[') {
1416 return body - pos;
1417 }
1418 let body = body + 1;
1419 let alias = chars[body..end.saturating_sub(2).max(body)]
1420 .iter()
1421 .position(|&c| c == '|')
1422 .map_or(body, |p| body + p + 1);
1423 alias - pos
1424}
1425
1426fn parse_terminal_links(
1433 text: &str,
1434 references: Option<&HashSet<String>>,
1435 abbreviations: &HashSet<String>,
1436) -> Vec<TerminalLink> {
1437 if !text.contains('[') {
1438 return Vec::new();
1439 }
1440 struct Label {
1441 start: usize,
1442 link_type: LinkType,
1443 visible: String,
1444 literal_terminal: bool,
1445 }
1446 let resolve = |link: BrokenLink<'_>| {
1447 references
1448 .filter(|defs| defs.contains(&normalize_reference_label(&link.reference)))
1449 .map(|_| (CowStr::Borrowed(""), CowStr::Borrowed("")))
1450 };
1451 let parser = Parser::new_with_broken_link_callback(
1452 text,
1453 Options::ENABLE_STRIKETHROUGH | Options::ENABLE_FOOTNOTES,
1454 Some(resolve),
1455 )
1456 .into_offset_iter();
1457 let mut labels: Vec<Label> = Vec::new();
1458 let mut result = Vec::new();
1459 for (event, range) in parser {
1460 match event {
1461 Event::Start(Tag::Link { link_type, .. } | Tag::Image { link_type, .. }) => {
1462 labels.push(Label {
1463 start: range.start,
1464 link_type,
1465 visible: String::new(),
1466 literal_terminal: false,
1467 });
1468 }
1469 Event::Text(value) => {
1470 if let Some(label) = labels.last_mut() {
1471 label.visible.push_str(&value);
1472 let visible = value.trim_end_matches(is_closing_quote);
1473 if let Some(last) = visible.chars().next_back() {
1474 let source = text[range].trim_end_matches(is_closing_quote);
1476 label.literal_terminal =
1477 (matches!(last, '.' | '!' | '?') || is_cjk_sentence_ending(last)) && source.ends_with(last);
1478 }
1479 }
1480 }
1481 Event::End(TagEnd::Link | TagEnd::Image) => {
1482 let Some(label) = labels.pop() else { continue };
1483 let mut end = range.end;
1484 if matches!(label.link_type, LinkType::Collapsed | LinkType::CollapsedUnknown)
1485 && text[end..].starts_with("[]")
1486 {
1487 end += 2;
1491 }
1492 if let Some(parent) = labels.last_mut() {
1493 parent.visible.push_str(&label.visible);
1494 parent.literal_terminal =
1495 label.literal_terminal && !matches!(label.link_type, LinkType::Autolink | LinkType::Email);
1496 } else if !matches!(label.link_type, LinkType::Autolink | LinkType::Email)
1497 && label.literal_terminal
1498 && let Some(punctuation) = label_terminator(&label.visible, abbreviations)
1499 {
1500 result.push(TerminalLink {
1501 start: label.start,
1502 end,
1503 punctuation,
1504 });
1505 }
1506 }
1507 Event::Code(_)
1508 | Event::Html(_)
1509 | Event::InlineHtml(_)
1510 | Event::InlineMath(_)
1511 | Event::DisplayMath(_)
1512 | Event::FootnoteReference(_)
1513 | Event::SoftBreak
1514 | Event::HardBreak => {
1515 if let Some(label) = labels.last_mut() {
1516 label.visible.push(' ');
1517 label.literal_terminal = false;
1518 }
1519 }
1520 _ => {}
1521 }
1522 }
1523 result
1524}
1525
1526fn label_terminator(label: &str, abbreviations: &HashSet<String>) -> Option<char> {
1530 let label = label.trim_end_matches(is_closing_quote);
1531 let punctuation = label.chars().next_back()?;
1532 if !matches!(punctuation, '.' | '!' | '?') && !is_cjk_sentence_ending(punctuation) {
1533 return None;
1534 }
1535 let prefix = &label[..label.len() - punctuation.len_utf8()];
1536 if !prefix.chars().any(char::is_alphanumeric) {
1537 return None;
1538 }
1539 if punctuation != '.' {
1542 return Some(punctuation);
1543 }
1544 if punctuation == '.'
1546 && label[..label.len() - 1]
1547 .chars()
1548 .next_back()
1549 .is_some_and(char::is_numeric)
1550 {
1551 return None;
1552 }
1553 const STRICT_OPENER: &str = " Next";
1555 let probe = format!("{label}{STRICT_OPENER}");
1556 let chars: Vec<char> = probe.chars().collect();
1557 let offsets = char_byte_offsets(&chars);
1558 let st = SentenceText {
1559 text: &probe,
1560 chars: &chars,
1561 char_offsets: &offsets,
1562 links: &[],
1563 code_spans: &[],
1564 markers: &[],
1565 marker_closers: &[],
1566 paragraph: None,
1567 cjk_soft_break: CjkSoftBreak::Space,
1568 };
1569 let pos = label.chars().count() - 1;
1570 debug_assert_eq!(chars[pos], punctuation);
1571 sentence_boundary(&st, pos, abbreviations, true).map(|_| punctuation)
1572}
1573
1574fn terminal_link_boundary(st: &SentenceText<'_>, pos: usize, punctuation: char) -> Option<usize> {
1578 let cut = cjk_sentence_end(st, pos)?;
1579 let chars = st.chars;
1580 let mut resume = cut;
1581 while resume < chars.len() && is_breakable_whitespace(chars[resume]) {
1582 resume += 1;
1583 }
1584 if resume == chars.len() || opens_ordered_list_marker(&chars[resume..]) {
1585 return None;
1586 }
1587 if is_cjk_sentence_ending(punctuation) {
1588 let break_dropped = st.cjk_soft_break == CjkSoftBreak::Join
1589 && joins_cjk_soft_break(chars[cut - 1])
1590 && joins_cjk_soft_break(chars[resume]);
1591 if (resume > cut) == break_dropped || continues_clause(chars[resume]) {
1592 return None;
1593 }
1594 } else {
1595 if resume == cut {
1596 return None;
1597 }
1598 let mut first = resume;
1599 while first < chars.len() {
1600 if let Some(end) = st.link_end_at(first) {
1601 first += link_opener_len(chars, first, end);
1602 } else if matches!(chars[first], '*' | '_' | '~') || is_opening_quote(chars[first]) {
1603 first += 1;
1604 } else {
1605 break;
1606 }
1607 }
1608 if !chars.get(first).is_some_and(|&c| opens_sentence_in_strict_mode(c)) {
1609 return None;
1610 }
1611 }
1612 st.cut_keeps_text(cut, resume).then_some(cut)
1613}
1614
1615fn sentence_structure_with_links(
1616 text: &str,
1617 references: Option<&HashSet<String>>,
1618 abbreviations: &HashSet<String>,
1619 policy: LinkTextPunctuation,
1620 semantic: bool,
1621) -> NestedStructure {
1622 let mut structure = if semantic {
1623 semantic_sentence_structure(text, references)
1624 } else {
1625 sentence_structure(text, references)
1626 };
1627 if policy == LinkTextPunctuation::Trailing {
1628 structure.terminal_links = parse_terminal_links(text, references, abbreviations);
1629 }
1630 structure
1631}
1632
1633pub fn split_into_sentences(
1646 text: &str,
1647 defined_references: Option<&HashSet<String>>,
1648 require_sentence_capital: bool,
1649 cjk_soft_break: CjkSoftBreak,
1650) -> Vec<String> {
1651 let abbreviations = get_abbreviations(&None);
1652 split_into_sentences_with_set(
1653 text,
1654 &abbreviations,
1655 require_sentence_capital,
1656 cjk_soft_break,
1657 defined_references,
1658 None,
1659 LinkTextPunctuation::Ignore,
1660 )
1661}
1662
1663pub(crate) fn split_into_sentences_with_link_text(
1665 text: &str,
1666 references: Option<&HashSet<String>>,
1667 require_sentence_capital: bool,
1668 cjk: CjkSoftBreak,
1669 policy: LinkTextPunctuation,
1670 abbreviations: &Option<Vec<String>>,
1671) -> Vec<String> {
1672 let abbreviations = get_abbreviations(if policy == LinkTextPunctuation::Trailing {
1675 abbreviations
1676 } else {
1677 &None
1678 });
1679 split_into_sentences_with_set(
1680 text,
1681 &abbreviations,
1682 require_sentence_capital,
1683 cjk,
1684 references,
1685 None,
1686 policy,
1687 )
1688}
1689
1690pub(crate) fn semantic_sentence_count(
1693 text: &str,
1694 defined_references: Option<&HashSet<String>>,
1695 require_sentence_capital: bool,
1696 cjk_soft_break: CjkSoftBreak,
1697 abbreviations: &Option<Vec<String>>,
1698 link_text_punctuation: LinkTextPunctuation,
1699) -> usize {
1700 let abbreviations = get_abbreviations(abbreviations);
1701 let structure =
1702 sentence_structure_with_links(text, defined_references, &abbreviations, link_text_punctuation, true);
1703 split_into_sentence_ranges(
1704 text,
1705 &abbreviations,
1706 require_sentence_capital,
1707 cjk_soft_break,
1708 defined_references,
1709 Some(ParagraphStructure {
1710 text,
1711 structure: &structure,
1712 base: 0,
1713 }),
1714 link_text_punctuation,
1715 )
1716 .len()
1717}
1718
1719fn split_into_sentences_with_set(
1727 text: &str,
1728 abbreviations: &HashSet<String>,
1729 require_sentence_capital: bool,
1730 cjk_soft_break: CjkSoftBreak,
1731 defined_references: Option<&HashSet<String>>,
1732 paragraph: Option<ParagraphStructure<'_>>,
1733 link_text_punctuation: LinkTextPunctuation,
1734) -> Vec<String> {
1735 split_into_sentence_ranges(
1736 text,
1737 abbreviations,
1738 require_sentence_capital,
1739 cjk_soft_break,
1740 defined_references,
1741 paragraph,
1742 link_text_punctuation,
1743 )
1744 .into_iter()
1745 .map(|(start, end)| text[start..end].to_string())
1746 .collect()
1747}
1748
1749fn trim_range(text: &str, start: usize, end: usize) -> Option<(usize, usize)> {
1752 let slice = &text[start..end];
1753 let leading = slice.len() - slice.trim_start_matches(is_breakable_whitespace).len();
1754 let trailing = slice.len() - slice.trim_end_matches(is_breakable_whitespace).len();
1755 (leading + trailing < slice.len()).then(|| (start + leading, end - trailing))
1756}
1757
1758fn split_into_sentence_ranges(
1766 text: &str,
1767 abbreviations: &HashSet<String>,
1768 require_sentence_capital: bool,
1769 cjk_soft_break: CjkSoftBreak,
1770 defined_references: Option<&HashSet<String>>,
1771 paragraph: Option<ParagraphStructure<'_>>,
1772 link_text_punctuation: LinkTextPunctuation,
1773) -> Vec<(usize, usize)> {
1774 let char_vec: Vec<char> = text.chars().collect();
1775 let char_offsets = char_byte_offsets(&char_vec);
1776
1777 let NestedStructure {
1781 parentheticals,
1782 terminal_links,
1783 atomic,
1784 links,
1785 code_spans,
1786 markers,
1787 marker_closers,
1788 } = match paragraph {
1789 Some(paragraph) => paragraph.window(text.len()),
1790 None => sentence_structure_with_links(text, defined_references, abbreviations, link_text_punctuation, false),
1791 };
1792 let mut atomic_it = atomic.iter().peekable();
1793 let st = SentenceText {
1794 text,
1795 chars: &char_vec,
1796 char_offsets: &char_offsets,
1797 links: &links,
1798 code_spans: &code_spans,
1799 markers: &markers,
1800 marker_closers: &marker_closers,
1801 paragraph,
1802 cjk_soft_break,
1803 };
1804
1805 let next_sentence_start = |cut: usize| if char_vec.get(cut) == Some(&' ') { cut + 1 } else { cut };
1808
1809 let mut cuts: Vec<usize> = Vec::new();
1813 let mut pos = 0;
1814
1815 while pos < char_vec.len() {
1816 let byte_idx = char_offsets[pos];
1817
1818 while let Some(&&(_, end)) = atomic_it.peek() {
1820 if end <= byte_idx {
1821 atomic_it.next();
1822 } else {
1823 break;
1824 }
1825 }
1826
1827 let in_atomic = atomic_it
1829 .peek()
1830 .is_some_and(|&&(start, end)| byte_idx >= start && byte_idx < end);
1831
1832 let boundary = if in_atomic && !terminal_links.is_empty() {
1833 let end = st.byte_at(pos + 1);
1834 terminal_links
1838 .binary_search_by_key(&end, |link| link.end)
1839 .ok()
1840 .filter(|_| atomic_it.peek().is_some_and(|&&(_, atomic_end)| atomic_end == end))
1841 .and_then(|index| terminal_link_boundary(&st, pos, terminal_links[index].punctuation))
1842 } else if !in_atomic {
1843 sentence_boundary(&st, pos, abbreviations, require_sentence_capital)
1844 } else {
1845 None
1846 };
1847 if let Some(cut) = boundary
1848 && !inside_parenthetical(char_offsets[cut], &parentheticals)
1849 {
1850 pos = next_sentence_start(cut);
1853 cuts.push(cut);
1854 continue;
1855 }
1856
1857 pos += 1;
1858 }
1859
1860 let mut sentences = Vec::new();
1861 let mut sentence_start = 0;
1863 for &cut in &cuts {
1864 if let Some(range) = trim_range(text, sentence_start, char_offsets[cut]) {
1870 sentences.push(range);
1871 }
1872 sentence_start = char_offsets[next_sentence_start(cut)];
1873 }
1874
1875 if let Some(range) = trim_range(text, sentence_start, text.len()) {
1877 sentences.push(range);
1878 }
1879 sentences
1880}
1881
1882fn sentence_structure(text: &str, defined_references: Option<&HashSet<String>>) -> NestedStructure {
1898 let holds_construct = text.contains(['`', '[', '<', '$']);
1903 let holds_emphasis = text.contains(['*', '_', '~']);
1904 if !holds_construct && !holds_emphasis {
1905 return NestedStructure {
1906 parentheticals: Vec::new(),
1907 terminal_links: Vec::new(),
1908 atomic: Vec::new(),
1909 markers: Vec::new(),
1910 marker_closers: Vec::new(),
1911 links: Vec::new(),
1912 code_spans: Vec::new(),
1913 };
1914 }
1915 nested_structure(text, defined_references, false)
1916}
1917
1918fn semantic_sentence_structure(text: &str, defined_references: Option<&HashSet<String>>) -> NestedStructure {
1922 let mut structure = sentence_structure(text, defined_references);
1923 let (Some(open), Some(close)) = (text.find('('), text.rfind(')')) else {
1924 return structure;
1925 };
1926 if open >= close || !text[open..close].contains(['.', '!', '?', '。', '!', '?']) {
1929 return structure;
1930 }
1931 let mut atomic = structure.atomic.iter().peekable();
1932 let mut opens = Vec::new();
1933 let mut groups = Vec::new();
1934 let mut escaped = false;
1935 for (offset, character) in text.char_indices() {
1936 while atomic.peek().is_some_and(|&&(_, end)| end <= offset) {
1937 atomic.next();
1938 }
1939 if atomic
1940 .peek()
1941 .is_some_and(|&&(start, end)| start <= offset && offset < end)
1942 {
1943 escaped = false;
1944 continue;
1945 }
1946 if escaped {
1947 escaped = false;
1948 continue;
1949 }
1950 match character {
1951 '\\' => escaped = true,
1952 '(' => opens.push(offset),
1953 ')' => {
1954 if let Some(start) = opens.pop() {
1955 groups.push((start, offset + 1));
1956 }
1957 }
1958 _ => {}
1959 }
1960 }
1961 structure.parentheticals = merge_ranges(groups);
1962 structure
1963}
1964
1965fn inside_parenthetical(offset: usize, groups: &[(usize, usize)]) -> bool {
1967 let past = groups.partition_point(|&(start, _)| start < offset);
1968 past > 0 && offset < groups[past - 1].1
1969}
1970
1971fn is_horizontal_rule(line: &str) -> bool {
1973 if line.len() < 3 {
1974 return false;
1975 }
1976
1977 let mut chars = line.chars();
1980 let Some(first_char) = chars.next() else {
1981 return false;
1982 };
1983 if first_char != '-' && first_char != '_' && first_char != '*' {
1984 return false;
1985 }
1986
1987 let mut non_space_count = 1usize; for c in chars {
1989 if c == ' ' {
1990 continue;
1991 }
1992 if c != first_char {
1993 return false;
1994 }
1995 non_space_count += 1;
1996 }
1997 non_space_count >= 3
1998}
1999
2000fn is_numbered_list_item(line: &str) -> bool {
2002 let mut chars = line.chars();
2003
2004 if !chars.next().is_some_and(char::is_numeric) {
2006 return false;
2007 }
2008
2009 while let Some(c) = chars.next() {
2011 if c == '.' {
2012 return chars.next() == Some(' ');
2015 }
2016 if !c.is_numeric() {
2017 return false;
2018 }
2019 }
2020
2021 false
2022}
2023
2024fn is_unordered_list_marker(s: &str) -> bool {
2026 matches!(s.as_bytes().first(), Some(b'-' | b'*' | b'+'))
2027 && !is_horizontal_rule(s)
2028 && (s.len() == 1 || s.as_bytes().get(1) == Some(&b' '))
2029}
2030
2031pub(crate) fn is_definition_list_marker(line: &str) -> bool {
2059 let trimmed = line.trim_start();
2060 trimmed.starts_with(':') && calculate_indentation_width_default(&line[..line.len() - trimmed.len()]) <= 3
2061}
2062
2063pub(crate) fn is_self_contained_display_math_line(line: &str) -> bool {
2085 let trimmed = line.trim();
2086 let inner = crate::utils::blockquote::parse_blockquote_prefix(trimmed).map_or(trimmed, |p| p.content.trim());
2087 let inner = inner.strip_suffix('\\').map_or(inner, str::trim_end);
2088 inner.len() >= 4
2089 && inner.starts_with("$$")
2090 && inner.ends_with("$$")
2091 && inner[2..].find("$$") == Some(inner.len() - 4)
2092}
2093
2094pub(crate) fn lines_touching_multiline_code_span(text: &str) -> Vec<bool> {
2117 let line_count = text.lines().count();
2118 if !text.contains('`') || !text.contains("$$") {
2119 return vec![false; line_count];
2120 }
2121 let code_spans = crate::utils::parser_options::code_span_ranges_without_math(text);
2122 let mut spans = code_spans.iter().copied().peekable();
2123 let mut starts_inside = Vec::with_capacity(line_count);
2124 let mut line_start = 0;
2125 for line in text.split_inclusive('\n') {
2126 while spans.next_if(|&(_, end)| end <= line_start).is_some() {}
2127 starts_inside.push(spans.peek().is_some_and(|&(start, _)| start < line_start));
2128 line_start += line.len();
2129 }
2130 (0..line_count)
2131 .map(|i| starts_inside[i] || starts_inside.get(i + 1).is_some_and(|&b| b))
2132 .collect()
2133}
2134
2135pub(crate) fn lines_ending_in_code_span(lines: &[&str]) -> Vec<bool> {
2141 if !lines.iter().any(|line| line.contains('`')) {
2142 return vec![false; lines.len()];
2143 }
2144 let text = lines.join("\n");
2145 let code_spans = nested_structure(&text, None, false).code_spans;
2146 let mut spans = code_spans.iter().copied().peekable();
2147 let mut line_end = 0;
2148 lines
2149 .iter()
2150 .map(|line| {
2151 line_end += line.len();
2152 while spans.next_if(|&(_, end)| end <= line_end).is_some() {}
2153 let inside = spans.peek().is_some_and(|&(start, _)| start <= line_end);
2154 line_end += 1;
2155 inside
2156 })
2157 .collect()
2158}
2159
2160fn has_block_line_above(lines: &[&str], index: usize) -> bool {
2169 let Some(previous) = index.checked_sub(1).map(|above| lines[above].trim()) else {
2170 return false;
2171 };
2172 !(previous.is_empty()
2173 || previous.starts_with('#')
2174 || previous.starts_with("```")
2175 || previous.starts_with("~~~")
2176 || previous.starts_with(":::")
2177 || is_horizontal_rule(previous))
2178}
2179
2180fn is_block_boundary_core(trimmed: &str) -> bool {
2183 trimmed.is_empty()
2184 || trimmed.starts_with('#')
2185 || trimmed.starts_with("```")
2186 || trimmed.starts_with("~~~")
2187 || trimmed.starts_with('>')
2188 || (trimmed.starts_with('[') && trimmed.contains("]:"))
2189 || is_horizontal_rule(trimmed)
2190 || is_unordered_list_marker(trimmed)
2191 || is_numbered_list_item(trimmed)
2192 || is_definition_list_marker(trimmed)
2193 || trimmed.starts_with(":::")
2194}
2195
2196fn is_block_boundary(trimmed: &str) -> bool {
2199 is_block_boundary_core(trimmed) || trimmed.starts_with('|')
2200}
2201
2202fn is_paragraph_boundary(trimmed: &str, line: &str) -> bool {
2206 is_block_boundary_core(trimmed)
2207 || calculate_indentation_width_default(line) >= 4
2208 || crate::utils::table_utils::TableUtils::is_potential_table_row(line)
2209}
2210
2211fn has_hard_break(line: &str) -> bool {
2217 let line = line.strip_suffix('\r').unwrap_or(line);
2218 line.ends_with(" ") || line.ends_with('\\')
2219}
2220
2221struct SoftBreakJoin {
2223 joined: String,
2224 joins: Vec<usize>,
2226 code_spans: Vec<(usize, usize)>,
2228}
2229
2230impl SoftBreakJoin {
2231 fn new<S: AsRef<str>>(lines: &[S]) -> Self {
2232 let mut joined = String::new();
2233 let mut joins = Vec::with_capacity(lines.len().saturating_sub(1));
2234 for (idx, line) in lines.iter().enumerate() {
2235 let line = line.as_ref();
2236 if idx + 1 == lines.len() {
2237 joined.push_str(line);
2238 } else {
2239 joined.push_str(line.strip_suffix('\r').unwrap_or(line));
2240 joins.push(joined.len());
2241 joined.push(' ');
2242 }
2243 }
2244 let code_spans = if !joins.is_empty() && joined.contains('`') {
2245 nested_structure(&joined, None, false).code_spans
2246 } else {
2247 Vec::new()
2248 };
2249 Self {
2250 joined,
2251 joins,
2252 code_spans,
2253 }
2254 }
2255}
2256
2257pub(crate) fn code_span_runs_into_indentation<S: AsRef<str>>(lines: &[S]) -> bool {
2268 let SoftBreakJoin {
2269 joined,
2270 joins,
2271 code_spans,
2272 } = SoftBreakJoin::new(lines);
2273 joins.iter().enumerate().any(|(k, &join)| {
2274 let next_line = &joined[join + 1..joins.get(k + 1).copied().unwrap_or(joined.len())];
2275 next_line.starts_with([' ', '\t']) && code_spans.iter().any(|&(start, end)| start <= join && join < end)
2276 })
2277}
2278
2279pub(crate) fn code_span_crosses_last_break<S: AsRef<str>>(lines: &[S]) -> bool {
2282 let SoftBreakJoin { joins, code_spans, .. } = SoftBreakJoin::new(lines);
2283 joins
2284 .last()
2285 .is_some_and(|&join| code_spans.iter().any(|&(start, end)| start <= join && join < end))
2286}
2287
2288pub(crate) fn join_soft_break_lines<S: AsRef<str>>(lines: &[S], cjk: CjkSoftBreak) -> String {
2313 join_soft_break_lines_with_sentences(lines, cjk, None, LinkTextPunctuation::Ignore, None)
2314}
2315
2316pub(crate) fn join_sentence_lines<S: AsRef<str>>(
2320 lines: &[S],
2321 cjk: CjkSoftBreak,
2322 abbreviations: &Option<Vec<String>>,
2323 link_text_punctuation: LinkTextPunctuation,
2324 references: Option<&HashSet<String>>,
2325) -> String {
2326 join_soft_break_lines_with_sentences(
2327 lines,
2328 cjk,
2329 Some(abbreviations),
2330 link_text_punctuation,
2331 references.filter(|_| link_text_punctuation == LinkTextPunctuation::Trailing),
2332 )
2333}
2334
2335fn join_reflow_lines<S: AsRef<str>>(lines: &[S], options: &ReflowOptions) -> String {
2336 if options.sentence_per_line {
2337 join_sentence_lines(
2338 lines,
2339 options.cjk_soft_break,
2340 &options.abbreviations,
2341 options.link_text_punctuation,
2342 options.defined_references.as_ref(),
2343 )
2344 } else {
2345 join_soft_break_lines(lines, options.cjk_soft_break)
2346 }
2347}
2348
2349fn join_soft_break_lines_with_sentences<S: AsRef<str>>(
2350 lines: &[S],
2351 cjk: CjkSoftBreak,
2352 sentence_abbreviations: Option<&Option<Vec<String>>>,
2353 link_text_punctuation: LinkTextPunctuation,
2354 references: Option<&HashSet<String>>,
2355) -> String {
2356 let SoftBreakJoin {
2357 mut joined,
2358 joins,
2359 code_spans,
2360 } = SoftBreakJoin::new(lines);
2361 if joins.is_empty() {
2362 return joined;
2363 }
2364 let sentence_ends = sentence_abbreviations.map(|abbreviations| {
2365 for &join in &joins {
2368 joined.replace_range(join..=join, "\n");
2369 }
2370 split_into_sentence_ranges(
2371 &joined,
2372 &get_abbreviations(abbreviations),
2373 true,
2374 cjk,
2375 references,
2376 None,
2377 link_text_punctuation,
2378 )
2379 .into_iter()
2380 .map(|(_, end)| end)
2381 .collect::<HashSet<_>>()
2382 });
2383 let mut trimmed = String::with_capacity(joined.len());
2389 let mut copied = 0;
2390 let mut space_pending = false;
2391 let mut sentence_break_pending = false;
2392 let mut spans = code_spans.iter().copied().peekable();
2393 for (k, &join) in joins.iter().enumerate() {
2394 while spans.next_if(|&(_, end)| end <= join).is_some() {}
2395 let inside_code_span = spans.peek().is_some_and(|&(start, _)| start <= join);
2396 let content_end = if inside_code_span {
2397 join
2398 } else {
2399 joined[..join].trim_end_matches([' ', '\t']).len().max(copied)
2400 };
2401 let wrote_text = content_end > copied;
2402 if wrote_text {
2403 if space_pending {
2404 trimmed.push(if sentence_break_pending { '\n' } else { ' ' });
2405 }
2406 trimmed.push_str(&joined[copied..content_end]);
2407 }
2408 let next_line = &joined[join + 1..joins.get(k + 1).copied().unwrap_or(joined.len())];
2409 let next_content = if inside_code_span {
2410 next_line
2411 } else {
2412 next_line.trim_start_matches([' ', '\t'])
2413 };
2414 copied = join + 1 + (next_line.len() - next_content.len());
2415 let break_removed = !inside_code_span
2416 && cjk == CjkSoftBreak::Join
2417 && joined[..content_end]
2418 .chars()
2419 .next_back()
2420 .is_some_and(joins_cjk_soft_break)
2421 && next_content.chars().next().is_some_and(joins_cjk_soft_break);
2422 space_pending = !break_removed || (space_pending && !wrote_text);
2423 sentence_break_pending = sentence_ends.as_ref().is_some_and(|ends| ends.contains(&content_end));
2424 }
2425 if space_pending {
2426 trimmed.push(if sentence_break_pending { '\n' } else { ' ' });
2427 }
2428 trimmed.push_str(&joined[copied..]);
2429 trimmed
2430}
2431
2432fn ends_with_sentence_punct(text: &str) -> bool {
2434 text.ends_with('.') || text.ends_with('!') || text.ends_with('?')
2435}
2436
2437fn ends_cjk_sentence_with_closer(text: &str) -> bool {
2444 let stripped = text.trim_end_matches(|c| is_closing_bracket(c) || is_closing_quote(c));
2445 stripped.len() < text.len() && stripped.chars().next_back().is_some_and(is_cjk_sentence_ending)
2446}
2447
2448fn trim_preserving_hard_break(s: &str) -> String {
2454 let s = s.strip_suffix('\r').unwrap_or(s);
2456
2457 if s.ends_with('\\') {
2459 return s.to_string();
2461 }
2462
2463 if s.ends_with(" ") {
2465 let content_end = s.trim_end().len();
2467 if content_end == 0 {
2468 return String::new();
2470 }
2471 format!("{} ", &s[..content_end])
2473 } else {
2474 s.trim_end().to_string()
2476 }
2477}
2478
2479fn parse_elements(text: &str, options: &ReflowOptions) -> Vec<Element> {
2481 parse_markdown_elements_inner(
2482 text,
2483 options.attr_lists,
2484 options.myst_roles,
2485 options.defined_references.as_ref(),
2486 )
2487}
2488
2489pub fn reflow_line(line: &str, options: &ReflowOptions) -> Vec<String> {
2499 let reflowed = reflow_line_unchecked(line, options);
2500 if preserves_content(line, &reflowed) {
2501 reflowed
2502 } else {
2503 vec![line.to_string()]
2504 }
2505}
2506
2507fn preserves_content(original: &str, reflowed: &[String]) -> bool {
2514 let (original_text, original_breaks) = visible_text_and_breaks(original.chars());
2515 let (reflowed_text, reflowed_breaks) =
2516 visible_text_and_breaks(reflowed.iter().flat_map(|line| line.chars().chain(['\n'])));
2517
2518 original_text == reflowed_text && contains_all(&reflowed_breaks, &original_breaks)
2519}
2520
2521fn visible_text_and_breaks(text: impl Iterator<Item = char>) -> (String, Vec<usize>) {
2524 let mut visible = String::new();
2525 let mut breaks = Vec::new();
2526 let mut count = 0usize;
2527 let mut pending_break = false;
2528
2529 for c in text {
2530 if c.is_whitespace() {
2531 pending_break = count > 0;
2532 } else {
2533 if pending_break {
2534 breaks.push(count);
2535 pending_break = false;
2536 }
2537 visible.push(c);
2538 count += 1;
2539 }
2540 }
2541
2542 (visible, breaks)
2543}
2544
2545fn contains_all(superset: &[usize], subset: &[usize]) -> bool {
2547 let mut candidates = superset.iter();
2548 subset
2549 .iter()
2550 .all(|wanted| candidates.by_ref().any(|found| found == wanted))
2551}
2552
2553fn reflow_line_unchecked(line: &str, options: &ReflowOptions) -> Vec<String> {
2554 let lines = keep_first_line_out_of_backtick_fence(line, reflow_lines(line, options));
2555 keep_first_line_out_of_html_block(lines)
2556}
2557
2558fn keep_first_line_out_of_backtick_fence(source: &str, mut lines: Vec<String>) -> Vec<String> {
2563 let first_source = source.lines().next().unwrap_or("").trim_start_matches([' ', '\t']);
2564 let ticks = first_source.bytes().take_while(|&b| b == b'`').count();
2565 if ticks < 3 || !first_source[ticks..].contains('`') {
2566 return lines;
2567 }
2568 while lines.len() > 1 {
2569 let first = lines[0].trim_start_matches([' ', '\t']);
2570 let ticks = first.bytes().take_while(|&b| b == b'`').count();
2571 if ticks < 3 || first[ticks..].contains('`') {
2572 break;
2573 }
2574 let next = lines.remove(1);
2575 lines[0].push(' ');
2576 lines[0].push_str(next.trim_start_matches([' ', '\t']));
2577 }
2578 lines
2579}
2580
2581fn reflow_lines(line: &str, options: &ReflowOptions) -> Vec<String> {
2582 if options.sentence_pack {
2583 let elements = parse_elements(line, options);
2584 return reflow_elements_sentence_pack(&elements, options);
2585 }
2586
2587 if options.sentence_per_line {
2589 let elements = parse_elements(line, options);
2590 return merge_block_construct_continuations(reflow_elements_sentence_per_line(&elements, options));
2591 }
2592
2593 if options.semantic_line_breaks {
2595 let elements = parse_elements(line, options);
2596 return merge_block_construct_continuations(reflow_elements_semantic(&elements, options));
2597 }
2598
2599 if options.line_length == 0 || line_fits(line, options) {
2602 return vec![line.to_string()];
2603 }
2604
2605 let elements = parse_elements(line, options);
2607
2608 merge_block_construct_continuations(reflow_elements(&elements, options))
2610}
2611
2612#[derive(Debug, Clone)]
2614enum Element {
2615 Text(String),
2617 Link(String),
2619 ReferenceLink(String),
2621 EmptyReferenceLink(String),
2623 ShortcutReference(String),
2625 InlineImage(String),
2627 ReferenceImage(String),
2629 EmptyReferenceImage(String),
2631 LinkedImage(String),
2633 FootnoteReference(String),
2635 Strikethrough {
2637 content: String,
2638 double: bool,
2640 },
2641 WikiLink(String),
2643 InlineMath(String),
2645 DisplayMath(String),
2647 EmojiShortcode(String),
2649 Autolink(String),
2651 HtmlTag(String),
2653 HtmlEntity(String),
2655 HugoShortcode(String),
2657 AttrList(String),
2659 MystRole(String),
2663 Code { content: String, marker: String },
2665 Bold {
2667 content: String,
2668 underscore: bool,
2670 },
2671 Italic {
2673 content: String,
2674 underscore: bool,
2676 },
2677}
2678
2679impl Element {
2680 fn opens_with_bracket(&self) -> bool {
2685 matches!(
2686 self,
2687 Element::Link(_)
2688 | Element::ReferenceLink(_)
2689 | Element::EmptyReferenceLink(_)
2690 | Element::ShortcutReference(_)
2691 | Element::FootnoteReference(_)
2692 | Element::InlineImage(_)
2693 | Element::ReferenceImage(_)
2694 | Element::EmptyReferenceImage(_)
2695 | Element::LinkedImage(_)
2696 | Element::WikiLink(_)
2697 )
2698 }
2699}
2700
2701impl std::fmt::Display for Element {
2702 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2703 match self {
2704 Element::Text(s) => write!(f, "{s}"),
2705 Element::Link(s) => write!(f, "{s}"),
2706 Element::ReferenceLink(s) => write!(f, "{s}"),
2707 Element::EmptyReferenceLink(s) => write!(f, "{s}"),
2708 Element::ShortcutReference(s) => write!(f, "{s}"),
2709 Element::InlineImage(s) => write!(f, "{s}"),
2710 Element::ReferenceImage(s) => write!(f, "{s}"),
2711 Element::EmptyReferenceImage(s) => write!(f, "{s}"),
2712 Element::LinkedImage(s) => write!(f, "{s}"),
2713 Element::FootnoteReference(s) => write!(f, "{s}"),
2714 Element::Strikethrough { content, double } => {
2715 let marker = if *double { "~~" } else { "~" };
2716 write!(f, "{marker}{content}{marker}")
2717 }
2718 Element::WikiLink(s) => write!(f, "[[{s}]]"),
2719 Element::InlineMath(s) => write!(f, "${s}$"),
2720 Element::DisplayMath(s) => write!(f, "$${s}$$"),
2721 Element::EmojiShortcode(s) => write!(f, ":{s}:"),
2722 Element::Autolink(s) => write!(f, "{s}"),
2723 Element::HtmlTag(s) => write!(f, "{s}"),
2724 Element::HtmlEntity(s) => write!(f, "{s}"),
2725 Element::HugoShortcode(s) => write!(f, "{s}"),
2726 Element::AttrList(s) => write!(f, "{s}"),
2727 Element::MystRole(s) => write!(f, "{s}"),
2728 Element::Code { content, marker } => write!(f, "{marker}{content}{marker}"),
2729 Element::Bold { content, underscore } => {
2730 if *underscore {
2731 write!(f, "__{content}__")
2732 } else {
2733 write!(f, "**{content}**")
2734 }
2735 }
2736 Element::Italic { content, underscore } => {
2737 if *underscore {
2738 write!(f, "_{content}_")
2739 } else {
2740 write!(f, "*{content}*")
2741 }
2742 }
2743 }
2744 }
2745}
2746
2747impl Element {
2748 fn display_len(&self, mode: ReflowLengthMode) -> usize {
2749 match self {
2750 Element::Text(s)
2751 | Element::Link(s)
2752 | Element::ReferenceLink(s)
2753 | Element::EmptyReferenceLink(s)
2754 | Element::ShortcutReference(s)
2755 | Element::InlineImage(s)
2756 | Element::ReferenceImage(s)
2757 | Element::EmptyReferenceImage(s)
2758 | Element::LinkedImage(s)
2759 | Element::FootnoteReference(s)
2760 | Element::Autolink(s)
2761 | Element::HtmlTag(s)
2762 | Element::HtmlEntity(s)
2763 | Element::HugoShortcode(s)
2764 | Element::AttrList(s)
2765 | Element::MystRole(s) => display_len(s, mode),
2766 Element::WikiLink(s) => display_len(s, mode) + 4,
2767 Element::InlineMath(s) => display_len(s, mode) + 2,
2768 Element::DisplayMath(s) => display_len(s, mode) + 4,
2769 Element::EmojiShortcode(s) => display_len(s, mode) + 2,
2770 Element::Strikethrough { content, double } => display_len(content, mode) + if *double { 4 } else { 2 },
2771 Element::Code { content, marker } => display_len(content, mode) + display_len(marker, mode) * 2,
2772 Element::Bold { content, .. } => display_len(content, mode) + 4,
2773 Element::Italic { content, .. } => display_len(content, mode) + 2,
2774 }
2775 }
2776
2777 fn exempt_width(&self, mode: ReflowLengthMode, exemptions: LengthExemptions) -> LineWidth {
2788 let full = self.display_len(mode);
2789 let mut width = LineWidth::plain(full);
2790 match self {
2791 Element::Link(s) | Element::LinkedImage(s) if exemptions.link_urls => {
2792 if let Some(text) = bracketed_text(s, 0) {
2793 width.link_exempt = (2 + display_len(text, mode)).min(full);
2794 }
2795 }
2796 Element::InlineImage(s) if exemptions.link_urls => {
2797 if let Some(alt) = bracketed_text(s, 1) {
2798 width.link_exempt = (3 + display_len(alt, mode)).min(full);
2799 }
2800 }
2801 Element::Code { .. } if exemptions.code_spans => width.code_exempt = 0,
2802 _ => {}
2803 }
2804 width
2805 }
2806}
2807
2808pub(crate) fn bracketed_text(s: &str, open: usize) -> Option<&str> {
2815 let bytes = s.as_bytes();
2816 if bytes.get(open) != Some(&b'[') {
2817 return None;
2818 }
2819 let mut depth = 0usize;
2820 let mut in_code_span = false;
2821 let mut escaped = false;
2822 for (i, &byte) in bytes.iter().enumerate().skip(open + 1) {
2823 if escaped {
2824 escaped = false;
2825 continue;
2826 }
2827 match byte {
2828 b'\\' => escaped = true,
2829 b'`' => in_code_span = !in_code_span,
2830 b'[' if !in_code_span => depth += 1,
2831 b']' if !in_code_span => match depth.checked_sub(1) {
2832 Some(next) => depth = next,
2833 None => return s.get(open + 1..i),
2834 },
2835 _ => {}
2836 }
2837 }
2838 None
2839}
2840
2841#[derive(Debug, Clone)]
2843struct EmphasisSpan {
2844 start: usize,
2846 end: usize,
2848 content: String,
2850 is_strong: bool,
2852 is_strikethrough: bool,
2854 uses_underscore: bool,
2856 strikethrough_double: bool,
2859}
2860
2861fn extract_emphasis_and_code_spans(text: &str) -> (Vec<EmphasisSpan>, Vec<CodeSpan>) {
2871 let has_emphasis = text.contains(['*', '_', '~']);
2873 let has_code = text.contains('`');
2874 if !has_emphasis && !has_code {
2875 return (Vec::new(), Vec::new());
2876 }
2877
2878 let mut emphasis_spans = Vec::new();
2879 let mut code_spans = Vec::new();
2880
2881 let mut options = Options::empty();
2882 if has_emphasis {
2883 options.insert(Options::ENABLE_STRIKETHROUGH);
2884 }
2885
2886 let mut emphasis_stack: Vec<(usize, bool)> = Vec::new(); let mut strong_stack: Vec<(usize, bool)> = Vec::new();
2889 let mut strikethrough_stack: Vec<usize> = Vec::new();
2890
2891 let parser = Parser::new_ext(text, options).into_offset_iter();
2892
2893 for (event, range) in parser {
2894 match event {
2895 Event::Code(_) => {
2896 code_spans.push(CodeSpan {
2897 start: range.start,
2898 end: range.end,
2899 });
2900 }
2901 Event::Start(Tag::Emphasis) => {
2902 let uses_underscore = text.get(range.start..range.start + 1) == Some("_");
2904 emphasis_stack.push((range.start, uses_underscore));
2905 }
2906 Event::End(TagEnd::Emphasis) => {
2907 if let Some((start_byte, uses_underscore)) = emphasis_stack.pop() {
2908 let content_start = start_byte + 1;
2909 let content_end = range.end - 1;
2910 if content_end > content_start
2911 && let Some(content) = text.get(content_start..content_end)
2912 {
2913 emphasis_spans.push(EmphasisSpan {
2914 start: start_byte,
2915 end: range.end,
2916 content: content.to_string(),
2917 is_strong: false,
2918 is_strikethrough: false,
2919 uses_underscore,
2920 strikethrough_double: false,
2921 });
2922 }
2923 }
2924 }
2925 Event::Start(Tag::Strong) => {
2926 let uses_underscore = text.get(range.start..range.start + 2) == Some("__");
2927 strong_stack.push((range.start, uses_underscore));
2928 }
2929 Event::End(TagEnd::Strong) => {
2930 if let Some((start_byte, uses_underscore)) = strong_stack.pop() {
2931 let content_start = start_byte + 2;
2932 let content_end = range.end - 2;
2933 if content_end > content_start
2934 && let Some(content) = text.get(content_start..content_end)
2935 {
2936 emphasis_spans.push(EmphasisSpan {
2937 start: start_byte,
2938 end: range.end,
2939 content: content.to_string(),
2940 is_strong: true,
2941 is_strikethrough: false,
2942 uses_underscore,
2943 strikethrough_double: false,
2944 });
2945 }
2946 }
2947 }
2948 Event::Start(Tag::Strikethrough) => {
2949 strikethrough_stack.push(range.start);
2950 }
2951 Event::End(TagEnd::Strikethrough) => {
2952 if let Some(start_byte) = strikethrough_stack.pop() {
2953 let double = text.get(start_byte..start_byte + 2) == Some("~~");
2954 let marker_len = if double { 2 } else { 1 };
2955 let content_start = start_byte + marker_len;
2956 let content_end = range.end - marker_len;
2957 if content_end > content_start
2958 && let Some(content) = text.get(content_start..content_end)
2959 {
2960 emphasis_spans.push(EmphasisSpan {
2961 start: start_byte,
2962 end: range.end,
2963 content: content.to_string(),
2964 is_strong: false,
2965 is_strikethrough: true,
2966 uses_underscore: false,
2967 strikethrough_double: double,
2968 });
2969 }
2970 }
2971 }
2972 _ => {}
2973 }
2974 }
2975
2976 emphasis_spans.sort_by_key(|s| s.start);
2977 (emphasis_spans, code_spans)
2978}
2979
2980#[derive(Debug, Clone)]
2981struct CodeSpan {
2982 start: usize,
2983 end: usize,
2984}
2985
2986#[derive(Debug, Clone)]
2987struct LinkSpan {
2988 start: usize,
2989 end: usize,
2990 link_type: Option<LinkType>,
2991 is_image: bool,
2992 is_footnote: bool,
2993 depth: usize,
2996}
2997
2998fn extract_link_spans(text: &str, defined_references: Option<&HashSet<String>>) -> Vec<LinkSpan> {
3002 let mut spans = all_link_spans(text, defined_references);
3003 spans.retain(|span| span.depth == 0);
3004 spans
3005}
3006
3007fn all_link_spans(text: &str, defined_references: Option<&HashSet<String>>) -> Vec<LinkSpan> {
3010 if !text.contains('[') {
3013 return Vec::new();
3014 }
3015
3016 let mut spans = Vec::new();
3017 let mut options = Options::empty();
3018 options.insert(Options::ENABLE_FOOTNOTES);
3019
3020 let resolve = move |link: BrokenLink<'_>| -> Option<(CowStr<'_>, CowStr<'_>)> {
3037 let atomic = match link.link_type {
3042 LinkType::Shortcut | LinkType::ShortcutUnknown => match defined_references {
3043 Some(defs) => defs.contains(&normalize_reference_label(link.reference.as_ref())),
3044 None => true,
3045 },
3046 _ => true,
3047 };
3048 atomic.then_some((CowStr::Borrowed(""), CowStr::Borrowed("")))
3049 };
3050 let parser = Parser::new_with_broken_link_callback(text, options, Some(resolve)).into_offset_iter();
3051 let mut stack = Vec::new();
3052
3053 for (event, range) in parser {
3054 match event {
3055 Event::Start(Tag::Link { link_type, .. }) => {
3056 stack.push((range.start, Some(link_type), false));
3057 }
3058 Event::Start(Tag::Image { link_type, .. }) => {
3059 stack.push((range.start, Some(link_type), true));
3060 }
3061 Event::End(TagEnd::Link | TagEnd::Image) => {
3062 if let Some((start_byte, link_type, is_image)) = stack.pop() {
3063 let mut end = range.end;
3064 if matches!(link_type, Some(LinkType::Collapsed) | Some(LinkType::CollapsedUnknown))
3065 && text[end..].starts_with("[]")
3066 {
3067 end += 2;
3068 }
3069 spans.push(LinkSpan {
3070 start: start_byte,
3071 end,
3072 link_type,
3073 is_image,
3074 is_footnote: false,
3075 depth: stack.len(),
3076 });
3077 }
3078 }
3079 Event::FootnoteReference(_) => {
3080 spans.push(LinkSpan {
3081 start: range.start,
3082 end: range.end,
3083 link_type: None,
3084 is_image: false,
3085 is_footnote: true,
3086 depth: stack.len(),
3087 });
3088 }
3089 _ => {}
3090 }
3091 }
3092
3093 spans.sort_by_key(|s| s.start);
3094 spans
3095}
3096
3097fn myst_role_len_at(text: &str, absolute_pos: usize, code_spans: &[CodeSpan]) -> Option<usize> {
3105 let bytes = text.as_bytes();
3106 if bytes.first() != Some(&b'{') {
3107 return None;
3108 }
3109
3110 let mut j = 1;
3112 match bytes.get(j) {
3113 Some(&b) if b.is_ascii_alphabetic() || b == b'_' => {}
3114 _ => return None,
3115 }
3116 while let Some(&b) = bytes.get(j) {
3117 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b':' | b'.') {
3118 j += 1;
3119 } else {
3120 break;
3121 }
3122 }
3123 if bytes.get(j) != Some(&b'}') {
3124 return None;
3125 }
3126 j += 1; let code_span_start = absolute_pos + j;
3130 if let Ok(idx) = code_spans.binary_search_by_key(&code_span_start, |span| span.start) {
3131 let span = &code_spans[idx];
3132 let code_span_len = span.end - span.start;
3133 return Some(j + code_span_len);
3134 }
3135
3136 None
3137}
3138
3139fn inline_math_len_at_start(s: &str) -> Option<usize> {
3146 let bytes = s.as_bytes();
3147 if bytes.first() != Some(&b'$') || bytes.get(1) == Some(&b'$') {
3149 return None;
3150 }
3151 let close = 1 + s[1..].find('$')?;
3154 if bytes.get(close + 1) == Some(&b'$') {
3156 return None;
3157 }
3158 Some(close + 1)
3159}
3160
3161#[derive(Clone, Copy, Debug)]
3163struct PatternMatch {
3164 start: usize,
3165 end: usize,
3166}
3167
3168fn inline_html_comment(text: &str) -> Option<(usize, usize)> {
3174 let start = text.find("<!--")?;
3175 let body = &text[start + 4..];
3176 let end = if body.starts_with('>') {
3177 start + 5
3178 } else if body.starts_with("->") {
3179 start + 6
3180 } else {
3181 start + 4 + body.find("-->")? + 3
3182 };
3183 Some((start, end))
3184}
3185
3186#[derive(Clone, Copy)]
3200enum PatternCache {
3201 Unsearched,
3202 NotFound,
3203 Found(PatternMatch),
3204}
3205
3206impl PatternCache {
3207 fn earliest_in(
3211 &mut self,
3212 remaining: &str,
3213 cursor: usize,
3214 find: impl FnOnce(&str) -> Option<(usize, usize)>,
3215 ) -> Option<(usize, usize)> {
3216 let stale = match self {
3217 PatternCache::Found(pm) => pm.start < cursor,
3218 PatternCache::NotFound => false,
3219 PatternCache::Unsearched => true,
3220 };
3221 if stale {
3222 *self = match find(remaining) {
3223 Some((start, end)) => PatternCache::Found(PatternMatch {
3224 start: cursor + start,
3225 end: cursor + end,
3226 }),
3227 None => PatternCache::NotFound,
3228 };
3229 }
3230 match self {
3231 PatternCache::Found(pm) => Some((pm.start - cursor, pm.end - cursor)),
3232 _ => None,
3233 }
3234 }
3235}
3236
3237fn parse_markdown_elements_inner(
3248 text: &str,
3249 attr_lists: bool,
3250 myst_roles: bool,
3251 defined_references: Option<&HashSet<String>>,
3252) -> Vec<Element> {
3253 let mut elements = Vec::new();
3254 let mut remaining = text;
3255
3256 let (emphasis_spans, code_spans) = extract_emphasis_and_code_spans(text);
3261 let link_spans = extract_link_spans(text, defined_references);
3262
3263 let mut cached_wiki_link = PatternCache::Unsearched;
3266 let mut cached_display_math = PatternCache::Unsearched;
3267 let mut cached_inline_math = PatternCache::Unsearched;
3268 let mut cached_emoji = PatternCache::Unsearched;
3269 let mut cached_html_entity = PatternCache::Unsearched;
3270 let mut cached_hugo_shortcode = PatternCache::Unsearched;
3271 let mut cached_html_tag = PatternCache::Unsearched;
3272 let mut cached_next_curly = PatternCache::Unsearched;
3273
3274 let mut link_span_idx = 0usize;
3278 let mut emphasis_span_idx = 0usize;
3279 let mut code_span_idx = 0usize;
3280
3281 while !remaining.is_empty() {
3282 let current_offset = text.len() - remaining.len();
3284 let mut earliest_match: Option<(usize, usize, &str)> = None;
3287
3288 while link_span_idx < link_spans.len() && link_spans[link_span_idx].start < current_offset {
3290 link_span_idx += 1;
3291 }
3292 let next_link: Option<&LinkSpan> = link_spans.get(link_span_idx);
3293
3294 if let Some(span) = next_link {
3295 let pos_in_remaining = span.start - current_offset;
3296 if earliest_match
3297 .as_ref()
3298 .is_none_or(|(start, _, _)| pos_in_remaining < *start)
3299 {
3300 let match_end = span.end - current_offset;
3301 earliest_match = Some((pos_in_remaining, match_end, "link_span"));
3302 }
3303 }
3304
3305 if let Some((start, end)) = cached_wiki_link.earliest_in(remaining, current_offset, |suffix| {
3307 WIKI_LINK_REGEX.find(suffix).map(|m| (m.start(), m.end()))
3308 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
3309 {
3310 earliest_match = Some((start, end, "wiki_link"));
3311 }
3312
3313 if let Some((start, end)) = cached_display_math.earliest_in(remaining, current_offset, |suffix| {
3315 DISPLAY_MATH_REGEX.find(suffix).map(|m| (m.start(), m.end()))
3316 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
3317 {
3318 earliest_match = Some((start, end, "display_math"));
3319 }
3320
3321 let inline_math_probe = if current_offset > 0 && text.as_bytes()[current_offset - 1] == b'$' {
3335 inline_math_len_at_start(remaining).map(|len| (0, len))
3336 } else {
3337 None
3338 };
3339 if let Some((start, end)) = inline_math_probe.or_else(|| {
3340 cached_inline_math.earliest_in(remaining, current_offset, |suffix| {
3341 INLINE_MATH_REGEX
3342 .find(suffix)
3343 .ok()
3344 .flatten()
3345 .map(|m| (m.start(), m.end()))
3346 })
3347 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
3348 {
3349 earliest_match = Some((start, end, "inline_math"));
3350 }
3351
3352 if let Some((start, end)) = cached_emoji.earliest_in(remaining, current_offset, |suffix| {
3354 EMOJI_SHORTCODE_REGEX.find(suffix).map(|m| (m.start(), m.end()))
3355 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
3356 {
3357 earliest_match = Some((start, end, "emoji"));
3358 }
3359
3360 if let Some((start, end)) = cached_html_entity.earliest_in(remaining, current_offset, |suffix| {
3362 HTML_ENTITY_REGEX.find(suffix).map(|m| (m.start(), m.end()))
3363 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
3364 {
3365 earliest_match = Some((start, end, "html_entity"));
3366 }
3367
3368 if let Some((start, end)) = cached_hugo_shortcode.earliest_in(remaining, current_offset, |suffix| {
3371 crate::utils::shortcode_utils::shortcode_ranges(suffix).next()
3372 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
3373 {
3374 earliest_match = Some((start, end, "hugo_shortcode"));
3375 }
3376
3377 if let Some((start, end)) = cached_html_tag.earliest_in(remaining, current_offset, |suffix| {
3385 let comment = inline_html_comment(suffix);
3386 let mut from = 0;
3387 while let Some(m) = HTML_TAG_PATTERN.find(&suffix[from..]) {
3388 let (tag_start, tag_end) = (from + m.start(), from + m.end());
3389 let tag = &suffix[tag_start..tag_end];
3390 let is_url_autolink = tag.starts_with("<http://")
3392 || tag.starts_with("<https://")
3393 || tag.starts_with("<mailto:")
3394 || tag.starts_with("<ftp://")
3395 || tag.starts_with("<ftps://");
3396 let is_email_autolink = {
3399 let content = tag.trim_start_matches('<').trim_end_matches('>');
3400 EMAIL_PATTERN.is_match(content)
3401 };
3402 if is_url_autolink || is_email_autolink {
3403 from = tag_end;
3404 } else {
3405 return match comment {
3406 Some(comment) if comment.0 < tag_start => Some(comment),
3407 _ => Some((tag_start, tag_end)),
3408 };
3409 }
3410 }
3411 comment
3412 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
3413 {
3414 earliest_match = Some((start, end, "html_tag"));
3415 }
3416
3417 let mut next_special = remaining.len();
3419 let mut special_type = "";
3420 let mut pulldown_emphasis: Option<&EmphasisSpan> = None;
3421 let mut attr_list_len: usize = 0;
3422 let mut myst_role_len: usize = 0;
3423
3424 while code_span_idx < code_spans.len() && code_spans[code_span_idx].start < current_offset {
3426 code_span_idx += 1;
3427 }
3428 let next_code_span: Option<&CodeSpan> = code_spans.get(code_span_idx);
3429 if let Some(span) = next_code_span {
3430 let pos_in_remaining = span.start - current_offset;
3431 if pos_in_remaining < next_special {
3432 next_special = pos_in_remaining;
3433 special_type = "pulldown_code";
3434 }
3435 }
3436
3437 let next_curly_pos = cached_next_curly
3440 .earliest_in(remaining, current_offset, |suffix| {
3441 suffix.find('{').map(|pos| (pos, pos + 1))
3442 })
3443 .map(|(start, _)| start);
3444
3445 if myst_roles
3450 && let Some(pos) = next_curly_pos
3451 && pos < next_special
3452 && let Some(role_len) = myst_role_len_at(&remaining[pos..], current_offset + pos, &code_spans)
3453 {
3454 next_special = pos;
3455 special_type = "myst_role";
3456 myst_role_len = role_len;
3457 }
3458
3459 if attr_lists
3461 && let Some(pos) = next_curly_pos
3462 && pos < next_special
3463 && let Some(m) = ATTR_LIST_PATTERN.find(&remaining[pos..])
3464 && m.start() == 0
3465 {
3466 next_special = pos;
3467 special_type = "attr_list";
3468 attr_list_len = m.end();
3469 }
3470
3471 while emphasis_span_idx < emphasis_spans.len() && emphasis_spans[emphasis_span_idx].start < current_offset {
3473 emphasis_span_idx += 1;
3474 }
3475 if let Some(span) = emphasis_spans.get(emphasis_span_idx) {
3476 let pos_in_remaining = span.start - current_offset;
3477 if pos_in_remaining < next_special {
3478 next_special = pos_in_remaining;
3479 special_type = "pulldown_emphasis";
3480 pulldown_emphasis = Some(span);
3481 }
3482 }
3483
3484 let should_process_markdown_link = if let Some((pos, _, _)) = earliest_match {
3486 pos < next_special
3487 } else {
3488 false
3489 };
3490
3491 if should_process_markdown_link {
3492 let (pos, match_end, pattern_type) = earliest_match.unwrap();
3493
3494 if pos > 0 {
3496 elements.push(Element::Text(remaining[..pos].to_string()));
3497 }
3498
3499 match pattern_type {
3501 "link_span" => {
3502 let span = next_link.unwrap();
3503 let raw_text = remaining[pos..match_end].to_string();
3504 if span.is_footnote {
3505 elements.push(Element::FootnoteReference(raw_text));
3506 } else if span.is_image {
3507 match span.link_type {
3508 Some(LinkType::Inline) => elements.push(Element::InlineImage(raw_text)),
3509 Some(LinkType::Reference)
3512 | Some(LinkType::ReferenceUnknown)
3513 | Some(LinkType::Shortcut)
3514 | Some(LinkType::ShortcutUnknown) => elements.push(Element::ReferenceImage(raw_text)),
3515 Some(LinkType::Collapsed) | Some(LinkType::CollapsedUnknown) => {
3516 elements.push(Element::EmptyReferenceImage(raw_text))
3517 }
3518 _ => elements.push(Element::InlineImage(raw_text)),
3519 }
3520 } else {
3521 match span.link_type {
3522 Some(LinkType::Inline) => {
3523 if raw_text.starts_with('[') && raw_text.contains("![") {
3524 elements.push(Element::LinkedImage(raw_text));
3525 } else {
3526 elements.push(Element::Link(raw_text));
3527 }
3528 }
3529 Some(LinkType::Reference) | Some(LinkType::ReferenceUnknown) => {
3532 elements.push(Element::ReferenceLink(raw_text))
3533 }
3534 Some(LinkType::Collapsed) | Some(LinkType::CollapsedUnknown) => {
3535 elements.push(Element::EmptyReferenceLink(raw_text))
3536 }
3537 Some(LinkType::Shortcut) | Some(LinkType::ShortcutUnknown) => {
3538 elements.push(Element::ShortcutReference(raw_text))
3539 }
3540 Some(LinkType::Autolink) | Some(LinkType::Email) => {
3541 elements.push(Element::Autolink(raw_text))
3542 }
3543 _ => elements.push(Element::Link(raw_text)),
3544 }
3545 }
3546 remaining = &remaining[match_end..];
3547 }
3548 "wiki_link" => {
3549 if let Some(caps) = WIKI_LINK_REGEX.captures(remaining) {
3550 let content = caps.get(1).map_or("", |m| m.as_str());
3551 elements.push(Element::WikiLink(content.to_string()));
3552 remaining = &remaining[match_end..];
3553 } else {
3554 elements.push(Element::Text("[[".to_string()));
3555 remaining = &remaining[2..];
3556 }
3557 }
3558 "display_math" => {
3559 if let Some(caps) = DISPLAY_MATH_REGEX.captures(remaining) {
3560 let math = caps.get(1).map_or("", |m| m.as_str());
3561 elements.push(Element::DisplayMath(math.to_string()));
3562 remaining = &remaining[match_end..];
3563 } else {
3564 elements.push(Element::Text("$$".to_string()));
3565 remaining = &remaining[2..];
3566 }
3567 }
3568 "inline_math" => {
3569 if let Ok(Some(caps)) = INLINE_MATH_REGEX.captures(remaining) {
3570 let math = caps.get(1).map_or("", |m| m.as_str());
3571 elements.push(Element::InlineMath(math.to_string()));
3572 remaining = &remaining[match_end..];
3573 } else {
3574 elements.push(Element::Text("$".to_string()));
3575 remaining = &remaining[1..];
3576 }
3577 }
3578 "emoji" => {
3579 if let Some(caps) = EMOJI_SHORTCODE_REGEX.captures(remaining) {
3580 let emoji = caps.get(1).map_or("", |m| m.as_str());
3581 elements.push(Element::EmojiShortcode(emoji.to_string()));
3582 remaining = &remaining[match_end..];
3583 } else {
3584 elements.push(Element::Text(":".to_string()));
3585 remaining = &remaining[1..];
3586 }
3587 }
3588 "html_entity" => {
3589 elements.push(Element::HtmlEntity(remaining[pos..match_end].to_string()));
3591 remaining = &remaining[match_end..];
3592 }
3593 "hugo_shortcode" => {
3594 elements.push(Element::HugoShortcode(remaining[pos..match_end].to_string()));
3596 remaining = &remaining[match_end..];
3597 }
3598 "html_tag" => {
3599 elements.push(Element::HtmlTag(remaining[pos..match_end].to_string()));
3601 remaining = &remaining[match_end..];
3602 }
3603 _ => unreachable!("unknown pattern type: {}", pattern_type),
3604 }
3605 } else {
3606 if next_special > 0 && next_special < remaining.len() {
3610 elements.push(Element::Text(remaining[..next_special].to_string()));
3611 remaining = &remaining[next_special..];
3612 }
3613
3614 match special_type {
3616 "pulldown_code" => {
3617 let span = next_code_span.unwrap();
3618 let span_len = span.end - span.start;
3619 let code_raw = &remaining[..span_len];
3620 if let Some((content, marker)) = decompose_code_span(code_raw) {
3621 elements.push(Element::Code {
3622 content: content.to_string(),
3623 marker: marker.to_string(),
3624 });
3625 } else {
3626 elements.push(Element::Text(code_raw.to_string()));
3627 }
3628 remaining = &remaining[span_len..];
3629 }
3630 "attr_list" => {
3631 elements.push(Element::AttrList(remaining[..attr_list_len].to_string()));
3632 remaining = &remaining[attr_list_len..];
3633 }
3634 "myst_role" => {
3635 elements.push(Element::MystRole(remaining[..myst_role_len].to_string()));
3636 remaining = &remaining[myst_role_len..];
3637 }
3638 "pulldown_emphasis" => {
3639 let span = pulldown_emphasis.expect("pulldown_emphasis must be set");
3641 let span_len = span.end - span.start;
3642 if span.is_strikethrough {
3643 elements.push(Element::Strikethrough {
3644 content: span.content.clone(),
3645 double: span.strikethrough_double,
3646 });
3647 } else if span.is_strong {
3648 elements.push(Element::Bold {
3649 content: span.content.clone(),
3650 underscore: span.uses_underscore,
3651 });
3652 } else {
3653 elements.push(Element::Italic {
3654 content: span.content.clone(),
3655 underscore: span.uses_underscore,
3656 });
3657 }
3658 remaining = &remaining[span_len..];
3659 }
3660 _ => {
3661 elements.push(Element::Text(remaining.to_string()));
3663 break;
3664 }
3665 }
3666 }
3667 }
3668
3669 let mut merged_elements = Vec::new();
3671 for el in elements {
3672 match el {
3673 Element::Text(s) => {
3674 if let Some(Element::Text(last_s)) = merged_elements.last_mut() {
3675 last_s.push_str(&s);
3676 } else {
3677 merged_elements.push(Element::Text(s));
3678 }
3679 }
3680 other => merged_elements.push(other),
3681 }
3682 }
3683 merged_elements
3684}
3685
3686fn source_gap_before(elements: &[Element], idx: usize) -> &str {
3701 let Some(Element::Text(previous)) = idx.checked_sub(1).map(|prev| &elements[prev]) else {
3702 return "";
3703 };
3704
3705 let gap = &previous[previous.trim_end_matches(char::is_whitespace).len()..];
3706 if gap.is_empty() {
3707 ""
3708 } else if gap.contains(is_significant_whitespace) {
3709 gap
3710 } else {
3711 " "
3712 }
3713}
3714
3715fn push_source_gap(current_line: &mut String, gap: &str) {
3718 if !gap.is_empty() && !current_line.is_empty() && !current_line.ends_with(char::is_whitespace) {
3719 current_line.push_str(gap);
3720 }
3721}
3722
3723fn is_setext_or_thematic(text: &str) -> bool {
3729 let mut marker = 0u8;
3730 let mut count = 0usize;
3731 let mut has_space = false;
3732 for &b in text.as_bytes() {
3733 match b {
3734 b' ' | b'\t' => has_space = true,
3735 b'-' | b'=' | b'*' | b'_' => {
3736 if marker == 0 {
3737 marker = b;
3738 } else if b != marker {
3739 return false;
3740 }
3741 count += 1;
3742 }
3743 _ => return false,
3744 }
3745 }
3746 match marker {
3747 b'=' => !has_space,
3748 b'-' => !has_space || count >= 3,
3749 b'*' | b'_' => count >= 3,
3750 _ => false,
3751 }
3752}
3753
3754pub(crate) fn is_table_delimiter_row(text: &str) -> bool {
3761 let text = text.trim_matches([' ', '\t']);
3762 if !text.contains(['|', ':']) {
3763 return false;
3764 }
3765 let text = text.strip_prefix('|').unwrap_or(text);
3766 let text = text.strip_suffix('|').unwrap_or(text);
3767 text.split('|').all(|cell| {
3768 let cell = cell.trim_matches([' ', '\t']);
3769 let cell = cell.strip_prefix(':').unwrap_or(cell);
3770 let cell = cell.strip_suffix(':').unwrap_or(cell);
3771 !cell.is_empty() && cell.bytes().all(|b| b == b'-')
3772 })
3773}
3774
3775fn starts_block_construct(text: &str) -> bool {
3791 let text = text.trim_start();
3792 let bytes = text.as_bytes();
3793 let Some(&first) = bytes.first() else {
3794 return false;
3795 };
3796 let marker_then_boundary = |len: usize| bytes.len() == len || bytes[len] == b' ' || bytes[len] == b'\t';
3797 match first {
3798 b'>' => true,
3800 b'-' => marker_then_boundary(1) || is_setext_or_thematic(text) || is_table_delimiter_row(text),
3801 b'*' | b'+' => marker_then_boundary(1) || is_setext_or_thematic(text),
3802 b'_' | b'=' => is_setext_or_thematic(text),
3803 b':' => true,
3805 b'|' => true,
3806 b'#' => {
3807 let hashes = bytes.iter().take_while(|&&b| b == b'#').count();
3812 hashes <= 6
3813 }
3814 b'`' => bytes.iter().take_while(|&&b| b == b'`').count() >= 3,
3815 b'~' => bytes.iter().take_while(|&&b| b == b'~').count() >= 3,
3816 b'0'..=b'9' => {
3823 let digits = bytes.iter().take_while(|b| b.is_ascii_digit()).count();
3824 digits <= 9
3825 && text[..digits].trim_start_matches('0') == "1"
3826 && bytes.len() > digits + 1
3827 && (bytes[digits] == b'.' || bytes[digits] == b')')
3828 && (bytes[digits + 1] == b' ' || bytes[digits + 1] == b'\t')
3829 }
3830 b'[' => {
3838 let mut escaped = false;
3839 let mut label_close = None;
3840 for (i, &b) in bytes.iter().enumerate().skip(1) {
3841 if escaped {
3842 escaped = false;
3843 } else if b == b'\\' {
3844 escaped = true;
3845 } else if b == b']' {
3846 label_close = Some(i);
3847 break;
3848 }
3849 }
3850 label_close.is_some_and(|i| bytes.get(i + 1) == Some(&b':'))
3851 }
3852 b'<' => {
3856 crate::utils::html_block::parse_html_block_start(text).is_some()
3857 || crate::utils::html_block::opens_untagged_html_block(text)
3858 }
3859 _ => false,
3860 }
3861}
3862
3863fn ends_with_unescaped_backslash(line: &str) -> bool {
3866 line.bytes().rev().take_while(|&b| b == b'\\').count() % 2 == 1
3867}
3868
3869fn merge_block_construct_continuations(lines: Vec<String>) -> Vec<String> {
3882 let mut merged: Vec<String> = Vec::with_capacity(lines.len());
3883 for line in lines {
3884 merged.push(line);
3885 while merged.len() > 1
3889 && (starts_block_construct(merged.last().expect("non-empty"))
3890 || ends_with_unescaped_backslash(&merged[merged.len() - 2]))
3891 {
3892 let last = merged.pop().expect("non-empty");
3893 let prev = merged.last_mut().expect("len > 1");
3894 prev.push(' ');
3895 prev.push_str(last.trim_start());
3896 }
3897 }
3898 merged
3899}
3900
3901fn keep_first_line_out_of_html_block(mut lines: Vec<String>) -> Vec<String> {
3911 let Some(first) = lines.first() else {
3912 return lines;
3913 };
3914 if !crate::utils::html_block::opens_tag_line_html_block(first.trim_start()) {
3915 return lines;
3916 }
3917 if lines.len() > 1 {
3918 let next = lines.remove(1);
3919 lines[0].push(' ');
3920 lines[0].push_str(next.trim_start());
3921 return lines;
3922 }
3923 let tag = lines.remove(0);
3924 let indent = tag.len() - tag.trim_start().len();
3925 let tag_len = crate::utils::html_block::complete_tag_len(&tag[indent..]).unwrap_or(0);
3926 let mut quote = None;
3927 let mut split = None;
3928 let mut quoted_split = None;
3929 for (offset, byte) in tag.bytes().enumerate().skip(indent).take(tag_len) {
3930 let breakable = matches!(byte, b' ' | b'\t') && !starts_block_construct(&tag[offset..]);
3931 match (quote, byte) {
3932 (None, b'"' | b'\'') => quote = Some(byte),
3933 (Some(open), _) if open == byte => quote = None,
3934 (None, _) if breakable => split = Some(offset),
3935 (Some(_), _) if breakable => quoted_split = Some(offset),
3936 _ => {}
3937 }
3938 }
3939 match split.or(quoted_split) {
3940 Some(offset) => vec![
3941 tag[..offset].trim_end().to_string(),
3942 tag[offset..].trim_start().to_string(),
3943 ],
3944 None => vec![tag],
3945 }
3946}
3947
3948fn elements_source_text(elements: &[Element]) -> (String, Vec<(usize, usize)>) {
3956 let mut text = String::new();
3957 let mut ranges = Vec::with_capacity(elements.len());
3958 for (idx, element) in elements.iter().enumerate() {
3959 let gap = source_gap_before(elements, idx);
3960 let piece = match element {
3961 Element::Text(content) => content.clone(),
3963 Element::Italic { content, underscore } => {
3964 wrap_emphasis(content, if *underscore { "_" } else { "*" }, &mut text, gap)
3965 }
3966 Element::Bold { content, underscore } => {
3967 wrap_emphasis(content, if *underscore { "__" } else { "**" }, &mut text, gap)
3968 }
3969 Element::Strikethrough { content, double } => {
3970 wrap_emphasis(content, if *double { "~~" } else { "~" }, &mut text, gap)
3971 }
3972 _ => {
3973 push_source_gap(&mut text, gap);
3974 element.to_string()
3975 }
3976 };
3977 let start = text.len();
3978 text.push_str(&piece);
3979 ranges.push((start, text.len()));
3980 }
3981 (text, ranges)
3982}
3983
3984fn reflow_elements_sentence_per_line(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
3986 let abbreviations = get_abbreviations(&options.abbreviations);
3987 let require_sentence_capital = options.require_sentence_capital;
3988 let mut lines = Vec::new();
3989
3990 let (paragraph_text, piece_ranges) = elements_source_text(elements);
3998 let paragraph = sentence_structure_with_links(
3999 ¶graph_text,
4000 options.defined_references.as_ref(),
4001 &abbreviations,
4002 options.link_text_punctuation,
4003 options.semantic_line_breaks,
4004 );
4005 let structure_from = |base: usize| ParagraphStructure {
4006 text: ¶graph_text,
4007 structure: ¶graph,
4008 base,
4009 };
4010 let mut line: Option<usize> = None;
4018 let absorb = |line_start: usize, (piece_start, piece_end): (usize, usize)| {
4026 let in_front = line_start <= piece_start;
4027 debug_assert!(
4028 in_front,
4029 "line under construction begins at {line_start}, past the element at {piece_start} of {paragraph_text:?}"
4030 );
4031 if in_front {
4032 (line_start, piece_end, Some(structure_from(line_start)))
4033 } else {
4034 (piece_start, piece_end, None)
4035 }
4036 };
4037
4038 for (idx, element) in elements.iter().enumerate() {
4039 let (line_start, line_end, structure) = absorb(line.unwrap_or(piece_ranges[idx].0), piece_ranges[idx]);
4040 if !paragraph.parentheticals.is_empty() && line.is_some() && idx > 0 {
4045 let previous_end = piece_ranges[idx - 1].1;
4046 let past = paragraph
4047 .parentheticals
4048 .partition_point(|&(start, _)| start < previous_end);
4049 if past > 0 {
4050 let group_end = paragraph.parentheticals[past - 1].1;
4051 if previous_end < group_end && line_end < group_end {
4052 line = Some(line_start);
4053 continue;
4054 }
4055 }
4056 }
4057 let combined = ¶graph_text[line_start..line_end];
4058
4059 let splits = matches!(
4065 element,
4066 Element::Text(_) | Element::Italic { .. } | Element::Bold { .. } | Element::Strikethrough { .. }
4067 );
4068
4069 if splits {
4070 let sentences = match structure {
4073 Some(structure) => split_into_sentence_ranges(
4074 combined,
4075 &abbreviations,
4076 require_sentence_capital,
4077 options.cjk_soft_break,
4078 options.defined_references.as_ref(),
4079 Some(structure),
4080 options.link_text_punctuation,
4081 ),
4082 None => trim_range(combined, 0, combined.len()).into_iter().collect(),
4083 };
4084
4085 let glued_to_next = piece_ranges
4096 .get(idx + 1)
4097 .is_some_and(|&(next_start, _)| !paragraph_text[..next_start].ends_with(is_breakable_whitespace));
4098 let next_bracketed = elements
4099 .get(idx + 1)
4100 .filter(|next| next.opens_with_bracket())
4101 .map(|_| piece_ranges[idx + 1]);
4102 let closes_before_next = |sentence: &str| -> bool {
4103 let sentence_end = line_start + combined.trim_end_matches(is_breakable_whitespace).len();
4104 if glued_to_next
4105 || inside_parenthetical(sentence_end, ¶graph.parentheticals)
4106 || text_ends_with_numbered_abbreviation(sentence, ¶graph_text[sentence_end..])
4107 {
4108 return false;
4109 }
4110 let Some((next_start, next_end)) = next_bracketed else {
4111 return true;
4112 };
4113 let probe_start = sentence_end - sentence.len();
4116 let probe = ¶graph_text[probe_start..next_end];
4117 let next_str = ¶graph_text[next_start..next_end];
4118 let probe_sentences = split_into_sentences_with_set(
4119 probe,
4120 &abbreviations,
4121 require_sentence_capital,
4122 options.cjk_soft_break,
4123 options.defined_references.as_ref(),
4124 Some(structure_from(probe_start)),
4125 options.link_text_punctuation,
4126 );
4127 probe_sentences.last().is_some_and(|last| last == next_str)
4128 };
4129
4130 if sentences.len() > 1 {
4131 let mut pending_start = None;
4137 let last = sentences.len() - 1;
4138 for (i, &(start, end)) in sentences.iter().enumerate() {
4139 let pending = &combined[*pending_start.get_or_insert(start)..end];
4140
4141 let closed = i < last || (ends_with_sentence_punct(pending) && closes_before_next(pending));
4146 if closed && !text_ends_with_abbreviation(pending, &abbreviations) {
4147 lines.push(pending.to_string());
4148 pending_start = None;
4149 }
4150 }
4151 line = pending_start.map(|held| line_start + held);
4152 } else {
4153 let trimmed = combined.trim_matches(is_breakable_whitespace);
4155
4156 if trimmed.is_empty() {
4160 continue;
4161 }
4162
4163 let ends_with_sentence_punct = ends_with_sentence_punct(trimmed);
4164
4165 if ends_with_sentence_punct
4166 && !text_ends_with_abbreviation(trimmed, &abbreviations)
4167 && closes_before_next(trimmed)
4168 {
4169 lines.push(combined.trim_matches(is_breakable_whitespace).to_string());
4172 line = None;
4173 } else {
4174 line = Some(line_start);
4176 }
4177 }
4178 } else {
4179 line = Some(line_start);
4182 }
4183 }
4184
4185 if let Some(line_start) = line {
4193 let tail = ¶graph_text[line_start..];
4197 lines.extend(split_into_sentences_with_set(
4198 tail,
4199 &abbreviations,
4200 require_sentence_capital,
4201 options.cjk_soft_break,
4202 options.defined_references.as_ref(),
4203 Some(structure_from(line_start)),
4204 options.link_text_punctuation,
4205 ));
4206 }
4207 lines
4208}
4209
4210#[derive(Clone)]
4211struct SentencePackSeparator {
4212 text: String,
4213 preserve_inline: bool,
4214}
4215
4216struct SentencePackUnit {
4217 text: String,
4218 separator_before: SentencePackSeparator,
4219}
4220
4221fn sentence_pack_separators(
4229 elements: &[Element],
4230 sentences: &[String],
4231 options: &ReflowOptions,
4232) -> Vec<SentencePackSeparator> {
4233 let mut separators = vec![
4234 SentencePackSeparator {
4235 text: " ".to_string(),
4236 preserve_inline: false,
4237 };
4238 sentences.len().saturating_sub(1)
4239 ];
4240 if options.cjk_soft_break != CjkSoftBreak::Join {
4241 return separators;
4242 }
4243
4244 let (source, _) = elements_source_text(elements);
4245 let mut cursor = 0;
4246 for (idx, pair) in sentences.windows(2).enumerate() {
4247 let Some(current_offset) = source[cursor..].find(&pair[0]) else {
4248 continue;
4249 };
4250 let current_end = cursor + current_offset + pair[0].len();
4251 let Some(next_offset) = source[current_end..].find(&pair[1]) else {
4252 cursor = current_end;
4253 continue;
4254 };
4255 let next_start = current_end + next_offset;
4256 cursor = next_start;
4257
4258 if pair[0].chars().next_back().is_some_and(joins_cjk_soft_break)
4259 && pair[1].chars().next().is_some_and(joins_cjk_soft_break)
4260 {
4261 let text = source[current_end..next_start].to_string();
4262 separators[idx] = SentencePackSeparator {
4263 preserve_inline: !text.is_empty(),
4264 text,
4265 };
4266 }
4267 }
4268 separators
4269}
4270
4271fn sentence_pack_units(elements: &[Element], options: &ReflowOptions) -> Vec<SentencePackUnit> {
4273 let sentences = reflow_elements_sentence_per_line(elements, options);
4274 let separators = sentence_pack_separators(elements, &sentences, options);
4275 let mut units: Vec<SentencePackUnit> = Vec::with_capacity(sentences.len());
4276
4277 for (idx, text) in sentences.into_iter().enumerate() {
4278 let separator_before = idx.checked_sub(1).map_or(
4279 SentencePackSeparator {
4280 text: String::new(),
4281 preserve_inline: false,
4282 },
4283 |separator| separators[separator].clone(),
4284 );
4285 units.push(SentencePackUnit { text, separator_before });
4286
4287 while units.len() > 1 && starts_block_construct(&units.last().expect("non-empty").text) {
4288 let last = units.pop().expect("non-empty");
4289 let previous = &mut units.last_mut().expect("len > 1").text;
4290 previous.push(' ');
4291 previous.push_str(last.text.trim_start());
4292 }
4293 }
4294 units
4295}
4296
4297fn sentence_pack_groups(elements: &[Element], options: &ReflowOptions) -> Vec<SentencePackUnit> {
4299 let units = sentence_pack_units(elements, options);
4300 let mut groups: Vec<SentencePackUnit> = Vec::with_capacity(units.len());
4301
4302 for unit in units {
4303 if unit.separator_before.preserve_inline
4304 && let Some(previous) = groups.last_mut()
4305 {
4306 previous.text.push_str(&unit.separator_before.text);
4307 previous.text.push_str(&unit.text);
4308 continue;
4309 }
4310 groups.push(unit);
4311 }
4312 groups
4313}
4314
4315fn reflow_elements_sentence_pack(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
4317 let mut lines: Vec<String> = Vec::new();
4318 for unit in sentence_pack_groups(elements, options) {
4319 let budget = if lines.len() == 1 {
4320 options.first_line_length.unwrap_or(options.line_length)
4321 } else {
4322 options.line_length
4323 };
4324 if let Some(current) = lines.last_mut() {
4325 let previous_len = current.len();
4326 current.push_str(&unit.separator_before.text);
4327 current.push_str(&unit.text);
4328 if budget == 0 || budget == usize::MAX || line_width(current, options) <= budget {
4329 continue;
4330 }
4331 current.truncate(previous_len);
4332 }
4333 lines.push(unit.text);
4334 }
4335 lines
4336}
4337
4338fn wrap_emphasis(content: &str, marker: &str, current_line: &mut String, gap: &str) -> String {
4342 push_source_gap(current_line, gap);
4343 format!("{marker}{content}{marker}")
4344}
4345
4346const BREAK_WORDS: &[&str] = &[
4350 "and",
4351 "or",
4352 "but",
4353 "nor",
4354 "yet",
4355 "so",
4356 "for",
4357 "which",
4358 "that",
4359 "because",
4360 "when",
4361 "if",
4362 "while",
4363 "where",
4364 "although",
4365 "though",
4366 "unless",
4367 "since",
4368 "after",
4369 "before",
4370 "until",
4371 "as",
4372 "once",
4373 "whether",
4374 "however",
4375 "therefore",
4376 "moreover",
4377 "furthermore",
4378 "nevertheless",
4379 "whereas",
4380];
4381
4382fn is_clause_punctuation(c: char) -> bool {
4384 matches!(c, ',' | ';' | ':' | '\u{2014}') }
4386
4387fn clause_break_allowed_after(chars: &[char], i: usize) -> bool {
4398 match chars.get(i + 1) {
4399 None => true,
4400 Some(next) => is_breakable_whitespace(*next),
4401 }
4402}
4403
4404fn paren_group_end<'a>(slice: &'a str, element_spans: &[ElementSpan], offset: usize) -> Option<(usize, &'a str)> {
4418 debug_assert!(slice.starts_with('('));
4419 let mut depth: i32 = 0;
4420 for (local_byte, c) in slice.char_indices() {
4421 let global_byte = offset + local_byte;
4422 if depth > 0 && is_inside_element(global_byte, element_spans) {
4427 continue;
4428 }
4429 match c {
4430 '(' => depth += 1,
4431 ')' => {
4432 depth -= 1;
4433 if depth == 0 {
4434 let end = local_byte + 1;
4435 let inner = &slice[1..local_byte];
4436 return Some((end, inner));
4437 }
4438 }
4439 _ => {}
4440 }
4441 }
4442 None
4443}
4444
4445fn split_at_parenthetical(
4462 text: &str,
4463 line_length: usize,
4464 element_spans: &[ElementSpan],
4465 length_mode: ReflowLengthMode,
4466) -> Option<(String, String)> {
4467 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
4468
4469 if text.starts_with('(')
4471 && let Some((end_local, inner)) = paren_group_end(text, element_spans, 0)
4472 && inner.contains(' ')
4473 {
4474 let mut first_end = end_local;
4481 loop {
4482 first_end += text[first_end..]
4483 .char_indices()
4484 .take_while(|(_, c)| !is_breakable_whitespace(*c))
4485 .last()
4486 .map_or(0, |(idx, c)| idx + c.len_utf8());
4487 match element_containing(first_end, element_spans) {
4488 Some(span) => first_end = span.end,
4489 None => break,
4490 }
4491 }
4492 let rest_start = first_end;
4493 let first = &text[..first_end];
4494 if measure(first, 0, element_spans, length_mode).fits(line_length) {
4497 let rest = text[rest_start..].trim_start();
4498 if !rest.is_empty() {
4499 return Some((first.to_string(), rest.to_string()));
4500 }
4501 }
4502 }
4503
4504 let mut best_open_byte: Option<usize> = None;
4506 let mut pos = 0usize;
4507 while pos < text.len() {
4508 if text.as_bytes()[pos] != b'(' {
4510 let c = text[pos..].chars().next().unwrap();
4511 pos += c.len_utf8();
4512 continue;
4513 }
4514 if is_inside_element(pos, element_spans) {
4516 pos += 1;
4517 continue;
4518 }
4519 if let Some((end_local, inner)) = paren_group_end(&text[pos..], element_spans, pos) {
4520 let first = text[..pos].trim_end_matches(is_breakable_whitespace);
4521 let first_len = measure(first, 0, element_spans, length_mode).effective();
4522 if first.len() < pos
4525 && !first.is_empty()
4526 && first_len >= min_first_len
4527 && first_len <= line_length
4528 && inner.contains(' ')
4529 && best_open_byte.is_none_or(|prev| pos > prev)
4530 {
4531 best_open_byte = Some(pos);
4532 }
4533 pos += end_local;
4534 } else {
4535 pos += 1;
4536 }
4537 }
4538
4539 let open_byte = best_open_byte?;
4540 let first = text[..open_byte].trim_end_matches(is_breakable_whitespace).to_string();
4541 let rest = text[open_byte..].to_string();
4542 if first.is_empty() || rest.trim().is_empty() {
4543 return None;
4544 }
4545 Some((first, rest))
4546}
4547
4548#[derive(Clone, Copy, Debug, PartialEq, Eq)]
4556struct ElementSpan {
4557 start: usize,
4558 end: usize,
4559 full: usize,
4560 link_saving: usize,
4563 code_saving: usize,
4565 is_hard: bool,
4567}
4568
4569impl ElementSpan {
4570 fn new(start: usize, len: usize, full: usize, width: LineWidth, is_hard: bool) -> Self {
4573 Self {
4574 start,
4575 end: start + len,
4576 full,
4577 link_saving: full - width.link_exempt,
4578 code_saving: full - width.code_exempt,
4579 is_hard,
4580 }
4581 }
4582
4583 fn contains(&self, pos: usize) -> bool {
4584 pos > self.start && pos < self.end
4585 }
4586
4587 fn within(&self, start: usize, end: usize) -> bool {
4588 self.start >= start && self.end <= end
4589 }
4590
4591 fn exempt_width(&self) -> LineWidth {
4592 LineWidth {
4593 link_exempt: self.full - self.link_saving,
4594 code_exempt: self.full - self.code_saving,
4595 }
4596 }
4597}
4598
4599fn link_text_parts(element: &Element, break_link_text: bool) -> Option<(&str, &str, &str)> {
4605 if !break_link_text {
4606 return None;
4607 }
4608 let (raw, open) = match element {
4609 Element::Link(raw)
4610 | Element::ReferenceLink(raw)
4611 | Element::EmptyReferenceLink(raw)
4612 | Element::ShortcutReference(raw) => (raw.as_str(), 0),
4613 Element::InlineImage(raw) | Element::ReferenceImage(raw) | Element::EmptyReferenceImage(raw) => {
4614 (raw.as_str(), 1)
4615 }
4616 _ => return None,
4617 };
4618 let inner = bracketed_text(raw, open)?;
4619 Some((&raw[..=open], inner, &raw[open + 1 + inner.len()..]))
4620}
4621
4622fn element_is_hard(element: &Element, break_link_text: bool) -> bool {
4630 match element {
4631 Element::Bold { content, .. } | Element::Italic { content, .. } | Element::Strikethrough { content, .. } => {
4632 content.contains(['[', '`', '<', '$', '{'])
4633 }
4634 _ => match link_text_parts(element, break_link_text) {
4635 Some((_, inner, suffix)) => {
4636 inner.contains(['[', '`', '<', '$', '{']) || suffix.chars().any(char::is_whitespace)
4637 }
4638 None => true,
4639 },
4640 }
4641}
4642
4643fn compute_element_spans(
4649 elements: &[Element],
4650 mode: ReflowLengthMode,
4651 exemptions: LengthExemptions,
4652 break_link_text: bool,
4653) -> Vec<ElementSpan> {
4654 let mut spans = Vec::new();
4655 let mut offset = 0;
4656 for element in elements {
4657 let len = element.display_len(ReflowLengthMode::Bytes);
4658 if !matches!(element, Element::Text(_)) {
4659 let full = element.display_len(mode);
4660 let width = element.exempt_width(mode, exemptions);
4661 spans.push(ElementSpan::new(
4662 offset,
4663 len,
4664 full,
4665 width,
4666 element_is_hard(element, break_link_text),
4667 ));
4668 }
4669 offset += len;
4670 }
4671 spans
4672}
4673
4674fn measure(text: &str, offset: usize, spans: &[ElementSpan], mode: ReflowLengthMode) -> LineWidth {
4682 let full = display_len(text, mode);
4683 let end = offset + text.len();
4684 let mut width = LineWidth::plain(full);
4685 for span in spans.iter().filter(|span| span.within(offset, end)) {
4686 width.link_exempt -= span.link_saving;
4687 width.code_exempt -= span.code_saving;
4688 }
4689 width
4690}
4691
4692fn line_width_components(line: &str, options: &ReflowOptions) -> LineWidth {
4697 let raw = display_len(line, options.length_mode);
4698 if !options.length_exemptions.any() {
4699 return LineWidth::plain(raw);
4700 }
4701 let elements = parse_markdown_elements_inner(
4702 line,
4703 options.attr_lists,
4704 options.myst_roles,
4705 options.defined_references.as_ref(),
4706 );
4707 let spans = compute_element_spans(
4708 &elements,
4709 options.length_mode,
4710 options.length_exemptions,
4711 options.break_link_text,
4712 );
4713 measure(line, 0, &spans, options.length_mode)
4714}
4715
4716fn line_width(line: &str, options: &ReflowOptions) -> usize {
4718 line_width_components(line, options).effective()
4719}
4720
4721fn line_fits(line: &str, options: &ReflowOptions) -> bool {
4727 display_len(line, options.length_mode) <= options.line_length || line_width(line, options) <= options.line_length
4728}
4729
4730fn element_containing(pos: usize, spans: &[ElementSpan]) -> Option<ElementSpan> {
4734 let past = spans.partition_point(|span| span.start < pos);
4735 past.checked_sub(1)
4736 .map(|index| spans[index])
4737 .filter(|span| span.contains(pos))
4738}
4739
4740fn is_inside_element(pos: usize, spans: &[ElementSpan]) -> bool {
4742 element_containing(pos, spans).is_some()
4743}
4744
4745const MIN_SPLIT_RATIO: f64 = 0.3;
4748
4749fn split_at_clause_punctuation(
4753 text: &str,
4754 line_length: usize,
4755 element_spans: &[ElementSpan],
4756 length_mode: ReflowLengthMode,
4757) -> Option<(String, String)> {
4758 let chars: Vec<char> = text.chars().collect();
4759 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
4760
4761 let mut width_acc = LineWidth::default();
4767 let mut search_end_char = 0;
4768 let mut byte = 0usize;
4769 let mut idx = 0usize;
4770 while idx < chars.len() {
4771 let (advance_chars, advance_bytes, width) = match element_spans.iter().find(|s| s.start == byte) {
4772 Some(span) => {
4773 let source = &text[span.start..span.end];
4774 (
4775 source.chars().count(),
4776 source.len(),
4777 measure(source, span.start, element_spans, length_mode),
4778 )
4779 }
4780 None => {
4781 let c = chars[idx];
4782 (
4783 1,
4784 c.len_utf8(),
4785 LineWidth::plain(display_len(&c.to_string(), length_mode)),
4786 )
4787 }
4788 };
4789 if !(width_acc + width).fits(line_length) {
4790 break;
4791 }
4792 width_acc += width;
4793 byte += advance_bytes;
4794 idx += advance_chars;
4795 search_end_char = idx;
4796 }
4797
4798 let mut paren_depth: i32 = 0;
4805 let mut best_pos = None;
4806 for i in (0..search_end_char).rev() {
4807 let byte_start: usize = chars[..i].iter().map(|c| c.len_utf8()).sum();
4809 let byte_after: usize = byte_start + chars[i].len_utf8();
4811
4812 if !is_inside_element(byte_start, element_spans) {
4813 match chars[i] {
4814 ')' => paren_depth += 1,
4815 '(' => paren_depth = paren_depth.saturating_sub(1),
4816 _ => {}
4817 }
4818 }
4819
4820 if paren_depth == 0
4821 && is_clause_punctuation(chars[i])
4822 && clause_break_allowed_after(&chars, i)
4823 && !is_inside_element(byte_after, element_spans)
4824 {
4825 best_pos = Some(i);
4826 break;
4827 }
4828 }
4829
4830 let pos = best_pos?;
4831
4832 let first: String = chars[..=pos].iter().collect();
4834 if measure(&first, 0, element_spans, length_mode).effective() < min_first_len {
4835 return None;
4836 }
4837
4838 let rest: String = chars[pos + 1..].iter().collect();
4840 let rest = rest.trim_start().to_string();
4841
4842 if rest.is_empty() {
4843 return None;
4844 }
4845
4846 Some((first, rest))
4847}
4848
4849fn paren_depth_map(text: &str, element_spans: &[ElementSpan]) -> Vec<i32> {
4856 let mut map = vec![0i32; text.len()];
4857 let mut depth = 0i32;
4858 for (byte, c) in text.char_indices() {
4859 if !is_inside_element(byte, element_spans) {
4860 match c {
4861 '(' => depth += 1,
4862 ')' => depth = depth.saturating_sub(1),
4863 _ => {}
4864 }
4865 }
4866 let end = (byte + c.len_utf8()).min(map.len());
4868 for slot in &mut map[byte..end] {
4869 *slot = depth;
4870 }
4871 }
4872 map
4873}
4874
4875fn is_standalone_parenthetical(line: &str) -> bool {
4884 let trimmed = line.trim();
4885 if !trimmed.starts_with('(') {
4886 return false;
4887 }
4888 let Some(close) = trimmed.rfind(')') else {
4891 return false;
4892 };
4893 if trimmed[close + 1..].contains(char::is_whitespace) {
4894 return false;
4895 }
4896 let core = &trimmed[..=close];
4897 let inner = &core[1..core.len() - 1];
4899 if !inner.contains(' ') {
4900 return false;
4901 }
4902 let mut depth = 0i32;
4904 for c in core.chars() {
4905 match c {
4906 '(' => depth += 1,
4907 ')' => depth -= 1,
4908 _ => {}
4909 }
4910 if depth < 0 {
4911 return false;
4912 }
4913 }
4914 depth == 0
4915}
4916
4917fn split_at_break_word(
4921 text: &str,
4922 line_length: usize,
4923 element_spans: &[ElementSpan],
4924 length_mode: ReflowLengthMode,
4925 custom_break_words: &[String],
4926) -> Option<(String, String)> {
4927 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
4928 let mut best_split = None;
4929
4930 let depth_map = paren_depth_map(text, element_spans);
4933
4934 let mut offset = 0;
4937 for token in text.split_inclusive(' ') {
4938 let Some(word) = token.strip_suffix(' ') else {
4939 break;
4940 };
4941 let position = offset;
4942 offset += token.len();
4943 if word.is_empty()
4944 || !(BREAK_WORDS.iter().any(|default| word.eq_ignore_ascii_case(default))
4945 || custom_break_words
4946 .iter()
4947 .any(|custom| unicase::UniCase::new(word) == unicase::UniCase::new(custom.as_str())))
4948 {
4949 continue;
4950 }
4951 let first_part = text[..position].trim_end();
4952 let first_part_len = measure(first_part, 0, element_spans, length_mode).effective();
4953 let inside_paren = depth_map.get(position).is_some_and(|&depth| depth > 0);
4954 if first_part_len >= min_first_len
4955 && first_part_len <= line_length
4956 && !is_inside_element(position, element_spans)
4957 && !inside_paren
4958 {
4959 best_split = Some(position);
4961 }
4962 }
4963
4964 let byte_start = best_split?;
4965
4966 let first = text[..byte_start].trim_end().to_string();
4967 let rest = text[byte_start..].to_string();
4968
4969 if first.is_empty() || rest.trim().is_empty() {
4970 return None;
4971 }
4972
4973 Some((first, rest))
4974}
4975
4976fn replaces_whitespace(text: &str, first: &str, rest: &str, element_spans: &[ElementSpan]) -> bool {
4987 if !text.starts_with(first) || !text.ends_with(rest) {
4988 return false;
4989 }
4990 let gap_end = text.len() - rest.len();
4991 gap_end > first.len()
4992 && text[first.len()..gap_end].chars().all(is_breakable_whitespace)
4993 && !element_spans
4994 .iter()
4995 .any(|span| first.len() < span.end && span.start < gap_end)
4996}
4997
4998fn cascade_split_line(text: &str, options: &ReflowOptions) -> Vec<String> {
5009 let line_length = options.line_length;
5010 let length_mode = options.length_mode;
5011 let attr_lists = options.attr_lists;
5012 let myst_roles = options.myst_roles;
5013 let defined_references = options.defined_references.as_ref();
5014 if line_length == 0 || display_len(text, length_mode) <= line_length {
5015 return vec![text.to_string()];
5016 }
5017
5018 let elements = parse_markdown_elements_inner(text, attr_lists, myst_roles, defined_references);
5019 let element_spans = compute_element_spans(
5020 &elements,
5021 length_mode,
5022 options.length_exemptions,
5023 options.break_link_text,
5024 );
5025
5026 if measure(text, 0, &element_spans, length_mode).fits(line_length) {
5029 return vec![text.to_string()];
5030 }
5031
5032 let rebased_spans = |start: usize| -> Vec<ElementSpan> {
5036 if start == 0 {
5037 return element_spans.clone();
5038 }
5039 element_spans
5040 .iter()
5041 .filter(|span| span.end > start)
5042 .map(|span| ElementSpan {
5043 start: span.start.saturating_sub(start),
5044 end: span.end.saturating_sub(start),
5045 ..*span
5046 })
5047 .collect()
5048 };
5049
5050 let mut result = Vec::new();
5051 let mut start = 0usize;
5052
5053 loop {
5054 let remaining = &text[start..];
5055 let spans = rebased_spans(start);
5056 if measure(remaining, 0, &spans, length_mode).fits(line_length) {
5057 result.push(remaining.to_string());
5058 return result;
5059 }
5060
5061 let at_whitespace = |candidate: Option<(String, String)>| {
5070 candidate.filter(|(first, rest)| replaces_whitespace(remaining, first, rest, &spans))
5071 };
5072 let split = at_whitespace(split_at_parenthetical(remaining, line_length, &spans, length_mode))
5073 .or_else(|| at_whitespace(split_at_clause_punctuation(remaining, line_length, &spans, length_mode)))
5074 .or_else(|| {
5075 at_whitespace(split_at_break_word(
5076 remaining,
5077 line_length,
5078 &spans,
5079 length_mode,
5080 &options.break_words,
5081 ))
5082 });
5083
5084 if let Some((first, rest)) = split {
5085 let consumed = remaining.len().saturating_sub(rest.len());
5086 if consumed == 0 {
5089 break;
5090 }
5091 result.push(first);
5092 start += consumed;
5093 continue;
5094 }
5095
5096 break;
5098 }
5099
5100 let mut fallback_options = options.clone();
5102 fallback_options.break_on_sentences = false;
5103 fallback_options.preserve_breaks = false;
5104 fallback_options.sentence_per_line = false;
5105 fallback_options.semantic_line_breaks = false;
5106 fallback_options.require_sentence_capital = true;
5107 fallback_options.max_list_continuation_indent = None;
5108 fallback_options.defined_references = None;
5109 let remaining = &text[start..];
5110 let tail_elements = if start == 0 {
5111 elements
5112 } else {
5113 parse_markdown_elements_inner(remaining, attr_lists, myst_roles, defined_references)
5114 };
5115 result.extend(reflow_elements(&tail_elements, &fallback_options));
5116 result
5117}
5118
5119fn cascade_semantic_sentence(text: &str, options: &ReflowOptions) -> Vec<String> {
5123 let (Some(open), Some(close)) = (text.find('('), text.rfind(')')) else {
5124 return cascade_split_line(text, options);
5125 };
5126 if open >= close || !text[open..close].contains(['.', '!', '?', '。', '!', '?']) {
5127 return cascade_split_line(text, options);
5128 }
5129 let abbreviations = get_abbreviations(&options.abbreviations);
5130 let mut structure = sentence_structure_with_links(
5131 text,
5132 options.defined_references.as_ref(),
5133 &abbreviations,
5134 options.link_text_punctuation,
5135 true,
5136 );
5137 let protected = structure.parentheticals.len();
5138 structure
5139 .parentheticals
5140 .retain(|&(start, end)| line_fits(&text[start..end], options));
5141 if structure.parentheticals.len() == protected {
5142 return cascade_split_line(text, options);
5143 }
5144
5145 split_into_sentence_ranges(
5146 text,
5147 &abbreviations,
5148 options.require_sentence_capital,
5149 options.cjk_soft_break,
5150 options.defined_references.as_ref(),
5151 Some(ParagraphStructure {
5152 text,
5153 structure: &structure,
5154 base: 0,
5155 }),
5156 options.link_text_punctuation,
5157 )
5158 .into_iter()
5159 .flat_map(|(start, end)| cascade_split_line(&text[start..end], options))
5160 .collect()
5161}
5162
5163fn reflow_elements_semantic(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
5167 let sentence_lines = reflow_elements_sentence_per_line(elements, options);
5169
5170 if options.line_length == 0 {
5173 return sentence_lines;
5174 }
5175
5176 let mut result = Vec::new();
5177 let mut sentence_starts = Vec::new();
5178 for line in sentence_lines {
5179 if options.link_text_punctuation == LinkTextPunctuation::Trailing {
5180 sentence_starts.push(result.len());
5181 }
5182 if line_fits(&line, options) {
5183 result.push(line);
5184 } else {
5185 result.extend(cascade_semantic_sentence(&line, options));
5186 }
5187 }
5188
5189 let min_line_len = ((options.line_length as f64) * MIN_SPLIT_RATIO) as usize;
5192 let mut merged: Vec<String> = Vec::with_capacity(result.len());
5193 for (index, line) in result.into_iter().enumerate() {
5194 if !merged.is_empty()
5197 && sentence_starts.binary_search(&index).is_err()
5198 && line_width(&line, options) < min_line_len
5199 && !line.trim().is_empty()
5200 {
5201 if is_standalone_parenthetical(&line) {
5204 merged.push(line);
5205 continue;
5206 }
5207
5208 let prev_ends_at_sentence = {
5210 let trimmed = merged.last().unwrap().trim_end();
5211 trimmed
5212 .chars()
5213 .rev()
5214 .find(|&c| !(is_closing_quote(c) || is_closing_bracket(c)))
5215 .is_some_and(|c| matches!(c, '.' | '!' | '?') || is_cjk_sentence_ending(c))
5216 };
5217
5218 if !prev_ends_at_sentence {
5219 let prev = merged.last_mut().unwrap();
5220 let combined = format!("{prev} {line}");
5221 if line_fits(&combined, options) {
5223 *prev = combined;
5224 continue;
5225 }
5226 }
5227 }
5228 merged.push(line);
5229 }
5230 merged
5231}
5232
5233fn rfind_safe_space(
5244 line: &str,
5245 element_spans: &[ElementSpan],
5246 options: &ReflowOptions,
5247 relax_soft_spans: bool,
5248) -> Option<usize> {
5249 line.char_indices().rev().map(|(pos, _)| pos).find(|&pos| {
5250 line.as_bytes()[pos] == b' '
5251 && !is_inside_element_filtered(pos, element_spans, options, relax_soft_spans)
5252 && !starts_block_construct(&line[pos + 1..])
5253 && !ends_with_unescaped_backslash(line[..pos].trim_end_matches(is_breakable_whitespace))
5254 })
5255}
5256
5257fn is_inside_element_filtered(
5258 pos: usize,
5259 spans: &[ElementSpan],
5260 options: &ReflowOptions,
5261 relax_soft_spans: bool,
5262) -> bool {
5263 spans.iter().any(|span| {
5264 span.contains(pos)
5265 && (!relax_soft_spans
5266 || span.is_hard
5267 || (options.atomic_spans && span.exempt_width().fits(options.line_length)))
5268 })
5269}
5270
5271#[derive(Clone, Copy)]
5276struct Attached<'a> {
5277 text: &'a str,
5278 width: LineWidth,
5279 separator: &'a str,
5280}
5281
5282fn break_before_attached(
5299 lines: &mut Vec<String>,
5300 current_line: &mut String,
5301 current_width: &mut LineWidth,
5302 element_spans: &mut Vec<ElementSpan>,
5303 attach: Attached<'_>,
5304 options: &ReflowOptions,
5305) -> Option<usize> {
5306 let length_mode = options.length_mode;
5307 let last_space = rfind_safe_space(current_line, element_spans, options, false)
5308 .or_else(|| rfind_safe_space(current_line, element_spans, options, true))?;
5309 let before = current_line[..last_space]
5310 .trim_end_matches(is_breakable_whitespace)
5311 .to_string();
5312 let after = current_line[last_space + 1..].to_string();
5313 let after_width = measure(&after, last_space + 1, element_spans, length_mode);
5314 lines.push(before);
5315 let carried = after.len();
5316 let Attached { text, width, separator } = attach;
5317 *current_line = format!("{after}{separator}{text}");
5318 *current_width = after_width + LineWidth::plain(display_len(separator, length_mode)) + width;
5319 rebase_spans_after_break(element_spans, last_space + 1);
5320 Some(carried)
5321}
5322
5323fn rebase_spans_after_break(element_spans: &mut Vec<ElementSpan>, carried_start: usize) {
5332 element_spans.retain(|span| span.end > carried_start);
5333 for span in element_spans.iter_mut() {
5334 span.start = span.start.saturating_sub(carried_start);
5335 span.end -= carried_start;
5336 }
5337}
5338
5339fn reflow_elements(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
5341 let mut lines = Vec::new();
5342 let mut current_line = String::new();
5343 let mut current_width = LineWidth::default();
5346 let mut current_line_element_spans: Vec<ElementSpan> = Vec::new();
5348 let length_mode = options.length_mode;
5349 let exemptions = options.length_exemptions;
5350
5351 for (idx, element) in elements.iter().enumerate() {
5352 let element_len = element.display_len(length_mode);
5353 let element_width = element.exempt_width(length_mode, exemptions);
5354 let is_hard = element_is_hard(element, options.break_link_text);
5355
5356 let is_adjacent_to_prev = if idx > 0 {
5365 match (&elements[idx - 1], element) {
5366 (Element::Text(t), _) => !t.is_empty() && !t.ends_with(is_breakable_whitespace),
5367 (_, Element::Text(t)) => !t.is_empty() && !t.starts_with(is_breakable_whitespace),
5368 _ => true,
5369 }
5370 } else {
5371 false
5372 };
5373
5374 if let Element::Text(text) = element {
5376 let has_leading_space = text.starts_with(is_breakable_whitespace);
5378 let words: Vec<&str> = split_breakable_words(text).collect();
5380
5381 for (i, word) in words.iter().enumerate() {
5382 let word_width = LineWidth::plain(display_len(word, length_mode));
5384 let is_trailing_punct = word.chars().all(|c| {
5390 matches!(c, ',' | '.' | ':' | ';' | '!' | '?' | ')' | ']' | '}') || is_significant_whitespace(c)
5391 });
5392
5393 let is_first_adjacent = i == 0 && is_adjacent_to_prev;
5396
5397 if is_first_adjacent {
5398 if !(current_width + word_width).fits(options.line_length)
5400 && !current_width.is_empty()
5401 && break_before_attached(
5402 &mut lines,
5403 &mut current_line,
5404 &mut current_width,
5405 &mut current_line_element_spans,
5406 Attached {
5407 text: word,
5408 width: word_width,
5409 separator: "",
5410 },
5411 options,
5412 )
5413 .is_some()
5414 {
5415 } else {
5420 current_line.push_str(word);
5421 current_width += word_width;
5422 }
5423 } else if !current_width.is_empty()
5424 && !(current_width + LineWidth::plain(1) + word_width).fits(options.line_length)
5425 {
5426 if is_trailing_punct {
5427 if break_before_attached(
5434 &mut lines,
5435 &mut current_line,
5436 &mut current_width,
5437 &mut current_line_element_spans,
5438 Attached {
5439 text: word,
5440 width: word_width,
5441 separator: " ",
5442 },
5443 options,
5444 )
5445 .is_none()
5446 {
5447 current_line.push(' ');
5448 current_line.push_str(word);
5449 current_width += LineWidth::plain(1) + word_width;
5450 }
5451 } else if !starts_block_construct(word)
5452 && !ends_with_unescaped_backslash(current_line.trim_end_matches(is_breakable_whitespace))
5453 {
5454 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
5456 current_line = word.to_string();
5457 current_width = word_width;
5458 current_line_element_spans.clear();
5459 } else if break_before_attached(
5460 &mut lines,
5461 &mut current_line,
5462 &mut current_width,
5463 &mut current_line_element_spans,
5464 Attached {
5465 text: word,
5466 width: word_width,
5467 separator: " ",
5468 },
5469 options,
5470 )
5471 .is_some()
5472 {
5473 } else {
5479 if i > 0 || has_leading_space {
5482 current_line.push(' ');
5483 current_width += LineWidth::plain(1);
5484 }
5485 current_line.push_str(word);
5486 current_width += word_width;
5487 }
5488 } else {
5489 let add_space = !current_width.is_empty() && (i > 0 || has_leading_space);
5501 if add_space {
5502 current_line.push(' ');
5503 current_width += LineWidth::plain(1);
5504 }
5505 current_line.push_str(word);
5506 current_width += word_width;
5507 }
5508 }
5509 } else {
5510 let link_parts = link_text_parts(element, options.break_link_text);
5511 let span_info = match element {
5512 Element::Italic { content, underscore } => {
5513 let marker = if *underscore { "_" } else { "*" };
5514 Some((content.as_str(), marker, marker, false))
5515 }
5516 Element::Bold { content, underscore } => {
5517 let marker = if *underscore { "__" } else { "**" };
5518 Some((content.as_str(), marker, marker, false))
5519 }
5520 Element::Strikethrough { content, double } => {
5521 let marker = if *double { "~~" } else { "~" };
5522 Some((content.as_str(), marker, marker, false))
5523 }
5524 Element::Code { content, marker } => Some((content.as_str(), marker.as_str(), marker.as_str(), true)),
5525 _ => link_parts.map(|(prefix, inner, suffix)| (inner, prefix, suffix, false)),
5526 };
5527 let is_link = link_parts.is_some();
5528
5529 let breakable: Option<Vec<&str>> = match span_info {
5536 Some((content, _, suffix, is_code)) => {
5537 if is_code {
5538 (!options.atomic_spans && options.wrap_code_spans && code_span_wraps_losslessly(content))
5539 .then(|| split_breakable_words(content).collect())
5540 } else if is_link {
5541 (!options.atomic_spans || !element_width.fits(options.line_length))
5542 .then(|| {
5543 link_text_break_units(
5544 content,
5545 suffix,
5546 options.line_length,
5547 length_mode,
5548 options.defined_references.as_ref(),
5549 options.attr_lists,
5550 )
5551 })
5552 .flatten()
5553 } else {
5554 (!options.atomic_spans || element_len > options.line_length)
5555 .then(|| breakable_units(content, options.defined_references.as_ref(), options.attr_lists))
5556 .flatten()
5557 }
5558 }
5559 None => None,
5560 };
5561
5562 if let Some(words) = breakable {
5563 let (_, prefix, suffix, is_code) = span_info.expect("breakable implies a span");
5564 let n = words.len();
5565 if n == 0 {
5566 let full = format!("{prefix}{suffix}");
5568 let full_width = LineWidth::plain(display_len(&full, length_mode));
5569 if !is_adjacent_to_prev && !current_width.is_empty() {
5570 current_line.push(' ');
5571 current_width += LineWidth::plain(1);
5572 }
5573 current_line.push_str(&full);
5574 current_width += full_width;
5575 } else {
5576 let suffix_span_width = LineWidth::plain(display_len(suffix, length_mode));
5582
5583 for (i, word) in words.iter().enumerate() {
5584 let is_first = i == 0;
5585 let is_last = i == n - 1;
5586
5587 let space_start = if is_first && is_code && word.starts_with('`') {
5588 " "
5589 } else {
5590 ""
5591 };
5592 let space_end = if is_last && is_code && word.ends_with('`') {
5593 " "
5594 } else {
5595 ""
5596 };
5597
5598 let word_str: String = match (is_first, is_last) {
5599 (true, true) => format!("{prefix}{space_start}{word}{space_end}{suffix}"),
5600 (true, false) => format!("{prefix}{space_start}{word}"),
5601 (false, true) => format!("{word}{space_end}{suffix}"),
5602 (false, false) => word.to_string(),
5603 };
5604 let word_elements = parse_elements(&word_str, options);
5605 let word_spans =
5606 compute_element_spans(&word_elements, length_mode, exemptions, options.break_link_text);
5607 let word_width = measure(&word_str, 0, &word_spans, length_mode);
5608
5609 let needs_space = if is_first {
5610 !is_adjacent_to_prev && !current_width.is_empty()
5611 } else {
5612 !current_width.is_empty()
5613 };
5614
5615 if needs_space
5616 && !(current_width + LineWidth::plain(1) + word_width).fits(options.line_length)
5617 && !starts_block_construct(&word_str)
5618 {
5619 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
5620 current_line = word_str;
5621 current_width = word_width;
5622 current_line_element_spans.clear();
5623 for span in word_spans {
5624 current_line_element_spans.push(span);
5625 }
5626 if is_link && is_last {
5627 current_line_element_spans.push(ElementSpan::new(
5628 word.len(),
5629 suffix.len(),
5630 display_len(suffix, length_mode),
5631 suffix_span_width,
5632 true,
5633 ));
5634 }
5635 } else {
5636 let mut start_pos = current_line.len();
5637 if needs_space {
5638 current_line.push(' ');
5639 current_width += LineWidth::plain(1);
5640 start_pos += 1;
5641 }
5642 current_line.push_str(&word_str);
5643 current_width += word_width;
5644 for mut span in word_spans {
5645 span.start += start_pos;
5646 span.end += start_pos;
5647 current_line_element_spans.push(span);
5648 }
5649 if is_link && is_last {
5650 current_line_element_spans.push(ElementSpan::new(
5651 start_pos + word.len(),
5652 suffix.len(),
5653 display_len(suffix, length_mode),
5654 suffix_span_width,
5655 true,
5656 ));
5657 }
5658 }
5659 }
5660 }
5661 } else {
5662 let element_str = format!("{element}");
5665
5666 if is_adjacent_to_prev {
5667 if !(current_width + element_width).fits(options.line_length)
5669 && let Some(carried) = break_before_attached(
5670 &mut lines,
5671 &mut current_line,
5672 &mut current_width,
5673 &mut current_line_element_spans,
5674 Attached {
5675 text: &element_str,
5676 width: element_width,
5677 separator: "",
5678 },
5679 options,
5680 )
5681 {
5682 current_line_element_spans.push(ElementSpan::new(
5686 carried,
5687 element_str.len(),
5688 element_len,
5689 element_width,
5690 is_hard,
5691 ));
5692 } else {
5693 let start = current_line.len();
5694 current_line.push_str(&element_str);
5695 current_width += element_width;
5696 current_line_element_spans.push(ElementSpan::new(
5697 start,
5698 element_str.len(),
5699 element_len,
5700 element_width,
5701 is_hard,
5702 ));
5703 }
5704 } else if !current_width.is_empty()
5705 && !(current_width + LineWidth::plain(1) + element_width).fits(options.line_length)
5706 {
5707 if !starts_block_construct(&element_str) {
5708 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
5710 current_line.clone_from(&element_str);
5711 current_width = element_width;
5712 current_line_element_spans.clear();
5713 current_line_element_spans.push(ElementSpan::new(
5714 0,
5715 element_str.len(),
5716 element_len,
5717 element_width,
5718 is_hard,
5719 ));
5720 } else if let Some(carried) = break_before_attached(
5721 &mut lines,
5722 &mut current_line,
5723 &mut current_width,
5724 &mut current_line_element_spans,
5725 Attached {
5726 text: &element_str,
5727 width: element_width,
5728 separator: " ",
5729 },
5730 options,
5731 ) {
5732 let start = carried + 1;
5736 current_line_element_spans.push(ElementSpan::new(
5737 start,
5738 element_str.len(),
5739 element_len,
5740 element_width,
5741 is_hard,
5742 ));
5743 } else {
5744 let ends_with_opener =
5747 current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
5748 if !ends_with_opener {
5749 current_line.push(' ');
5750 current_width += LineWidth::plain(1);
5751 }
5752 let start = current_line.len();
5753 current_line.push_str(&element_str);
5754 current_width += element_width;
5755 current_line_element_spans.push(ElementSpan::new(
5756 start,
5757 element_str.len(),
5758 element_len,
5759 element_width,
5760 is_hard,
5761 ));
5762 }
5763 } else {
5764 let ends_with_opener =
5766 current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
5767 if !current_width.is_empty() && !ends_with_opener {
5768 current_line.push(' ');
5769 current_width += LineWidth::plain(1);
5770 }
5771 let start = current_line.len();
5772 current_line.push_str(&element_str);
5773 current_width += element_width;
5774 current_line_element_spans.push(ElementSpan::new(
5775 start,
5776 element_str.len(),
5777 element_len,
5778 element_width,
5779 is_hard,
5780 ));
5781 }
5782 }
5783 }
5784 }
5785
5786 if !current_line.is_empty() {
5788 lines.push(current_line.trim_end_matches(is_breakable_whitespace).to_string());
5789 }
5790
5791 lines
5792}
5793
5794pub fn reflow_markdown(content: &str, options: &ReflowOptions) -> String {
5796 let lines: Vec<&str> = content.lines().collect();
5797 let inside_code_span = lines_touching_multiline_code_span(content);
5799 let mut result = Vec::new();
5800 let mut i = 0;
5801
5802 while i < lines.len() {
5803 let line = lines[i];
5804 let trimmed = line.trim();
5805
5806 if trimmed.is_empty() {
5808 result.push(String::new());
5809 i += 1;
5810 continue;
5811 }
5812
5813 if trimmed.starts_with('#') {
5815 result.push(line.to_string());
5816 i += 1;
5817 continue;
5818 }
5819
5820 if trimmed.starts_with(":::") {
5822 result.push(line.to_string());
5823 i += 1;
5824 continue;
5825 }
5826
5827 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
5829 result.push(line.to_string());
5830 i += 1;
5831 while i < lines.len() {
5833 result.push(lines[i].to_string());
5834 if lines[i].trim().starts_with("```") || lines[i].trim().starts_with("~~~") {
5835 i += 1;
5836 break;
5837 }
5838 i += 1;
5839 }
5840 continue;
5841 }
5842
5843 if calculate_indentation_width_default(line) >= 4 {
5845 result.push(line.to_string());
5847 i += 1;
5848 while i < lines.len() {
5849 let next_line = lines[i];
5850 if calculate_indentation_width_default(next_line) >= 4 || next_line.trim().is_empty() {
5852 result.push(next_line.to_string());
5853 i += 1;
5854 } else {
5855 break;
5856 }
5857 }
5858 continue;
5859 }
5860
5861 if trimmed.starts_with('>') {
5863 let gt_pos = line.find('>').expect("'>' must exist since trimmed.starts_with('>')");
5866 let quote_prefix = line[0..=gt_pos].to_string();
5867 let quote_content = &line[quote_prefix.len()..].trim_start();
5868
5869 let reflowed = reflow_line(quote_content, options);
5870 for reflowed_line in &reflowed {
5871 result.push(format!("{quote_prefix} {reflowed_line}"));
5872 }
5873 i += 1;
5874 continue;
5875 }
5876
5877 if is_horizontal_rule(trimmed) {
5879 result.push(line.to_string());
5880 i += 1;
5881 continue;
5882 }
5883
5884 if is_unordered_list_marker(trimmed) || is_numbered_list_item(trimmed) {
5886 let indent = line.len() - line.trim_start().len();
5888 let indent_str = " ".repeat(indent);
5889
5890 let mut marker_end = indent;
5893 let mut content_start = indent;
5894
5895 if trimmed.chars().next().is_some_and(char::is_numeric) {
5896 if let Some(period_pos) = line[indent..].find('.') {
5898 marker_end = indent + period_pos + 1; content_start = marker_end;
5900 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
5904 content_start += 1;
5905 }
5906 }
5907 } else {
5908 marker_end = indent + 1; content_start = marker_end;
5911 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
5915 content_start += 1;
5916 }
5917 }
5918
5919 let min_continuation_indent = content_start;
5921
5922 let rest = &line[content_start..];
5925 if rest.starts_with("[ ] ") || rest.starts_with("[x] ") || rest.starts_with("[X] ") {
5926 marker_end = content_start + 3; content_start += 4; }
5929
5930 let marker = &line[indent..marker_end];
5931
5932 let mut list_content = vec![trim_preserving_hard_break(&line[content_start..])];
5935 i += 1;
5936
5937 while i < lines.len() {
5941 let next_line = lines[i];
5942 let next_trimmed = next_line.trim();
5943
5944 if is_block_boundary(next_trimmed) {
5946 break;
5947 }
5948
5949 let next_indent = next_line.len() - next_line.trim_start().len();
5951 if next_indent >= min_continuation_indent {
5952 let trimmed_start = next_line.trim_start();
5955 list_content.push(trim_preserving_hard_break(trimmed_start));
5956 i += 1;
5957 } else {
5958 break;
5960 }
5961 }
5962
5963 let combined_content = if options.preserve_breaks {
5966 list_content[0].clone()
5967 } else {
5968 let has_hard_breaks = list_content.iter().any(|line| has_hard_break(line));
5970 if has_hard_breaks {
5971 list_content.join("\n")
5973 } else {
5974 join_reflow_lines(&list_content, options)
5975 }
5976 };
5977
5978 let trimmed_marker = marker;
5980 let continuation_spaces = if let Some(max_indent) = options.max_list_continuation_indent {
5981 indent + (content_start - indent).min(max_indent)
5984 } else {
5985 content_start
5986 };
5987
5988 let prefix_length = indent + trimmed_marker.len() + 1;
5990
5991 let adjusted_options = ReflowOptions {
5993 line_length: options.line_length.saturating_sub(prefix_length),
5994 ..options.clone()
5995 };
5996
5997 let reflowed = reflow_line(&combined_content, &adjusted_options);
5998 for (j, reflowed_line) in reflowed.iter().enumerate() {
5999 if j == 0 {
6000 result.push(format!("{indent_str}{trimmed_marker} {reflowed_line}"));
6001 } else {
6002 let continuation_indent = " ".repeat(continuation_spaces);
6004 result.push(format!("{continuation_indent}{reflowed_line}"));
6005 }
6006 }
6007 continue;
6008 }
6009
6010 if crate::utils::table_utils::TableUtils::is_potential_table_row(line) {
6012 result.push(line.to_string());
6013 i += 1;
6014 continue;
6015 }
6016
6017 if trimmed.starts_with('[') && line.contains("]:") {
6019 result.push(line.to_string());
6020 i += 1;
6021 continue;
6022 }
6023
6024 if is_definition_list_marker(trimmed) && has_block_line_above(&lines, i) {
6030 result.push(line.to_string());
6031 i += 1;
6032 continue;
6033 }
6034
6035 let mut is_single_line_paragraph = true;
6037 if i + 1 < lines.len() {
6038 let next_trimmed = lines[i + 1].trim();
6039 if !is_block_boundary(next_trimmed) {
6041 is_single_line_paragraph = false;
6042 }
6043 }
6044
6045 if is_single_line_paragraph && line_fits(line, options) {
6047 result.push(line.to_string());
6048 i += 1;
6049 continue;
6050 }
6051
6052 let mut paragraph_parts: Vec<(String, bool)> = Vec::new();
6056 let mut current_part = vec![line];
6057 i += 1;
6058
6059 if options.preserve_breaks {
6061 let hard_break_type = if line.strip_suffix('\r').unwrap_or(line).ends_with('\\') {
6063 Some("\\")
6064 } else if line.ends_with(" ") {
6065 Some(" ")
6066 } else {
6067 None
6068 };
6069 let reflowed = reflow_line(line, options);
6070
6071 if let Some(break_marker) = hard_break_type {
6073 if !reflowed.is_empty() {
6074 let mut reflowed_with_break = reflowed;
6075 let last_idx = reflowed_with_break.len() - 1;
6076 if !has_hard_break(&reflowed_with_break[last_idx]) {
6077 reflowed_with_break[last_idx].push_str(break_marker);
6078 }
6079 result.extend(reflowed_with_break);
6080 }
6081 } else {
6082 result.extend(reflowed);
6083 }
6084 } else {
6085 let paragraph_start = i - 1;
6099 let paragraph_scan = options.sentence_per_line.then(|| {
6100 let mut paragraph_end = i;
6101 while paragraph_end < lines.len() && !is_block_boundary(lines[paragraph_end].trim()) {
6102 paragraph_end += 1;
6103 }
6104 let joined_paragraph = lines[paragraph_start..paragraph_end].join(" ");
6105 let joined_atomic = sentence_structure(&joined_paragraph, options.defined_references.as_ref()).atomic;
6106 let mut line_starts = Vec::with_capacity(paragraph_end - paragraph_start);
6107 let mut line_offset = 0;
6108 for paragraph_line in &lines[paragraph_start..paragraph_end] {
6109 line_starts.push(line_offset);
6110 line_offset += paragraph_line.len() + 1;
6111 }
6112 (joined_atomic, line_starts)
6113 });
6114
6115 while i < lines.len() {
6116 let prev_line = if !current_part.is_empty() {
6117 current_part.last().unwrap()
6118 } else {
6119 ""
6120 };
6121 let next_line = lines[i];
6122 let next_trimmed = next_line.trim();
6123
6124 if is_block_boundary(next_trimmed) {
6126 break;
6127 }
6128
6129 let prev_trimmed = prev_line.trim();
6132 let abbreviations = get_abbreviations(&options.abbreviations);
6133 let ends_with_sentence = (prev_trimmed.ends_with('.')
6134 || prev_trimmed.ends_with('!')
6135 || 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("?_")
6142 || prev_trimmed.ends_with(".\"")
6144 || prev_trimmed.ends_with("!\"")
6145 || prev_trimmed.ends_with("?\"")
6146 || prev_trimmed.ends_with(".'")
6147 || prev_trimmed.ends_with("!'")
6148 || prev_trimmed.ends_with("?'")
6149 || prev_trimmed.ends_with(".\u{201D}")
6150 || prev_trimmed.ends_with("!\u{201D}")
6151 || prev_trimmed.ends_with("?\u{201D}")
6152 || prev_trimmed.ends_with(".\u{2019}")
6153 || prev_trimmed.ends_with("!\u{2019}")
6154 || prev_trimmed.ends_with("?\u{2019}")
6155 || ends_cjk_sentence_with_closer(prev_trimmed))
6160 && !text_ends_with_abbreviation(
6161 prev_trimmed.trim_end_matches(['*', '_', '"', '\'', '\u{201D}', '\u{2019}']),
6162 &abbreviations,
6163 )
6164 && !text_ends_with_numbered_abbreviation(
6165 prev_trimmed.trim_end_matches(['*', '_', '"', '\'', '\u{201D}', '\u{2019}']),
6166 next_line,
6167 );
6168
6169 let inside_construct = paragraph_scan.as_ref().is_some_and(|(joined_atomic, line_starts)| {
6172 let join_offset = line_starts[i - paragraph_start] - 1;
6173 joined_atomic
6174 .iter()
6175 .any(|&(start, end)| start <= join_offset && join_offset < end)
6176 });
6177
6178 let ends_at_hard_break = has_hard_break(prev_line);
6185 if ends_at_hard_break
6186 || (is_self_contained_display_math_line(prev_line) && !inside_code_span[i - 1])
6187 || (is_self_contained_display_math_line(next_line) && !inside_code_span[i])
6188 || (options.sentence_per_line && ends_with_sentence && !inside_construct)
6189 {
6190 paragraph_parts.push((join_reflow_lines(¤t_part, options), ends_at_hard_break));
6192 current_part = vec![next_line];
6193 } else {
6194 current_part.push(next_line);
6195 }
6196 i += 1;
6197 }
6198
6199 if !current_part.is_empty() {
6201 if current_part.len() == 1 {
6202 paragraph_parts.push((current_part[0].to_string(), false));
6204 } else {
6205 paragraph_parts.push((join_reflow_lines(¤t_part, options), false));
6206 }
6207 }
6208
6209 for (j, (part, ends_at_hard_break)) in paragraph_parts.iter().enumerate() {
6211 let reflowed = reflow_line(part, options);
6212 result.extend(reflowed);
6213
6214 if *ends_at_hard_break
6220 && j < paragraph_parts.len() - 1
6221 && !result.is_empty()
6222 && !options.sentence_per_line
6223 {
6224 let last_idx = result.len() - 1;
6225 if !has_hard_break(&result[last_idx]) {
6226 result[last_idx].push_str(" ");
6227 }
6228 }
6229 }
6230 }
6231 }
6232
6233 let mut result_text = result.join("\n");
6236 if content.ends_with('\n') {
6237 result_text.push('\n');
6238 }
6239 result_text
6240}
6241
6242#[derive(Debug, Clone)]
6244pub struct ParagraphReflow {
6245 pub start_byte: usize,
6247 pub end_byte: usize,
6249 pub reflowed_text: String,
6251}
6252
6253#[derive(Debug, Clone)]
6259pub struct BlockquoteLineData {
6260 pub(crate) content: String,
6262 pub(crate) is_explicit: bool,
6264 pub(crate) prefix: Option<String>,
6266}
6267
6268impl BlockquoteLineData {
6269 pub fn explicit(content: String, prefix: String) -> Self {
6271 Self {
6272 content,
6273 is_explicit: true,
6274 prefix: Some(prefix),
6275 }
6276 }
6277
6278 pub fn lazy(content: String) -> Self {
6280 Self {
6281 content,
6282 is_explicit: false,
6283 prefix: None,
6284 }
6285 }
6286}
6287
6288#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6290pub enum BlockquoteContinuationStyle {
6291 Explicit,
6292 Lazy,
6293}
6294
6295pub fn blockquote_continuation_style(lines: &[BlockquoteLineData]) -> BlockquoteContinuationStyle {
6303 let mut explicit_count = 0usize;
6304 let mut lazy_count = 0usize;
6305
6306 for line in lines.iter().skip(1) {
6307 if line.is_explicit {
6308 explicit_count += 1;
6309 } else {
6310 lazy_count += 1;
6311 }
6312 }
6313
6314 if explicit_count > 0 && lazy_count == 0 {
6315 BlockquoteContinuationStyle::Explicit
6316 } else if lazy_count > 0 && explicit_count == 0 {
6317 BlockquoteContinuationStyle::Lazy
6318 } else if explicit_count >= lazy_count {
6319 BlockquoteContinuationStyle::Explicit
6320 } else {
6321 BlockquoteContinuationStyle::Lazy
6322 }
6323}
6324
6325pub fn dominant_blockquote_prefix(lines: &[BlockquoteLineData], fallback: &str) -> String {
6330 let mut counts: std::collections::HashMap<String, (usize, usize)> = std::collections::HashMap::new();
6331
6332 for (idx, line) in lines.iter().enumerate() {
6333 let Some(prefix) = line.prefix.as_ref() else {
6334 continue;
6335 };
6336 counts
6337 .entry(prefix.clone())
6338 .and_modify(|entry| entry.0 += 1)
6339 .or_insert((1, idx));
6340 }
6341
6342 counts
6343 .into_iter()
6344 .max_by(|(_, (count_a, first_idx_a)), (_, (count_b, first_idx_b))| {
6345 count_a.cmp(count_b).then_with(|| first_idx_b.cmp(first_idx_a))
6346 })
6347 .map_or_else(|| fallback.to_string(), |(prefix, _)| prefix)
6348}
6349
6350pub(crate) fn should_force_explicit_blockquote_line(content_line: &str) -> bool {
6355 let trimmed = content_line.trim_start();
6356 trimmed.starts_with('>')
6357 || trimmed.starts_with('#')
6358 || trimmed.starts_with("```")
6359 || trimmed.starts_with("~~~")
6360 || is_unordered_list_marker(trimmed)
6361 || is_numbered_list_item(trimmed)
6362 || is_horizontal_rule(trimmed)
6363 || is_definition_list_marker(trimmed)
6364 || (trimmed.starts_with('[') && trimmed.contains("]:"))
6365 || trimmed.starts_with(":::")
6366 || (trimmed.starts_with('<')
6367 && !trimmed.starts_with("<http")
6368 && !trimmed.starts_with("<https")
6369 && !trimmed.starts_with("<mailto:"))
6370}
6371
6372pub fn reflow_blockquote_content(
6381 lines: &[BlockquoteLineData],
6382 explicit_prefix: &str,
6383 continuation_style: BlockquoteContinuationStyle,
6384 options: &ReflowOptions,
6385) -> Vec<String> {
6386 let content_strs: Vec<&str> = lines.iter().map(|l| l.content.as_str()).collect();
6387 let ends_in_code_span = lines_ending_in_code_span(&content_strs);
6388 let segments = split_into_segments_strs(&content_strs, &ends_in_code_span);
6389 let mut reflowed_content_lines: Vec<String> = Vec::new();
6390
6391 for segment in segments {
6392 let hard_break_type = segment.last().and_then(|&(line, in_code_span)| {
6393 let line = line.strip_suffix('\r').unwrap_or(line);
6394 if in_code_span {
6395 None
6396 } else if line.ends_with('\\') {
6397 Some("\\")
6398 } else if line.ends_with(" ") {
6399 Some(" ")
6400 } else {
6401 None
6402 }
6403 });
6404
6405 let pieces: Vec<&str> = segment
6406 .iter()
6407 .map(|&(line, in_code_span)| {
6408 if in_code_span {
6409 line
6410 } else if let Some(l) = line.strip_suffix('\\') {
6411 l.trim_end_matches(is_breakable_whitespace)
6412 } else if let Some(l) = line.strip_suffix(" ") {
6413 l.trim_end_matches(is_breakable_whitespace)
6414 } else {
6415 line.trim_end_matches(is_breakable_whitespace)
6416 }
6417 })
6418 .collect();
6419
6420 let segment_text = join_reflow_lines(&pieces, options);
6421 let segment_text = trim_breakable_whitespace(&segment_text);
6422 if segment_text.is_empty() {
6423 continue;
6424 }
6425
6426 let mut segment_options = options.clone();
6427 if options.sentence_pack
6428 && !reflowed_content_lines.is_empty()
6429 && !should_force_explicit_blockquote_line(segment_text)
6430 {
6431 segment_options.first_line_length = None;
6432 }
6433 let mut reflowed = reflow_line(segment_text, &segment_options);
6434 if let Some(break_marker) = hard_break_type
6435 && !reflowed.is_empty()
6436 {
6437 let last_idx = reflowed.len() - 1;
6438 if !has_hard_break(&reflowed[last_idx]) {
6439 reflowed[last_idx].push_str(break_marker);
6440 }
6441 }
6442 reflowed_content_lines.extend(reflowed);
6443 }
6444
6445 let mut styled_lines: Vec<String> = Vec::new();
6446 for (idx, line) in reflowed_content_lines.iter().enumerate() {
6447 let force_explicit = idx == 0
6448 || continuation_style == BlockquoteContinuationStyle::Explicit
6449 || should_force_explicit_blockquote_line(line);
6450 if force_explicit {
6451 styled_lines.push(format!("{explicit_prefix}{line}"));
6452 } else {
6453 styled_lines.push(line.clone());
6454 }
6455 }
6456
6457 styled_lines
6458}
6459
6460fn is_blockquote_content_boundary(content: &str) -> bool {
6461 let trimmed = content.trim();
6462 trimmed.is_empty()
6463 || is_block_boundary(trimmed)
6464 || crate::utils::table_utils::TableUtils::is_potential_table_row(content)
6465 || trimmed.starts_with(":::")
6466 || crate::utils::is_template_directive_only(content)
6467 || is_standalone_attr_list(content)
6468 || is_snippet_block_delimiter(content)
6469}
6470
6471fn split_into_segments_strs<'a>(lines: &[&'a str], ends_in_code_span: &[bool]) -> Vec<Vec<(&'a str, bool)>> {
6475 let mut segments = Vec::new();
6476 let mut current = Vec::new();
6477 let inside_code_span = lines_touching_multiline_code_span(&lines.join("\n"));
6481
6482 for (idx, &line) in lines.iter().enumerate() {
6483 let is_display_math =
6488 is_self_contained_display_math_line(line) && !inside_code_span.get(idx).is_some_and(|&inside| inside);
6489 if is_display_math && !current.is_empty() {
6490 segments.push(std::mem::take(&mut current));
6491 }
6492 current.push((line, ends_in_code_span[idx]));
6493 if (has_hard_break(line) && !ends_in_code_span[idx]) || is_display_math {
6494 segments.push(std::mem::take(&mut current));
6495 }
6496 }
6497
6498 if !current.is_empty() {
6499 segments.push(current);
6500 }
6501
6502 segments
6503}
6504
6505fn reflow_blockquote_paragraph_at_line(
6506 content: &str,
6507 lines: &[&str],
6508 target_idx: usize,
6509 options: &ReflowOptions,
6510) -> Option<ParagraphReflow> {
6511 let mut anchor_idx = target_idx;
6512 let mut target_level = if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[target_idx]) {
6513 parsed.nesting_level
6514 } else {
6515 let mut found = None;
6516 let mut idx = target_idx;
6517 loop {
6518 if lines[idx].trim().is_empty() {
6519 break;
6520 }
6521 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[idx]) {
6522 found = Some((idx, parsed.nesting_level));
6523 break;
6524 }
6525 if idx == 0 {
6526 break;
6527 }
6528 idx -= 1;
6529 }
6530 let (idx, level) = found?;
6531 anchor_idx = idx;
6532 level
6533 };
6534
6535 let mut para_start = anchor_idx;
6537 while para_start > 0 {
6538 let prev_idx = para_start - 1;
6539 let prev_line = lines[prev_idx];
6540
6541 if prev_line.trim().is_empty() {
6542 break;
6543 }
6544
6545 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(prev_line) {
6546 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
6547 break;
6548 }
6549 para_start = prev_idx;
6550 continue;
6551 }
6552
6553 let prev_lazy = prev_line.trim_start();
6554 if is_blockquote_content_boundary(prev_lazy) {
6555 break;
6556 }
6557 para_start = prev_idx;
6558 }
6559
6560 while para_start < lines.len() {
6562 let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[para_start]) else {
6563 para_start += 1;
6564 continue;
6565 };
6566 target_level = parsed.nesting_level;
6567 break;
6568 }
6569
6570 if para_start >= lines.len() || para_start > target_idx {
6571 return None;
6572 }
6573
6574 let mut collected: Vec<(usize, BlockquoteLineData)> = Vec::new();
6577 let mut idx = para_start;
6578 while idx < lines.len() {
6579 if !collected.is_empty() && has_hard_break(&collected[collected.len() - 1].1.content) {
6580 break;
6581 }
6582
6583 let line = lines[idx];
6584 if line.trim().is_empty() {
6585 break;
6586 }
6587
6588 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(line) {
6589 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
6590 break;
6591 }
6592 collected.push((
6593 idx,
6594 BlockquoteLineData::explicit(trim_preserving_hard_break(parsed.content), parsed.prefix.to_string()),
6595 ));
6596 idx += 1;
6597 continue;
6598 }
6599
6600 let lazy_content = line.trim_start();
6601 if is_blockquote_content_boundary(lazy_content) {
6602 break;
6603 }
6604
6605 collected.push((idx, BlockquoteLineData::lazy(trim_preserving_hard_break(lazy_content))));
6606 idx += 1;
6607 }
6608
6609 if collected.is_empty() {
6610 return None;
6611 }
6612
6613 let para_end = collected[collected.len() - 1].0;
6614 if target_idx < para_start || target_idx > para_end {
6615 return None;
6616 }
6617
6618 let line_data: Vec<BlockquoteLineData> = collected.iter().map(|(_, d)| d.clone()).collect();
6619
6620 let fallback_prefix = line_data
6621 .iter()
6622 .find_map(|d| d.prefix.clone())
6623 .unwrap_or_else(|| "> ".to_string());
6624 let explicit_prefix = dominant_blockquote_prefix(&line_data, &fallback_prefix);
6625 let continuation_style = blockquote_continuation_style(&line_data);
6626
6627 let adjusted_line_length = options
6628 .line_length
6629 .saturating_sub(display_len(&explicit_prefix, options.length_mode))
6630 .max(1);
6631
6632 let adjusted_options = if options.sentence_pack && continuation_style == BlockquoteContinuationStyle::Lazy {
6633 ReflowOptions {
6634 first_line_length: if options.line_length == 0 || options.line_length == usize::MAX {
6635 None
6636 } else {
6637 Some(adjusted_line_length)
6638 },
6639 ..options.clone()
6640 }
6641 } else {
6642 ReflowOptions {
6643 line_length: adjusted_line_length,
6644 first_line_length: None,
6645 ..options.clone()
6646 }
6647 };
6648
6649 let styled_lines = reflow_blockquote_content(&line_data, &explicit_prefix, continuation_style, &adjusted_options);
6650
6651 if styled_lines.is_empty() {
6652 return None;
6653 }
6654
6655 let mut start_byte = 0;
6657 for line in lines.iter().take(para_start) {
6658 start_byte += line.len() + 1;
6659 }
6660
6661 let mut end_byte = start_byte;
6662 for line in lines.iter().take(para_end + 1).skip(para_start) {
6663 end_byte += line.len() + 1;
6664 }
6665
6666 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
6667 if !includes_trailing_newline {
6668 end_byte -= 1;
6669 }
6670
6671 let reflowed_joined = styled_lines.join("\n");
6672 let reflowed_text = if includes_trailing_newline {
6673 if reflowed_joined.ends_with('\n') {
6674 reflowed_joined
6675 } else {
6676 format!("{reflowed_joined}\n")
6677 }
6678 } else if reflowed_joined.ends_with('\n') {
6679 reflowed_joined.trim_end_matches('\n').to_string()
6680 } else {
6681 reflowed_joined
6682 };
6683
6684 Some(ParagraphReflow {
6685 start_byte,
6686 end_byte,
6687 reflowed_text,
6688 })
6689}
6690
6691pub fn reflow_paragraph_at_line(content: &str, line_number: usize, line_length: usize) -> Option<ParagraphReflow> {
6709 reflow_paragraph_at_line_with_mode(content, line_number, line_length, ReflowLengthMode::default())
6710}
6711
6712pub fn reflow_paragraph_at_line_with_mode(
6714 content: &str,
6715 line_number: usize,
6716 line_length: usize,
6717 length_mode: ReflowLengthMode,
6718) -> Option<ParagraphReflow> {
6719 let options = ReflowOptions {
6720 line_length,
6721 length_mode,
6722 ..Default::default()
6723 };
6724 reflow_paragraph_at_line_with_options(content, line_number, &options)
6725}
6726
6727pub fn reflow_paragraph_at_line_with_options(
6738 content: &str,
6739 line_number: usize,
6740 options: &ReflowOptions,
6741) -> Option<ParagraphReflow> {
6742 if line_number == 0 {
6743 return None;
6744 }
6745
6746 let lines: Vec<&str> = content.lines().collect();
6747
6748 if line_number > lines.len() {
6750 return None;
6751 }
6752
6753 let target_idx = line_number - 1; let target_line = lines[target_idx];
6755 let trimmed = target_line.trim();
6756
6757 if let Some(blockquote_reflow) = reflow_blockquote_paragraph_at_line(content, &lines, target_idx, options) {
6760 return Some(blockquote_reflow);
6761 }
6762
6763 if is_paragraph_boundary(trimmed, target_line) {
6765 return None;
6766 }
6767
6768 let mut para_start = target_idx;
6770 while para_start > 0 {
6771 let prev_idx = para_start - 1;
6772 let prev_line = lines[prev_idx];
6773 let prev_trimmed = prev_line.trim();
6774
6775 if is_paragraph_boundary(prev_trimmed, prev_line) {
6777 break;
6778 }
6779
6780 para_start = prev_idx;
6781 }
6782
6783 let mut para_end = target_idx;
6785 while para_end + 1 < lines.len() {
6786 let next_idx = para_end + 1;
6787 let next_line = lines[next_idx];
6788 let next_trimmed = next_line.trim();
6789
6790 if is_paragraph_boundary(next_trimmed, next_line) {
6792 break;
6793 }
6794
6795 para_end = next_idx;
6796 }
6797
6798 let paragraph_lines = &lines[para_start..=para_end];
6800
6801 let mut start_byte = 0;
6803 for line in lines.iter().take(para_start) {
6804 start_byte += line.len() + 1; }
6806
6807 let mut end_byte = start_byte;
6808 for line in paragraph_lines {
6809 end_byte += line.len() + 1; }
6811
6812 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
6815
6816 if !includes_trailing_newline {
6818 end_byte -= 1;
6819 }
6820
6821 let paragraph_text = paragraph_lines.join("\n");
6823
6824 let reflowed = reflow_markdown(¶graph_text, options);
6826
6827 let reflowed_text = if includes_trailing_newline {
6831 if reflowed.ends_with('\n') {
6833 reflowed
6834 } else {
6835 format!("{reflowed}\n")
6836 }
6837 } else {
6838 if reflowed.ends_with('\n') {
6840 reflowed.trim_end_matches('\n').to_string()
6841 } else {
6842 reflowed
6843 }
6844 };
6845
6846 Some(ParagraphReflow {
6847 start_byte,
6848 end_byte,
6849 reflowed_text,
6850 })
6851}
6852fn decompose_code_span(raw: &str) -> Option<(&str, &str)> {
6858 let marker_len = raw.bytes().take_while(|&b| b == b'`').count();
6859 if marker_len == 0 {
6860 return None;
6861 }
6862 let marker = &raw[..marker_len];
6863 if raw.len() < marker_len * 2 {
6864 return None;
6865 }
6866 let content = &raw[marker_len..raw.len() - marker_len];
6867 Some((content, marker))
6868}
6869
6870#[cfg(test)]
6871mod tests {
6872 use super::*;
6873
6874 #[test]
6875 fn source_sentence_breaks_respect_custom_abbreviations_and_atomic_constructs() {
6876 let custom = Some(vec!["Widget".to_string()]);
6877 assert_eq!(
6878 join_sentence_lines(
6879 &["Use Widget.", "then retry."],
6880 CjkSoftBreak::Space,
6881 &custom,
6882 LinkTextPunctuation::Ignore,
6883 None,
6884 ),
6885 "Use Widget. then retry."
6886 );
6887 for lines in [
6888 vec!["Read `literal.", "text` here."],
6889 vec here."],
6890 vec!["Read <span title=\"literal.", "text\"> here."],
6891 ] {
6892 assert_eq!(
6893 join_sentence_lines(&lines, CjkSoftBreak::Space, &None, LinkTextPunctuation::Ignore, None),
6894 lines.join(" ")
6895 );
6896 }
6897 assert_eq!(
6898 join_sentence_lines(
6899 &["First sentence.", "lowercase follows."],
6900 CjkSoftBreak::Space,
6901 &None,
6902 LinkTextPunctuation::Ignore,
6903 None,
6904 ),
6905 "First sentence.\nlowercase follows."
6906 );
6907 }
6908
6909 #[test]
6913 fn preserves_content_accepts_whitespace_changes_and_rejects_the_rest() {
6914 let accepted: &[(&str, &[&str])] = &[
6915 ("one two three", &["one two three"]),
6916 ("one two three", &["one two", "three"]),
6917 ("one two three", &["one", "two", "three"]),
6918 ("one two ", &["one two"]),
6920 ("日本語のテキスト", &["日本語の", "テキスト"]),
6922 ("_First. Second._", &["_First.", "Second._"]),
6924 ];
6925 for (original, reflowed) in accepted {
6926 let reflowed: Vec<String> = reflowed.iter().map(ToString::to_string).collect();
6927 assert!(
6928 preserves_content(original, &reflowed),
6929 "{original:?} -> {reflowed:?} only moves whitespace"
6930 );
6931 }
6932
6933 let rejected: &[(&str, &[&str])] = &[
6934 ("one two three", &["one two"]),
6936 ("one two", &["one two three"]),
6938 ("one two", &["two one"]),
6940 ("_First. Second._", &["_First._", "_Second._"]),
6942 ("alpha and beta", &["alpha", "andbeta"]),
6944 ("mot suivant : autre", &["mot suivant: autre"]),
6946 ];
6947 for (original, reflowed) in rejected {
6948 let reflowed: Vec<String> = reflowed.iter().map(ToString::to_string).collect();
6949 assert!(
6950 !preserves_content(original, &reflowed),
6951 "{original:?} -> {reflowed:?} changes the text, not just its line breaks"
6952 );
6953 }
6954 }
6955
6956 #[test]
6959 fn nested_structure_reads_past_indentation_starting_the_text() {
6960 for indent in [" ", "\t", " "] {
6961 let text = format!("{indent}run `cargo test` now");
6962 let start = text.find('`').unwrap();
6963 let end = text.rfind('`').unwrap() + 1;
6964 assert_eq!(
6965 nested_structure(&text, None, false).code_spans,
6966 vec![(start, end)],
6967 "{text:?}"
6968 );
6969 }
6970 assert!(code_span_runs_into_indentation(&[" `cargo test", " --all`"]));
6971 }
6972
6973 #[test]
6976 fn lines_touching_multiline_code_span_reads_an_indented_code_block_as_code() {
6977 assert_eq!(
6978 lines_touching_multiline_code_span(" `a\n $$x`$$\n"),
6979 [false, false]
6980 );
6981 assert_eq!(lines_touching_multiline_code_span("Run `a\n$$x`$$\n"), [true, true]);
6982 }
6983
6984 #[test]
6986 fn reflow_line_falls_back_to_the_input_when_content_would_change() {
6987 let options = ReflowOptions {
6988 line_length: 40,
6989 ..Default::default()
6990 };
6991 let line = "one two three four five six seven eight nine ten";
6992
6993 assert!(preserves_content(line, &reflow_line(line, &options)));
6994 assert_eq!(reflow_line(line, &options), reflow_line_unchecked(line, &options));
6995 }
6996
6997 #[test]
6998 fn cascade_split_line_handles_a_very_long_line_without_overflowing() {
6999 let words: Vec<String> = (0..4000).map(|i| format!("word{i}")).collect();
7005 let line = words.join(" ");
7006
7007 let options = ReflowOptions {
7008 line_length: 80,
7009 length_mode: ReflowLengthMode::Chars,
7010 ..Default::default()
7011 };
7012 let out = cascade_split_line(&line, &options);
7013
7014 assert!(out.len() > 1, "a very long line should split into many lines");
7015 for segment in &out {
7016 assert!(
7017 display_len(segment, ReflowLengthMode::Chars) <= 80 || !segment.contains(' '),
7018 "each wrapped line should fit the width (or be a single unbreakable token)"
7019 );
7020 }
7021 let rejoined = out.join(" ");
7023 let original_words: Vec<&str> = line.split(' ').collect();
7024 let result_words: Vec<&str> = rejoined.split_whitespace().collect();
7025 assert_eq!(original_words, result_words, "reflow must preserve all words in order");
7026 }
7027
7028 #[test]
7033 fn test_helper_function_text_ends_with_abbreviation() {
7034 let abbreviations = get_abbreviations(&None);
7036
7037 assert!(text_ends_with_abbreviation("Dr.", &abbreviations));
7039 assert!(text_ends_with_abbreviation("word Dr.", &abbreviations));
7040 assert!(text_ends_with_abbreviation("e.g.", &abbreviations));
7041 assert!(text_ends_with_abbreviation("i.e.", &abbreviations));
7042 assert!(text_ends_with_abbreviation("Mr.", &abbreviations));
7043 assert!(text_ends_with_abbreviation("Mrs.", &abbreviations));
7044 assert!(text_ends_with_abbreviation("Ms.", &abbreviations));
7045 assert!(text_ends_with_abbreviation("Prof.", &abbreviations));
7046
7047 assert!(!text_ends_with_abbreviation("etc.", &abbreviations));
7049 assert!(!text_ends_with_abbreviation("paradigms.", &abbreviations));
7050 assert!(!text_ends_with_abbreviation("programs.", &abbreviations));
7051 assert!(!text_ends_with_abbreviation("items.", &abbreviations));
7052 assert!(!text_ends_with_abbreviation("systems.", &abbreviations));
7053 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)); }
7059
7060 #[test]
7061 fn test_footnote_after_period_splits_sentence() {
7062 let text = "First sentence.[^1] Second sentence.";
7066 let sentences = split_into_sentences(text, None, true, CjkSoftBreak::Space);
7067 assert_eq!(
7068 sentences,
7069 vec!["First sentence.[^1]".to_string(), "Second sentence.".to_string()],
7070 "footnote glued to the period should keep the boundary and stay attached to the first sentence"
7071 );
7072 }
7073
7074 #[test]
7075 fn test_multiple_consecutive_footnotes_after_period_splits_sentence() {
7076 let text = "Notes here.[^1][^2] Second sentence.";
7078 let sentences = split_into_sentences(text, None, true, CjkSoftBreak::Space);
7079 assert_eq!(
7080 sentences,
7081 vec!["Notes here.[^1][^2]".to_string(), "Second sentence.".to_string()]
7082 );
7083 }
7084
7085 #[test]
7086 fn test_footnote_before_period_still_splits_sentence() {
7087 let text = "Annotation here[^1]. Second sentence.";
7091 let sentences = split_into_sentences(text, None, true, CjkSoftBreak::Space);
7092 assert_eq!(
7093 sentences,
7094 vec!["Annotation here[^1].".to_string(), "Second sentence.".to_string()]
7095 );
7096 }
7097
7098 #[test]
7099 fn test_mid_sentence_footnote_does_not_split() {
7100 let text = "The system word[^1] more words. Next sentence.";
7103 let sentences = split_into_sentences(text, None, true, CjkSoftBreak::Space);
7104 assert_eq!(
7105 sentences,
7106 vec![
7107 "The system word[^1] more words.".to_string(),
7108 "Next sentence.".to_string()
7109 ]
7110 );
7111 }
7112
7113 #[test]
7114 fn test_bare_numeric_bracket_after_period_does_not_split() {
7115 let text = "Citation here.[1] Second sentence.";
7118 let sentences = split_into_sentences(text, None, true, CjkSoftBreak::Space);
7119 assert_eq!(
7120 sentences,
7121 vec![text.to_string()],
7122 "a bare numeric bracket must not be treated as a sentence boundary"
7123 );
7124 }
7125
7126 #[test]
7127 fn test_footnote_glued_to_following_word_does_not_split() {
7128 let text = "First sentence.[^1]Continued glued text.";
7131 let sentences = split_into_sentences(text, None, true, CjkSoftBreak::Space);
7132 assert_eq!(sentences, vec![text.to_string()]);
7133 }
7134
7135 #[test]
7136 fn test_footnote_at_end_of_text_is_preserved() {
7137 let text = "Sentence.[^1]";
7140 let sentences = split_into_sentences(text, None, true, CjkSoftBreak::Space);
7141 assert_eq!(sentences, vec![text.to_string()]);
7142 }
7143
7144 #[test]
7145 fn test_abbreviation_before_footnote_does_not_split() {
7146 let text = "See the notes, e.g.[^1] this one.";
7149 let sentences = split_into_sentences(text, None, true, CjkSoftBreak::Space);
7150 assert_eq!(
7151 sentences,
7152 vec![text.to_string()],
7153 "e.g. is an abbreviation, not a sentence boundary"
7154 );
7155 }
7156
7157 #[test]
7158 fn sentence_boundary_never_falls_inside_an_atomic_construct() {
7159 let cases = [
7165 "Prefix [link. Still link](https://example.com) tail. Next sentence.",
7166 "Prefix [target](<https://example.com/First. Second>) tail. Next sentence.",
7167 "Prefix [text](url \"Title. More\") tail. Next sentence.",
7168 "Prefix  tail. Next sentence.",
7169 "Prefix [ref text. More][ref] tail. Next sentence.",
7170 "Prefix [collapsed. More][] tail. Next sentence.",
7171 "Prefix [[Page name. Title]] tail. Next sentence.",
7172 "Prefix $x. Y$ tail. Next sentence.",
7173 "Prefix $$x. Y$$ tail. Next sentence.",
7174 "Prefix <span title=\"A. B\">x</span> tail. Next sentence.",
7175 "Prefix `code. Still code` tail. Next sentence.",
7176 ];
7177 for text in cases {
7178 let sentences = split_into_sentences(text, None, true, CjkSoftBreak::Space);
7179 let (head, tail) = text.rsplit_once(" tail. ").expect("case has a tail");
7180 assert_eq!(
7181 sentences,
7182 vec![format!("{head} tail."), tail.to_string()],
7183 "input {text:?}"
7184 );
7185 }
7186
7187 let text = "Prefix [shortcut. More] tail. Next sentence.";
7191 let whole = vec![
7192 "Prefix [shortcut. More] tail.".to_string(),
7193 "Next sentence.".to_string(),
7194 ];
7195 let defined = HashSet::from(["shortcut. more".to_string()]);
7196 assert_eq!(
7197 split_into_sentences(text, Some(&defined), true, CjkSoftBreak::Space),
7198 whole
7199 );
7200 assert_eq!(split_into_sentences(text, None, true, CjkSoftBreak::Space), whole);
7201 assert_eq!(
7202 split_into_sentences(text, Some(&HashSet::new()), true, CjkSoftBreak::Space),
7203 vec!["Prefix [shortcut.", "More] tail.", "Next sentence."]
7204 );
7205 }
7206
7207 #[test]
7208 fn a_sentence_may_open_with_a_link_or_image() {
7209 for text in [
7214 "Opening sentence. [First. Second](https://example.com)",
7215 "Opening sentence. ",
7216 "Opening sentence. [[First. Second]]",
7217 "Opening sentence. [[first-note|First. Second]]",
7218 "Opening sentence. [Ref link][ref]",
7219 "Opening sentence. [](url) continues.",
7222 "Opening sentence. [][ref] continues.",
7223 "Opening sentence. [![First image][img]](url) continues.",
7226 "Opening sentence. [![First image][]](url) continues.",
7227 "Opening sentence. [![First image][img]][ref] continues.",
7228 ] {
7229 let (head, tail) = text.split_once(". ").expect("case has a boundary");
7230 assert_eq!(
7231 split_into_sentences(text, None, true, CjkSoftBreak::Space),
7232 vec![format!("{head}."), tail.to_string()],
7233 "input {text:?}"
7234 );
7235 }
7236 let text = "Opening sentence. [![First image]](url) continues.";
7239 let defined = HashSet::from(["first image".to_string()]);
7240 assert_eq!(
7241 split_into_sentences(text, Some(&defined), true, CjkSoftBreak::Space),
7242 vec!["Opening sentence.", "[![First image]](url) continues."]
7243 );
7244 assert_eq!(
7245 split_into_sentences(text, Some(&HashSet::new()), true, CjkSoftBreak::Space),
7246 vec![text.to_string()],
7247 "an undefined shortcut is bracketed text, and `!` opens no sentence"
7248 );
7249 assert_eq!(
7252 split_into_sentences(
7253 "Opening sentence. [](url) continues.",
7254 None,
7255 true,
7256 CjkSoftBreak::Space
7257 ),
7258 vec](url) continues."]
7259 );
7260 let defined = HashSet::from(["smith 2020".to_string()]);
7263 assert_eq!(
7264 split_into_sentences(
7265 "Claim ends here. [Smith 2020] more text.",
7266 Some(&defined),
7267 true,
7268 CjkSoftBreak::Space
7269 ),
7270 vec!["Claim ends here.", "[Smith 2020] more text."]
7271 );
7272 let none_defined = HashSet::new();
7278 for text in [
7279 "Opening sentence. [first link](https://example.com) continues.",
7280 "Opening sentence. [[first note]] continues.",
7281 "Opening sentence. [[First Note|first note]] continues.",
7282 "Opening sentence. [[Page continues.",
7283 "Opening sentence. [[First] stray]] continues.",
7284 "Opening sentence.  continues.",
7285 "Opening sentence. [1] is the citation.",
7286 "Opening sentence. [First](unterminated",
7287 "Opening sentence. [First][unterminated",
7288 "Opening sentence. [First] (aside) continues.",
7289 "Claim ends here. [Smith 2020]",
7290 "Claim ends here. [Smith 2020] more text.",
7291 "See the RFC. [RFC] More text.",
7292 "Claim ends here. [^Note] more text.",
7293 ] {
7294 assert_eq!(
7295 split_into_sentences(text, Some(&none_defined), true, CjkSoftBreak::Space),
7296 vec![text.to_string()],
7297 "input {text:?}"
7298 );
7299 }
7300 }
7301
7302 #[test]
7303 fn link_opener_is_read_off_the_parse() {
7304 let len = |text: &str, defs: Option<&HashSet<String>>| {
7307 let chars: Vec<char> = text.chars().collect();
7308 let char_offsets = char_byte_offsets(&chars);
7309 let NestedStructure { links, .. } = sentence_structure(text, defs);
7310 let st = SentenceText {
7311 text,
7312 chars: &chars,
7313 char_offsets: &char_offsets,
7314 links: &links,
7315 code_spans: &[],
7316 markers: &[],
7317 marker_closers: &[],
7318 paragraph: None,
7319 cjk_soft_break: CjkSoftBreak::Space,
7320 };
7321 st.link_end_at(0).map_or(0, |end| link_opener_len(&chars, 0, end))
7322 };
7323 let none = HashSet::new();
7324 assert_eq!(len("[text](url)", Some(&none)), 1);
7325 assert_eq!(
7326 len("[text][ref]", Some(&none)),
7327 1,
7328 "a full reference is a link whether or not defined"
7329 );
7330 assert_eq!(len("[text][]", Some(&none)), 1);
7331 assert_eq!(len("", Some(&none)), 2);
7332 assert_eq!(len("[[wiki]]", Some(&none)), 2);
7333 assert_eq!(
7334 len("[[wiki|shown]]", Some(&none)),
7335 7,
7336 "the displayed text starts after the alias pipe"
7337 );
7338 assert_eq!(len("![[img.png|100]]", Some(&none)), 11);
7339 assert_eq!(len("[[wiki|a|b]]", Some(&none)), 7, "the first pipe starts the alias");
7340 assert_eq!(
7341 len("[[wiki|shown]] [[a|b]]", Some(&none)),
7342 7,
7343 "a pipe past the closing `]]` is not this alias"
7344 );
7345 assert_eq!(
7346 len("[a \\] b](url)", Some(&none)),
7347 1,
7348 "an escaped bracket does not close the text"
7349 );
7350 assert_eq!(
7351 len("[](url)", Some(&none)),
7352 1,
7353 "the outer opener is skipped first"
7354 );
7355 for text in [
7359 "[^1]",
7360 "[text](unterminated",
7361 "[text][unterminated",
7362 "[text] (url)",
7363 "[[wiki",
7364 "[[wiki]",
7365 "[[First] stray]]",
7366 "[Smith 2020]",
7367 "[Smith 2020] (see also)",
7368 "[unclosed",
7369 "!bang",
7370 "text",
7371 ] {
7372 assert_eq!(len(text, Some(&none)), 0, "input {text:?}");
7373 }
7374 let smith = HashSet::from(["smith 2020".to_string()]);
7377 assert_eq!(len("[Smith 2020]", Some(&smith)), 1);
7378 assert_eq!(len("[Smith 2020]", None), 1);
7379 }
7380
7381 #[test]
7382 fn sentence_per_line_reflow_breaks_before_a_bracket_only_where_the_check_counts() {
7383 let defined = HashSet::from(["spec".to_string()]);
7391 let options = ReflowOptions {
7392 line_length: 120,
7393 sentence_per_line: true,
7394 defined_references: Some(defined.clone()),
7395 ..Default::default()
7396 };
7397 for (text, expected) in [
7398 (
7399 "Claim ends here. [Smith](https://example.com) more text. Second sentence.",
7400 vec more text.",
7403 "Second sentence.",
7404 ],
7405 ),
7406 (
7407 "Wow! [smith](https://example.com) more text. Second sentence.",
7408 vec more text.", "Second sentence."],
7409 ),
7410 (
7411 "Claim ends here. [smith](https://example.com) more text. Second sentence.",
7412 vec more text.",
7414 "Second sentence.",
7415 ],
7416 ),
7417 (
7418 "Claim ends here. [smith][ref] more text. Second sentence.",
7419 vec!["Claim ends here. [smith][ref] more text.", "Second sentence."],
7420 ),
7421 (
7422 "Claim ends here.  more text. Second sentence.",
7423 vec more text.", "Second sentence."],
7424 ),
7425 (
7426 "Claim ends here.[Link](https://example.com) more text. Second sentence.",
7427 vec more text.",
7429 "Second sentence.",
7430 ],
7431 ),
7432 (
7433 "See the RFC. [RFC] More text. Second sentence.",
7434 vec!["See the RFC. [RFC] More text.", "Second sentence."],
7435 ),
7436 (
7437 "See the spec. [Spec] More text. Second sentence.",
7438 vec!["See the spec.", "[Spec] More text.", "Second sentence."],
7439 ),
7440 (
7441 "See the spec. [spec] more text. Second sentence.",
7442 vec!["See the spec. [spec] more text.", "Second sentence."],
7443 ),
7444 (
7445 "Claim ends here. [[page|Second sentence]] continues. Third sentence.",
7446 vec![
7447 "Claim ends here.",
7448 "[[page|Second sentence]] continues.",
7449 "Third sentence.",
7450 ],
7451 ),
7452 (
7453 "Claim ends here. [[Page|second sentence]] continues. Third sentence.",
7454 vec![
7455 "Claim ends here. [[Page|second sentence]] continues.",
7456 "Third sentence.",
7457 ],
7458 ),
7459 ] {
7460 let lines = reflow_line(text, &options);
7461 assert_eq!(lines, expected, "input {text:?}");
7462 assert_eq!(
7465 split_into_sentences(text, Some(&defined), true, CjkSoftBreak::Space).len(),
7466 expected.len(),
7467 "check count for {text:?}"
7468 );
7469 for line in &lines {
7470 assert_eq!(
7471 split_into_sentences(line, Some(&defined), true, CjkSoftBreak::Space).len(),
7472 1,
7473 "line {line:?} of {text:?}"
7474 );
7475 }
7476 }
7477 }
7478
7479 #[test]
7480 fn sentence_per_line_reflow_holds_atomic_constructs_whole() {
7481 let options = ReflowOptions {
7485 line_length: 80,
7486 sentence_per_line: true,
7487 ..Default::default()
7488 };
7489 let lines = reflow_line(
7490 "Prefix `code. Still code` and [link. Still link](https://example.com) tail. Next sentence.",
7491 &options,
7492 );
7493 assert_eq!(
7494 lines,
7495 vec tail.".to_string(),
7497 "Next sentence.".to_string(),
7498 ]
7499 );
7500
7501 let lines = reflow_line(
7502 "Prefix  and [target](<https://example.com/First. Second>) tail. Next sentence.",
7503 &options,
7504 );
7505 assert_eq!(
7506 lines,
7507 vec and [target](<https://example.com/First. Second>) tail.".to_string(),
7509 "Next sentence.".to_string(),
7510 ]
7511 );
7512
7513 let lines = reflow_line("First one. Then [link](url) second. Third one.", &options);
7516 assert_eq!(
7517 lines,
7518 vec second.".to_string(),
7521 "Third one.".to_string(),
7522 ]
7523 );
7524 }
7525
7526 #[test]
7527 fn test_is_unordered_list_marker() {
7528 assert!(is_unordered_list_marker("- item"));
7530 assert!(is_unordered_list_marker("* item"));
7531 assert!(is_unordered_list_marker("+ item"));
7532 assert!(is_unordered_list_marker("-")); assert!(is_unordered_list_marker("*"));
7534 assert!(is_unordered_list_marker("+"));
7535
7536 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")); }
7547
7548 #[test]
7549 fn test_is_block_boundary() {
7550 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"));
7572 assert!(!is_block_boundary("*emphasis*")); assert!(!is_block_boundary("[link](url)")); assert!(!is_block_boundary("some words here"));
7575 }
7576
7577 #[test]
7578 fn test_definition_list_boundary_in_single_line_paragraph() {
7579 let options = ReflowOptions {
7582 line_length: 80,
7583 ..Default::default()
7584 };
7585 let input = "Term\n: Definition of the term";
7586 let result = reflow_markdown(input, &options);
7587 assert!(
7589 result.contains(": Definition"),
7590 "Definition list item should not be merged into previous line. Got: {result:?}"
7591 );
7592 let lines: Vec<&str> = result.lines().collect();
7593 assert_eq!(lines.len(), 2, "Should remain two separate lines. Got: {lines:?}");
7594 assert_eq!(lines[0], "Term");
7595 assert_eq!(lines[1], ": Definition of the term");
7596 }
7597
7598 #[test]
7599 fn test_is_paragraph_boundary() {
7600 assert!(is_paragraph_boundary("# Heading", "# Heading"));
7602 assert!(is_paragraph_boundary("- item", "- item"));
7603 assert!(is_paragraph_boundary(":::", ":::"));
7604 assert!(is_paragraph_boundary(": definition", ": definition"));
7605
7606 assert!(is_paragraph_boundary("code", " code"));
7608 assert!(is_paragraph_boundary("code", "\tcode"));
7609
7610 assert!(is_paragraph_boundary("| a | b |", "| a | b |"));
7612 assert!(is_paragraph_boundary("a | b", "a | b")); assert!(!is_paragraph_boundary("regular text", "regular text"));
7616 assert!(!is_paragraph_boundary("text", " text")); }
7618
7619 #[test]
7620 fn test_div_marker_boundary_in_reflow_paragraph_at_line() {
7621 let content = "Some paragraph text here.\n\n::: {.callout-note}\nThis is a callout.\n:::\n";
7624 let result = reflow_paragraph_at_line(content, 3, 80);
7626 assert!(result.is_none(), "Div marker line should not be reflowed");
7627 }
7628
7629 #[test]
7630 fn starts_block_construct_detects_block_openers() {
7631 for case in ["- item", "-", "* item", "*", "+ item", "+", "-\titem"] {
7633 assert!(starts_block_construct(case), "bullet: {case:?}");
7634 }
7635 for case in ["1. item", "1) item", "01. x", "000000001. x", "1.\titem"] {
7638 assert!(starts_block_construct(case), "ordered: {case:?}");
7639 }
7640 for case in ["> quote", ">quote", ">"] {
7642 assert!(starts_block_construct(case), "blockquote: {case:?}");
7643 }
7644 for case in ["# heading", "###### h6", "#", "##"] {
7646 assert!(starts_block_construct(case), "heading: {case:?}");
7647 }
7648 for case in [
7651 "#hashtag",
7652 "#help-desk.",
7653 "#123",
7654 "##tag",
7655 "######x",
7656 "#\u{1F600}",
7657 "#*x",
7658 "#_x",
7659 ] {
7660 assert!(starts_block_construct(case), "heading missing space: {case:?}");
7661 }
7662 for case in ["```", "```rust", "````", "~~~", "~~~text"] {
7664 assert!(starts_block_construct(case), "fence: {case:?}");
7665 }
7666 for case in ["---", "--", "===", "=", "***", "___", "_ _ _", "- - -"] {
7668 assert!(starts_block_construct(case), "setext/thematic: {case:?}");
7669 }
7670 for case in [": definition", ":\tdefinition", ":", ": ", "::: note"] {
7673 assert!(starts_block_construct(case), "definition list: {case:?}");
7674 }
7675 for case in [
7678 "[^1]: text",
7679 "[^note]:",
7680 "[ref]: http://example.com",
7681 "[wat]: url follows",
7682 ] {
7683 assert!(starts_block_construct(case), "definition: {case:?}");
7684 }
7685 for case in ["<div>content", "</div>", "<p>text", "<table>", "<pre>code", "<h1>x"] {
7687 assert!(starts_block_construct(case), "html block: {case:?}");
7688 }
7689 for case in ["<!-- note -->", "<!-->", "<?php", "<!DOCTYPE html>", "<![CDATA[x]]>"] {
7691 assert!(starts_block_construct(case), "untagged html block: {case:?}");
7692 }
7693 for case in [
7696 "--- | ---",
7697 "------ | - |",
7698 "-|-",
7699 "--:|:--",
7700 "- | -",
7701 "---|",
7702 "-- |\t:-:",
7703 ] {
7704 assert!(starts_block_construct(case), "table delimiter row: {case:?}");
7705 }
7706 }
7707
7708 #[test]
7709 fn starts_block_construct_allows_ordinary_prose() {
7710 for case in [
7711 "",
7712 "word",
7713 "-5 degrees",
7714 "--flag",
7715 "-item",
7716 "####### seven hashes is not a heading",
7717 "#######x",
7718 "a #b",
7719 "x#y",
7720 "7#8",
7721 "1.5 million",
7722 "1234567890. ten digits is not a list marker",
7723 "0000000001. ten digits is not a list marker either",
7724 "2. item",
7727 "7. item",
7728 "0. item",
7729 "42) x",
7730 "123456. item",
7731 "1.",
7732 "1)",
7733 "123456.",
7734 "123456)",
7735 "1.item",
7736 "1:30 pm",
7737 "*emphasis*",
7738 "**bold** text",
7739 "__bold__ text",
7740 "_emphasis_ text",
7741 "`code` span",
7742 "`` double backtick span ``",
7743 "~~strikethrough~~",
7744 "=x",
7745 "== ==",
7746 "(parenthetical)",
7747 "[link](url)",
7748 "[text][ref] more",
7749 "[bracketed] aside",
7750 "[a](b) [ref]: first bracket is a link, not a label",
7751 "[esc\\]: not a close] text",
7752 "<span>inline</span>",
7753 "<b>bold</b>",
7754 "<https://example.com> autolink",
7755 "<mailto:a@b.com>",
7756 "<notarealtag>",
7757 "--flag | grep x",
7758 "-- | it depends",
7759 "--- | -x |",
7760 ] {
7761 assert!(!starts_block_construct(case), "prose: {case:?}");
7762 }
7763 }
7764
7765 #[test]
7766 fn merge_block_construct_continuations_merges_marker_led_lines() {
7767 let lines = vec![
7768 "First sentence?".to_string(),
7769 "- looks like a list item".to_string(),
7770 "Second sentence.".to_string(),
7771 ];
7772 assert_eq!(
7773 merge_block_construct_continuations(lines),
7774 vec![
7775 "First sentence? - looks like a list item".to_string(),
7776 "Second sentence.".to_string(),
7777 ]
7778 );
7779
7780 let lines = vec!["- real list content".to_string(), "continuation".to_string()];
7783 assert_eq!(
7784 merge_block_construct_continuations(lines.clone()),
7785 lines,
7786 "first line must never be merged"
7787 );
7788
7789 let lines = vec!["prose".to_string(), "1.".to_string(), "[ref]:".to_string()];
7792 assert_eq!(
7793 merge_block_construct_continuations(lines),
7794 vec!["prose 1. [ref]:".to_string()],
7795 "a merge that creates an opener must fold again"
7796 );
7797 }
7798
7799 #[test]
7800 fn merge_block_construct_continuations_folds_a_line_after_a_backslash() {
7801 let lines = vec!["Press the \\".to_string(), "key.".to_string()];
7803 assert_eq!(
7804 merge_block_construct_continuations(lines),
7805 vec!["Press the \\ key.".to_string()]
7806 );
7807
7808 let lines = vec!["A path C:\\\\".to_string(), "here.".to_string()];
7811 assert_eq!(merge_block_construct_continuations(lines.clone()), lines);
7812
7813 let lines = vec!["Line one".to_string(), "ends here \\".to_string()];
7816 assert_eq!(merge_block_construct_continuations(lines.clone()), lines);
7817 }
7818
7819 #[test]
7820 fn wrap_never_starts_a_line_with_a_block_marker() {
7821 let options = ReflowOptions {
7822 line_length: 25,
7823 ..Default::default()
7824 };
7825 let lines = reflow_line(
7828 "Some words here and then - a dash clause that wraps around the limit.",
7829 &options,
7830 );
7831 assert_eq!(
7832 lines,
7833 vec![
7834 "Some words here and",
7835 "then - a dash clause that",
7836 "wraps around the limit."
7837 ]
7838 );
7839
7840 for input in [
7842 "Alpha beta gamma delta epsilon - dash clause here to wrap",
7843 "Alpha beta gamma delta epsilon > quote lookalike here to wrap",
7844 "Alpha beta gamma delta epsilon # heading lookalike here to wrap",
7845 "Alpha beta gamma delta epsilon 1. ordered lookalike here to wrap",
7846 "Alpha beta gamma delta epsilon * star clause here to wrap",
7847 "Alpha beta gamma delta epsilon + plus clause here to wrap",
7848 ] {
7849 for width in 10..40 {
7850 let options = ReflowOptions {
7851 line_length: width,
7852 ..Default::default()
7853 };
7854 for line in reflow_line(input, &options) {
7855 assert!(
7856 !starts_block_construct(&line),
7857 "width {width}: wrapped line opens a block construct: {line:?} (input {input:?})"
7858 );
7859 }
7860 }
7861 }
7862 }
7863
7864 #[test]
7867 fn hash_word_never_starts_a_wrapped_line_in_any_mode() {
7868 let options_for = |mode: &str, width: usize| ReflowOptions {
7869 line_length: width,
7870 sentence_per_line: mode == "sentence-per-line",
7871 semantic_line_breaks: mode == "semantic",
7872 ..Default::default()
7873 };
7874 let inputs = [
7875 "Alpha beta gamma delta epsilon #help-desk and the rest here to wrap",
7876 "Alpha beta gamma delta epsilon, ##tag and the rest here to wrap",
7877 "Alpha beta gamma delta fixed in #123, thanks everyone for the report here",
7878 "Is it open? #help-desk is. Really! #123 too. Yes? #1 also.",
7879 "Ask in the channel, #help-desk or elsewhere, and #123 maybe, then more.",
7880 "Questions about this guide? Post them in the channel #help-desk.",
7881 ];
7882 for mode in ["wrap", "sentence-per-line", "semantic"] {
7883 for input in inputs {
7884 for width in 10..60 {
7885 let lines = reflow_line(input, &options_for(mode, width));
7886 for line in &lines {
7887 let hashes = line.bytes().take_while(|&b| b == b'#').count();
7888 assert!(
7889 !starts_block_construct(line) && !(1..=6).contains(&hashes),
7890 "{mode} width {width}: line opens a block construct: {line:?} (input {input:?})"
7891 );
7892 }
7893 let words = |s: &str| s.split_whitespace().map(String::from).collect::<Vec<_>>();
7894 assert_eq!(
7895 words(&lines.join(" ")),
7896 words(input),
7897 "{mode} width {width}: content changed (input {input:?})"
7898 );
7899 }
7900 }
7901 }
7902 }
7903
7904 #[test]
7906 fn hash_controls_stay_where_they_are() {
7907 let options = ReflowOptions {
7908 line_length: 80,
7909 ..Default::default()
7910 };
7911 for input in [
7912 "Issue numbers like # alone and 7#8 and a#b stay mid-line",
7913 "Seven hashes #######y are prose and fixes #12 are fine here",
7914 ] {
7915 assert_eq!(reflow_line(input, &options), vec![input.to_string()]);
7916 }
7917 let options = ReflowOptions {
7919 line_length: 20,
7920 ..Default::default()
7921 };
7922 let lines = reflow_line("aaaa bbbb cccc dddd #######y zzzz", &options);
7923 assert!(
7924 lines.iter().any(|l| l.starts_with("#######y")),
7925 "7+ hashes may start a line: {lines:?}"
7926 );
7927 }
7928
7929 #[test]
7932 fn block_guard_covers_every_line_start_md018_acts_on() {
7933 use crate::config::MarkdownFlavor;
7934 use crate::lint_context::LintContext;
7935 use crate::rule::Rule;
7936 use crate::rules::MD018NoMissingSpaceAtx;
7937
7938 let rule = MD018NoMissingSpaceAtx::new();
7939 let mut acted_on = 0;
7940 for line in [
7941 "#a",
7942 "##a",
7943 "###a",
7944 "####a",
7945 "#####a",
7946 "######a",
7947 "#######a",
7948 "# a",
7949 "## a",
7950 "#",
7951 "##",
7952 "#1",
7953 "#123",
7954 "#help-desk.",
7955 "#*a",
7956 "#_a",
7957 "#\u{FE0F}",
7958 "#\u{1F600}",
7959 "#a#b",
7960 "##\u{00e9}",
7961 "#-x",
7962 "#.x",
7963 "#(x)",
7964 ] {
7965 let content = format!("para\n{line}\n");
7966 let ctx = LintContext::new(&content, MarkdownFlavor::Standard, None);
7967 let acts = ctx.lines[1].heading.is_some()
7968 || ctx.lines[1].atx_missing_space.is_some()
7969 || !rule.check(&ctx).unwrap().is_empty();
7970 if acts {
7971 acted_on += 1;
7972 assert!(
7973 starts_block_construct(line),
7974 "MD018/heading detection acts on {line:?} but the reflow guard allows it"
7975 );
7976 }
7977 }
7978 assert!(acted_on >= 10, "the corpus must exercise the detector, got {acted_on}");
7979 }
7980
7981 #[test]
7982 fn sentence_per_line_keeps_block_markers_mid_line() {
7983 let options = ReflowOptions {
7984 line_length: 80,
7985 sentence_per_line: true,
7986 ..Default::default()
7987 };
7988 let lines = reflow_line(
7991 "Google Calendar (Can't we get rid of this dependency? - I don't really see the need)",
7992 &options,
7993 );
7994 assert_eq!(
7995 lines,
7996 vec!["Google Calendar (Can't we get rid of this dependency? - I don't really see the need)".to_string()]
7997 );
7998
7999 let lines = reflow_line("See section 4? # is the marker we use. Fine.", &options);
8001 assert_eq!(lines, vec!["See section 4? # is the marker we use.", "Fine."]);
8002
8003 let lines = reflow_line("Is this a problem? > I quote someone here.", &options);
8004 assert_eq!(lines, vec!["Is this a problem? > I quote someone here."]);
8005
8006 let lines = reflow_line("Another case! 1. Not a list. More text follows here.", &options);
8007 for line in &lines {
8008 assert!(
8009 !starts_block_construct(line),
8010 "sentence-per-line output opens a block construct: {line:?}"
8011 );
8012 }
8013 }
8014
8015 fn strict_sentence_lines(input: &str, require_sentence_capital: bool) -> Vec<String> {
8017 let options = ReflowOptions {
8018 line_length: 80,
8019 sentence_per_line: true,
8020 require_sentence_capital,
8021 ..Default::default()
8022 };
8023 reflow_line(input, &options)
8024 }
8025
8026 #[test]
8027 fn strict_mode_lets_a_sentence_open_with_a_number() {
8028 for (input, expected) in [
8032 (
8033 "The number of items was 5. 2 of them failed.",
8034 vec!["The number of items was 5.", "2 of them failed."],
8035 ),
8036 (
8037 "Sometimes we have 2. 3 might be here.",
8038 vec!["Sometimes we have 2.", "3 might be here."],
8039 ),
8040 (
8041 "The number of items was 5. 2nd sentence.",
8042 vec!["The number of items was 5.", "2nd sentence."],
8043 ),
8044 (
8045 "Released in 2020. 3 of them failed.",
8046 vec!["Released in 2020.", "3 of them failed."],
8047 ),
8048 (
8049 "First sentence. 2nd sentence.",
8050 vec!["First sentence.", "2nd sentence."],
8051 ),
8052 (
8053 "We met at 6:00 sharp. 6:00 is early.",
8054 vec!["We met at 6:00 sharp.", "6:00 is early."],
8055 ),
8056 ("Pi is 3.14 roughly. Next.", vec!["Pi is 3.14 roughly.", "Next."]),
8057 (
8060 "A \"Is this a test?\" 2020 was memorable.",
8061 vec!["A \"Is this a test?\"", "2020 was memorable."],
8062 ),
8063 ] {
8064 assert_eq!(strict_sentence_lines(input, true), expected, "input {input:?}");
8065 }
8066
8067 for input in [
8070 "The count was 5. and that was all.",
8071 "See fig. 3 for details.",
8072 "See no. 5 in the list.",
8073 "See ch. 12 and vol. 3 for more.",
8074 "A \"Is this a test?\" guide to it.",
8075 ] {
8076 assert_eq!(
8077 strict_sentence_lines(input, true),
8078 vec![input.to_string()],
8079 "input {input:?}"
8080 );
8081 }
8082 }
8083
8084 #[test]
8085 fn sentence_never_opens_with_an_ordered_list_marker() {
8086 for (input, require_capital, expected) in [
8093 (
8094 "Steps: 1. Do this. 2. Do that.",
8095 true,
8096 vec!["Steps: 1.", "Do this. 2.", "Do that."],
8097 ),
8098 (
8099 "First sentence. 1. Do that.",
8100 true,
8101 vec!["First sentence. 1.", "Do that."],
8102 ),
8103 ("Do this! 2. Do that.", true, vec!["Do this! 2.", "Do that."]),
8104 ("Do this. 12) Do that.", true, vec!["Do this. 12) Do that."]),
8105 ("Do this. 2. do that.", true, vec!["Do this. 2. do that."]),
8106 ("Do this. 2. do that.", false, vec!["Do this. 2.", "do that."]),
8107 (
8108 "Twelve. 1234567890. next one here.",
8109 true,
8110 vec!["Twelve. 1234567890. next one here."],
8111 ),
8112 ("Do this. 2 more times.", true, vec!["Do this.", "2 more times."]),
8115 ("How many? 2.", true, vec!["How many?", "2."]),
8116 ("第一句。 2. Do that.", true, vec!["第一句。 2.", "Do that."]),
8118 ("第一句。 2) 第二句。", true, vec!["第一句。 2) 第二句。"]),
8119 ("第一句。 2 more.", true, vec!["第一句。", "2 more."]),
8120 ("第一句。 第二句。", true, vec!["第一句。", "第二句。"]),
8121 ] {
8122 let lines = strict_sentence_lines(input, require_capital);
8123 assert_eq!(lines, expected, "input {input:?}, require capital {require_capital}");
8124 for line in &lines {
8125 let chars: Vec<char> = line.chars().collect();
8126 assert!(
8127 !opens_ordered_list_marker(&chars),
8128 "line opens with an ordered-list marker: {line:?} (input {input:?})"
8129 );
8130 }
8131 }
8132 }
8133
8134 #[test]
8135 fn opens_ordered_list_marker_matches_the_marker_shape() {
8136 let chars = |s: &str| s.chars().collect::<Vec<char>>();
8137 for text in [
8138 "2. x",
8139 "1) x",
8140 "12. x",
8141 "1.\tx",
8142 "2.\nx",
8143 "1)\nx",
8144 "1234567890. x",
8145 "0. x",
8146 ] {
8147 assert!(opens_ordered_list_marker(&chars(text)), "{text:?} is a marker");
8148 }
8149 for text in ["2.x", "2.", "2)", "2 x", "x. y", "", " 2. x", "2.5 x", "-2. x"] {
8150 assert!(!opens_ordered_list_marker(&chars(text)), "{text:?} is not a marker");
8151 }
8152 }
8153
8154 #[test]
8155 fn inline_math_directly_after_display_math_stays_atomic() {
8156 let options = ReflowOptions {
8164 line_length: 8,
8165 ..Default::default()
8166 };
8167 let lines = reflow_line("$$a$$$bb cc dd$ x", &options);
8168 assert_eq!(lines, vec!["$$a$$$bb cc dd$".to_string(), "x".to_string()]);
8169 }
8170
8171 #[test]
8172 fn test_code_span_parsing() {
8173 let elements = parse_markdown_elements_inner("`code`", false, false, None);
8175 assert_eq!(elements.len(), 1);
8176 assert!(matches!(&elements[0], Element::Code { content, marker } if content == "code" && marker == "`"));
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("``code`inside``", false, false, None);
8185 assert_eq!(elements.len(), 1);
8186 assert!(
8187 matches!(&elements[0], Element::Code { content, marker } if content == "code`inside" && marker == "``")
8188 );
8189
8190 let elements = parse_markdown_elements_inner("`` code ``", false, false, None);
8192 assert_eq!(elements.len(), 1);
8193 assert!(matches!(&elements[0], Element::Code { content, marker } if content == " code " && marker == "``"));
8194
8195 let elements = parse_markdown_elements_inner("`unclosed", false, false, None);
8197 assert_eq!(elements.len(), 1);
8198 assert!(matches!(&elements[0], Element::Text(s) if s == "`unclosed"));
8199
8200 let elements = parse_markdown_elements_inner("`unclosed [link](url)", false, false, None);
8202 assert_eq!(elements.len(), 2);
8204 assert!(matches!(&elements[0], Element::Text(s) if s == "`unclosed "));
8205 assert!(matches!(&elements[1], Element::Link(s) if s == "[link](url)"));
8206 }
8207
8208 #[test]
8209 fn test_reflow_performance_long_input() {
8210 let mut text = String::new();
8213 for i in 1..400 {
8214 let backticks = "`".repeat(i);
8215 text.push_str(&backticks);
8216 text.push(' ');
8217 }
8218
8219 let start = std::time::Instant::now();
8220 let elements = parse_markdown_elements_inner(&text, false, false, None);
8221 let duration = start.elapsed();
8222
8223 assert!(duration.as_millis() < 100, "Parsing took too long: {duration:?}");
8225 assert!(!elements.is_empty());
8226 }
8227
8228 #[test]
8229 fn test_reflow_performance_display_math_heavy() {
8230 let text = "$$a$$".repeat(4000);
8235
8236 let start = std::time::Instant::now();
8237 let elements = parse_markdown_elements_inner(&text, false, false, None);
8238 let duration = start.elapsed();
8239
8240 assert!(duration.as_millis() < 100, "Parsing took too long: {duration:?}");
8241 assert_eq!(elements.len(), 4000);
8242 }
8243
8244 #[test]
8245 fn inline_math_len_at_start_matches_regex_at_slice_start() {
8246 let alphabet = ['$', 'a', ' '];
8251 let mut inputs: Vec<String> = vec![String::new()];
8252 let mut frontier: Vec<String> = vec![String::new()];
8253 for _ in 0..6 {
8254 let mut longer = Vec::new();
8255 for prefix in &frontier {
8256 for ch in alphabet {
8257 let mut s = prefix.clone();
8258 s.push(ch);
8259 longer.push(s);
8260 }
8261 }
8262 inputs.extend(longer.iter().cloned());
8263 frontier = longer;
8264 }
8265 inputs.push("$αβ$x".to_string());
8267 inputs.push("$α$$".to_string());
8268
8269 for s in &inputs {
8270 let expected = INLINE_MATH_REGEX
8271 .find(s)
8272 .ok()
8273 .flatten()
8274 .filter(|m| m.start() == 0)
8275 .map(|m| m.end());
8276 assert_eq!(inline_math_len_at_start(s), expected, "input: {s:?}");
8277 }
8278 }
8279
8280 #[test]
8281 fn inline_math_probe_after_dollar_matches_uncached_parse() {
8282 let cases = [
8288 ("$$a$$$b c$ x", r#"[DisplayMath("a"), InlineMath("b c"), Text(" x")]"#),
8289 (
8290 "$$a$$$b$ $$a$$$b$",
8291 r#"[DisplayMath("a"), InlineMath("b"), Text(" "), DisplayMath("a"), InlineMath("b")]"#,
8292 ),
8293 (
8295 "$$a$$$ x $y z$",
8296 r#"[DisplayMath("a"), InlineMath(" x "), Text("y z$")]"#,
8297 ),
8298 ("$$a$$$$ x", r#"[DisplayMath("a"), Text("$$ x")]"#),
8300 ("$$a$$$$b$$ x", r#"[DisplayMath("a"), DisplayMath("b"), Text(" x")]"#),
8301 (
8303 "$a$$b$$c$$d$ tail",
8304 r#"[Text("$a"), DisplayMath("b"), Text("c$$d$ tail")]"#,
8305 ),
8306 ];
8307 for (input, expected) in cases {
8308 let elements = parse_markdown_elements_inner(input, false, false, None);
8309 assert_eq!(format!("{elements:?}"), expected, "input: {input:?}");
8310 }
8311 }
8312
8313 #[test]
8314 fn test_atomic_spans() {
8315 let text_emphasis = "hello **word1 word2**";
8317
8318 let options_disabled = ReflowOptions {
8319 line_length: 18,
8320 atomic_spans: true,
8321 ..Default::default()
8322 };
8323 let lines_disabled = reflow_line(text_emphasis, &options_disabled);
8324 assert_eq!(lines_disabled, vec!["hello", "**word1 word2**"]);
8325
8326 let options_enabled = ReflowOptions {
8327 line_length: 18,
8328 atomic_spans: false,
8329 ..Default::default()
8330 };
8331 let lines_enabled = reflow_line(text_emphasis, &options_enabled);
8332 assert_eq!(lines_enabled, vec!["hello **word1", "word2**"]);
8333
8334 let text_code = "hello `word1 word2`";
8336
8337 let lines_code_disabled = reflow_line(text_code, &options_disabled);
8338 assert_eq!(lines_code_disabled, vec!["hello", "`word1 word2`"]);
8339
8340 let lines_code_enabled = reflow_line(text_code, &options_enabled);
8341 assert_eq!(lines_code_enabled, vec!["hello `word1", "word2`"]);
8342
8343 let text_code_padding = "hello `` `word1` `word2` ``";
8345 let lines_padding_enabled = reflow_line(text_code_padding, &options_enabled);
8346 assert_eq!(lines_padding_enabled, vec![r#"hello `` `word1`"#, r#"`word2` ``"#]);
8347
8348 let text_attached = "**one two**,"; let options_11 = ReflowOptions {
8353 line_length: 11,
8354 atomic_spans: true,
8355 ..Default::default()
8356 };
8357 assert_eq!(reflow_line(text_attached, &options_11), vec!["**one two**,"]);
8358
8359 let options_10 = ReflowOptions {
8361 line_length: 10,
8362 atomic_spans: true,
8363 ..Default::default()
8364 };
8365 assert_eq!(reflow_line(text_attached, &options_10), vec!["**one", "two**,"]);
8366 }
8367
8368 #[test]
8369 fn test_emphasis_containing_markers_is_not_split() {
8370 let options = ReflowOptions {
8371 line_length: 5,
8372 atomic_spans: false,
8373 ..Default::default()
8374 };
8375 let lines = reflow_line(r#"*foo \*bar*"#, &options);
8377 assert_eq!(lines, vec![r#"*foo \*bar*"#.to_string()]);
8378 }
8379
8380 fn semantic_shape(markdown: &str) -> String {
8385 let mut options = Options::empty();
8386 options.insert(Options::ENABLE_STRIKETHROUGH);
8387 let mut out = String::new();
8388 let push_prose = |out: &mut String, text: &str| {
8389 for c in text.chars() {
8390 if c.is_whitespace() {
8391 if !out.ends_with(char::is_whitespace) {
8392 out.push(' ');
8393 }
8394 } else {
8395 out.push(c);
8396 }
8397 }
8398 };
8399 for event in Parser::new_ext(markdown, options) {
8400 match event {
8401 Event::Text(text) => push_prose(&mut out, &text),
8402 Event::SoftBreak | Event::HardBreak => push_prose(&mut out, " "),
8403 Event::Code(code) => out.push_str(&format!("<code>{code}</code>")),
8405 Event::Start(tag) => out.push_str(&format!("<{tag:?}>")),
8406 Event::End(tag) => out.push_str(&format!("</{tag:?}>")),
8407 other => out.push_str(&format!("{other:?}")),
8408 }
8409 }
8410 out.trim().to_string()
8411 }
8412
8413 #[test]
8414 fn test_wrapping_a_span_never_changes_what_it_parses_to() {
8415 let corpus = [
8419 "_This is a very, very, very, very, very long line with some `code` inside._",
8420 "_alpha beta gamma delta epsilon `a b` zeta eta theta iota kappa lambda_",
8421 "**strong text with `code` and more words than fit on one single line**",
8422 "~~struck text with `code` and more words than fit on one single line~~",
8423 "_emphasis with **nested strong that is quite long** and trailing words_",
8424 "***A doubly nested bold italic span with more words than fit on a line***",
8427 "___Another doubly nested span with more words than fit on a single line___",
8428 "**_mixed strong then emphasis with more words than fit on a single line_**",
8429 "*__mixed emphasis then strong with more words than fit on a single line__*",
8430 "**~~strong strikethrough with more words than fit on a single line here~~**",
8431 "**a * b with a stray marker and plenty more words to pass the budget**",
8434 "_foo `a` bar `b` baz qux quux corge grault garply waldo fred plugh xyzzy_",
8435 "text before _a long emphasis with `code` inside of it here_ and after",
8436 "(_a parenthesized long emphasis with `code` inside of it right here_)",
8437 r#"*foo \*bar baz qux quux corge grault garply waldo fred plugh xyzzy*"#,
8438 "_tab\tseparated `a\tb` words spread out over quite a long emphasis span_",
8439 "_This has [text](<a b c d e f g h i j k>) and trailing words to wrap._",
8442 "_This has [`code`](<a b c d e f g h i j k>) and trailing words to wrap._",
8443 r#"_See [x](https://example.com "a long link title here") and `code` too._"#,
8444 "_A [link with a long label](https://example.com/path) and `code` here._",
8445 "_An image  plus `code` and more text_",
8446 ];
8447 for text in corpus {
8448 let expected = semantic_shape(text);
8449 for line_length in [20, 30, 40, 80] {
8450 for atomic_spans in [true, false] {
8451 let options = ReflowOptions {
8452 line_length,
8453 atomic_spans,
8454 ..Default::default()
8455 };
8456 let wrapped = reflow_line(text, &options).join("\n");
8457 assert_eq!(
8458 semantic_shape(&wrapped),
8459 expected,
8460 "reflow changed the parse of {text:?} at line_length={line_length} \
8461 atomic_spans={atomic_spans}\n wrapped: {wrapped:?}"
8462 );
8463 }
8464 }
8465 }
8466 }
8467
8468 #[test]
8469 fn test_wrapping_a_span_keeps_whole_the_constructs_pulldown_cannot_see() {
8470 let cases = [
8474 (
8475 "_alpha beta gamma [[a wiki link]] delta epsilon zeta eta_",
8476 "[[a wiki link]]",
8477 ),
8478 (
8479 "_alpha beta gamma {{< foo bar >}} delta epsilon zeta eta_",
8480 "{{< foo bar >}}",
8481 ),
8482 ("_alpha beta gamma $a + b$ delta epsilon zeta eta theta_", "$a + b$"),
8483 ("_alpha beta gamma $$a + b$$ delta epsilon zeta eta theta_", "$$a + b$$"),
8484 ];
8485 for (text, construct) in cases {
8486 for line_length in [12, 20, 30] {
8487 for atomic_spans in [true, false] {
8488 let options = ReflowOptions {
8489 line_length,
8490 atomic_spans,
8491 ..Default::default()
8492 };
8493 let wrapped = reflow_line(text, &options).join("\n");
8494 assert!(
8495 wrapped.contains(construct),
8496 "{construct} was broken at line_length={line_length} \
8497 atomic_spans={atomic_spans}: {wrapped:?}"
8498 );
8499 }
8500 }
8501 }
8502 }
8503
8504 #[test]
8505 fn test_overlong_emphasis_with_nested_code_span_wraps() {
8506 let options = ReflowOptions {
8510 line_length: 80,
8511 atomic_spans: true,
8512 ..Default::default()
8513 };
8514 let text = "_This is a very, very, very, very, very, very, very long line that exceeds 80 characters with some `code` inside._";
8515 let lines = reflow_line(text, &options);
8516 assert_eq!(
8517 lines,
8518 vec![
8519 "_This is a very, very, very, very, very, very, very long line that exceeds 80",
8520 "characters with some `code` inside._",
8521 ]
8522 );
8523 }
8524
8525 #[test]
8526 fn test_overlong_emphasis_with_nested_strong_wraps() {
8527 let options = ReflowOptions {
8529 line_length: 80,
8530 atomic_spans: true,
8531 ..Default::default()
8532 };
8533 let text = "_This is a very, very, very, very, very, very, very long line that exceeds 80 characters with some **bold** inside._";
8534 let lines = reflow_line(text, &options);
8535 assert_eq!(
8536 lines,
8537 vec![
8538 "_This is a very, very, very, very, very, very, very long line that exceeds 80",
8539 "characters with some **bold** inside._",
8540 ]
8541 );
8542 }
8543
8544 #[test]
8545 fn test_overlong_doubly_nested_span_wraps() {
8546 let options = ReflowOptions {
8551 line_length: 80,
8552 atomic_spans: true,
8553 ..Default::default()
8554 };
8555 let body = "This is a very, very, very, very, very, very, very, very, very, very long line that is emphasised.";
8556 for (open, close) in [
8557 ("***", "***"),
8558 ("___", "___"),
8559 ("**_", "_**"),
8560 ("*__", "__*"),
8561 ("**~~", "~~**"),
8562 ] {
8563 let text = format!("{open}{body}{close}");
8564 assert!(text.len() > options.line_length, "case must start over budget");
8565 let lines = reflow_line(&text, &options);
8566 assert!(
8567 lines.len() > 1,
8568 "{open}...{close} should wrap but stayed on one line: {lines:?}"
8569 );
8570 assert!(
8571 lines.iter().all(|line| line.len() <= options.line_length),
8572 "{open}...{close} left a line over the budget: {lines:?}"
8573 );
8574 assert_eq!(
8575 lines.join(" "),
8576 text,
8577 "{open}...{close} wrapping must only replace a space with a newline"
8578 );
8579 }
8580 }
8581
8582 #[test]
8583 fn test_overlong_span_with_stray_marker_stays_whole() {
8584 let options = ReflowOptions {
8588 line_length: 40,
8589 atomic_spans: true,
8590 ..Default::default()
8591 };
8592 let text = "**alpha * beta gamma delta epsilon zeta eta theta iota kappa**";
8593 let lines = reflow_line(text, &options);
8594 assert_eq!(lines, vec![text], "stray marker must keep the span whole");
8595 }
8596
8597 #[test]
8598 fn test_overlong_span_never_breaks_inside_a_nested_reference_link() {
8599 let options = ReflowOptions {
8605 line_length: 30,
8606 atomic_spans: true,
8607 defined_references: Some(HashSet::from([
8608 "ref".to_string(),
8609 "one two three four five six seven".to_string(),
8611 ])),
8612 ..Default::default()
8613 };
8614 for (text, link) in [
8615 (
8616 "_**alpha [one two three four five six seven][ref] beta gamma delta**_",
8617 "[one two three four five six seven][ref]",
8618 ),
8619 (
8620 "**alpha [one two three four five six seven][ref] beta gamma delta**",
8621 "[one two three four five six seven][ref]",
8622 ),
8623 (
8624 "_**alpha ![one two three four five six seven][ref] beta gamma delta**_",
8625 "![one two three four five six seven][ref]",
8626 ),
8627 (
8628 "_**alpha [one two three four five six seven][] beta gamma delta**_",
8629 "[one two three four five six seven][]",
8630 ),
8631 (
8632 "_**alpha [one two three four five six seven] beta gamma delta**_",
8633 "[one two three four five six seven]",
8634 ),
8635 ] {
8636 let lines = reflow_line(text, &options);
8637 assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
8638 assert!(
8639 lines.iter().any(|line| line.contains(link)),
8640 "{link} must stay on one line: {lines:?}"
8641 );
8642 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
8643 }
8644 }
8645
8646 #[test]
8647 fn test_overlong_span_breaks_inside_an_undefined_shortcut_reference() {
8648 let options = ReflowOptions {
8652 line_length: 30,
8653 atomic_spans: true,
8654 defined_references: Some(HashSet::new()),
8655 ..Default::default()
8656 };
8657 let text = "_**alpha [one two three four five six seven] beta gamma delta**_";
8658 let lines = reflow_line(text, &options);
8659 assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
8660 assert!(
8661 !lines
8662 .iter()
8663 .any(|line| line.contains("[one two three four five six seven]")),
8664 "an undefined shortcut is prose and should break: {lines:?}"
8665 );
8666 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
8667 }
8668
8669 #[test]
8670 fn test_overlong_span_never_breaks_inside_a_nested_attr_list() {
8671 let attr = "{.highlight key=\"a b c\"}";
8675 let text = format!("_**alpha beta gamma delta epsilon zeta{attr} eta theta iota kappa**_");
8676 let options = ReflowOptions {
8677 line_length: 20,
8678 atomic_spans: true,
8679 attr_lists: true,
8680 ..Default::default()
8681 };
8682 let lines = reflow_line(&text, &options);
8683 assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
8684 assert!(
8685 lines.iter().any(|line| line.contains(attr)),
8686 "attr list must stay on one line: {lines:?}"
8687 );
8688 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
8689
8690 let plain = ReflowOptions {
8693 attr_lists: false,
8694 ..options
8695 };
8696 let lines = reflow_line(&text, &plain);
8697 assert!(
8698 !lines.iter().any(|line| line.contains(attr)),
8699 "without the flavor the braces are prose and should break: {lines:?}"
8700 );
8701 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
8702 }
8703
8704 #[test]
8705 fn test_overlong_emphasis_never_breaks_inside_nested_code_span() {
8706 let options = ReflowOptions {
8710 line_length: 30,
8711 atomic_spans: true,
8712 ..Default::default()
8713 };
8714 let text = "_alpha beta gamma delta epsilon `a b` zeta eta theta iota kappa_";
8715 let lines = reflow_line(text, &options);
8716 assert!(lines.len() > 1, "over-long emphasis should wrap: {lines:?}");
8717 assert!(
8718 lines.iter().any(|line| line.contains("`a b`")),
8719 "nested code span must stay whole with its interior spaces: {lines:?}"
8720 );
8721 for line in &lines {
8722 assert_eq!(
8723 line.matches('`').count() % 2,
8724 0,
8725 "no line may contain half a code span: {line:?}"
8726 );
8727 }
8728 }
8729
8730 #[test]
8731 fn test_definition_list_marker_does_not_start_line() {
8732 let options = ReflowOptions {
8733 line_length: 20,
8734 ..Default::default()
8735 };
8736 let lines = reflow_line("This is a term and : definition here.", &options);
8738 for line in &lines {
8739 assert!(
8740 !line.trim_start().starts_with(": "),
8741 "Wrapped line should not start with definition marker: {line}"
8742 );
8743 }
8744 }
8745
8746 #[test]
8747 fn test_div_marker_does_not_start_line() {
8748 let options = ReflowOptions {
8749 line_length: 20,
8750 ..Default::default()
8751 };
8752 let lines = reflow_line("This is some text with ::: class marker.", &options);
8754 for line in &lines {
8755 assert!(
8756 !line.trim_start().starts_with(":::"),
8757 "Wrapped line should not start with div marker: {line}"
8758 );
8759 }
8760 }
8761
8762 fn reflow_markdown_preserving_rendering(content: &str, options: &ReflowOptions) -> String {
8775 let render = |text: &str| {
8776 let mut parser_options = Options::empty();
8777 parser_options.insert(Options::ENABLE_STRIKETHROUGH);
8778 parser_options.insert(Options::ENABLE_DEFINITION_LIST);
8779 let mut html = String::new();
8780 pulldown_cmark::html::push_html(&mut html, Parser::new_ext(text, parser_options));
8781 html.retain(|c| !c.is_ascii_whitespace());
8782 html
8783 };
8784 let reflowed = reflow_markdown(content, options);
8785 assert_eq!(
8786 render(content),
8787 render(&reflowed),
8788 "rendering changed for input: {content:?}"
8789 );
8790 reflowed
8791 }
8792
8793 #[test]
8801 fn a_paragraph_part_never_ends_inside_an_atomic_construct() {
8802 let options = ReflowOptions {
8803 line_length: 0,
8804 sentence_per_line: true,
8805 ..Default::default()
8806 };
8807
8808 let cases = [
8809 ("[(完成。)\n继续。](url)", "[(完成。) 继续。](url)"),
8811 ("`(完成。)\n继续。`", "`(完成。) 继续。`"),
8813 ("[Done.\nNext](url)", "[Done. Next](url)"),
8816 ("(完成。)\n继续。", "(完成。)\n继续。"),
8818 ("Done. Next", "Done.\nNext"),
8819 ];
8820
8821 for (input, expected) in cases {
8822 assert_eq!(
8823 reflow_markdown_preserving_rendering(input, &options),
8824 expected,
8825 "input: {input:?}"
8826 );
8827 }
8828 }
8829
8830 #[test]
8833 fn a_bare_colon_line_ends_the_paragraph_above_it() {
8834 let options = ReflowOptions {
8835 line_length: 0,
8836 sentence_per_line: true,
8837 ..Default::default()
8838 };
8839
8840 for input in ["文章です。\n:", "Done!\n:", "完成。\n:", "Term\n:\nNext term\n: text"] {
8841 assert_eq!(
8842 reflow_markdown_preserving_rendering(input, &options),
8843 input,
8844 "input: {input:?}"
8845 );
8846 }
8847 }
8848
8849 #[test]
8854 fn a_colon_leading_the_first_line_of_a_paragraph_is_prose() {
8855 let options = ReflowOptions {
8856 line_length: 0,
8857 sentence_per_line: true,
8858 ..Default::default()
8859 };
8860
8861 for (input, expected) in [
8862 (
8863 ":warning: First sentence. Second sentence.",
8864 ":warning: First sentence.\nSecond sentence.",
8865 ),
8866 (
8867 "Term\n\n:warning: First sentence. Second sentence.",
8868 "Term\n\n:warning: First sentence.\nSecond sentence.",
8869 ),
8870 (
8871 "# Heading\n:warning: First sentence. Second sentence.",
8872 "# Heading\n:warning: First sentence.\nSecond sentence.",
8873 ),
8874 ] {
8875 assert_eq!(
8876 reflow_markdown_preserving_rendering(input, &options),
8877 expected,
8878 "input: {input:?}"
8879 );
8880 }
8881 for input in [
8882 "Term\n:warning: First sentence. Second sentence.",
8883 ":warning: First sentence.\n:note: Second sentence.",
8884 "- term\n :warning: First sentence. Second sentence.",
8885 ] {
8886 assert_eq!(
8887 reflow_markdown_preserving_rendering(input, &options),
8888 input,
8889 "input: {input:?}"
8890 );
8891 }
8892 }
8893}
8894
8895#[cfg(test)]
8896mod shortcode_delimiter_tests {
8897 use super::*;
8898
8899 #[test]
8900 fn reflow_preserves_shortcode_arguments_containing_closing_markers() {
8901 let options = ReflowOptions {
8902 line_length: 30,
8903 ..Default::default()
8904 };
8905 for (open, close) in [("{{<", ">}}"), ("{{%", "%}}")] {
8906 for quote in ["`", "\""] {
8907 let tag =
8908 format!("{open} note title={quote}literal {close} friend with some words and more{quote} {close}");
8909 let line = format!("Before {tag} after with more prose words.");
8910 let fixed = reflow_line(&line, &options).join("\n");
8911 assert!(fixed.contains(&tag), "{line:?} -> {fixed:?}");
8912 assert!(fixed.contains('\n'), "surrounding prose should still reflow");
8913 }
8914 }
8915 }
8916}