1use crate::utils::calculate_indentation_width_default;
7use crate::utils::mkdocs_attr_list::{ATTR_LIST_PATTERN, is_standalone_attr_list};
8use crate::utils::mkdocs_snippets::is_snippet_block_delimiter;
9use crate::utils::regex_cache::{
10 DISPLAY_MATH_REGEX, EMAIL_PATTERN, EMOJI_SHORTCODE_REGEX, HTML_ENTITY_REGEX, HTML_TAG_PATTERN, INLINE_MATH_REGEX,
11 WIKI_LINK_REGEX,
12};
13use crate::utils::sentence_utils::{
14 get_abbreviations, is_cjk_char, is_cjk_sentence_ending, is_closing_bracket, is_closing_quote, is_opening_quote,
15 text_ends_with_abbreviation,
16};
17use crate::utils::unicode::joins_cjk_soft_break;
18use pulldown_cmark::{BrokenLink, CowStr, Event, LinkType, Options, Parser, Tag, TagEnd};
19use std::collections::HashSet;
20use std::sync::LazyLock;
21use unicode_width::UnicodeWidthStr;
22
23#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
25#[serde(rename_all = "kebab-case")]
26pub enum CjkSoftBreak {
27 #[default]
29 Space,
30 Join,
33}
34
35#[derive(Clone, Copy, Debug, Default, PartialEq)]
37pub enum ReflowLengthMode {
38 Chars,
40 #[default]
42 Visual,
43 Bytes,
45}
46
47pub(crate) fn display_len(s: &str, mode: ReflowLengthMode) -> usize {
49 match mode {
50 ReflowLengthMode::Chars => s.chars().count(),
51 ReflowLengthMode::Visual => s.width(),
52 ReflowLengthMode::Bytes => s.len(),
53 }
54}
55
56fn is_breakable_whitespace(c: char) -> bool {
60 matches!(c, ' ' | '\t' | '\n' | '\r')
61}
62
63fn is_significant_whitespace(c: char) -> bool {
70 c.is_whitespace() && !is_breakable_whitespace(c)
71}
72
73pub(crate) fn trim_breakable_whitespace(text: &str) -> &str {
76 text.trim_matches(is_breakable_whitespace)
77}
78
79fn split_breakable_words(text: &str) -> impl Iterator<Item = &str> {
81 text.split(is_breakable_whitespace).filter(|word| !word.is_empty())
82}
83
84fn code_span_wraps_losslessly(content: &str) -> bool {
93 let mut prev_ws = false;
94 for c in content.chars() {
95 let ws = is_breakable_whitespace(c);
96 if ws && (prev_ws || c != ' ') {
97 return false;
98 }
99 prev_ws = ws;
100 }
101 true
102}
103
104struct NestedStructure {
107 atomic: Vec<(usize, usize)>,
114 markers: Vec<(usize, usize)>,
119 marker_closers: Vec<(usize, usize)>,
125 links: Vec<(usize, usize)>,
130 code_spans: Vec<(usize, usize)>,
133}
134
135struct OpenSpan {
137 span: (usize, usize),
139 content: Option<(usize, usize)>,
142}
143
144fn note_span_content(open: &mut [OpenSpan], start: usize, end: usize) {
147 for open_span in open.iter_mut() {
148 if start >= open_span.span.0 && end <= open_span.span.1 {
149 open_span.content = Some(match open_span.content {
150 Some((known_start, known_end)) => (known_start.min(start), known_end.max(end)),
151 None => (start, end),
152 });
153 }
154 }
155}
156
157fn merge_ranges(mut ranges: Vec<(usize, usize)>) -> Vec<(usize, usize)> {
158 ranges.sort_unstable();
161 let mut merged: Vec<(usize, usize)> = Vec::with_capacity(ranges.len());
162 for (start, end) in ranges {
163 match merged.last_mut() {
164 Some(last) if start <= last.1 => last.1 = last.1.max(end),
165 _ => merged.push((start, end)),
166 }
167 }
168 merged
169}
170
171fn nested_structure(content: &str, defined_references: Option<&HashSet<String>>, attr_lists: bool) -> NestedStructure {
173 let mut options = Options::empty();
174 options.insert(Options::ENABLE_STRIKETHROUGH);
175
176 let mut atomic: Vec<(usize, usize)> = Vec::new();
177 let mut markers: Vec<(usize, usize)> = Vec::new();
178 let mut marker_closers: Vec<(usize, usize)> = Vec::new();
179 let mut links: Vec<(usize, usize)> = Vec::new();
180 let mut code_spans: Vec<(usize, usize)> = Vec::new();
181 let mut open: Vec<OpenSpan> = Vec::new();
184
185 let indent = content.len() - content.trim_start_matches([' ', '\t']).len();
190 for (event, range) in Parser::new_ext(&content[indent..], options).into_offset_iter() {
191 let (start, end) = (range.start + indent, range.end + indent);
192 if !matches!(event, Event::End(_)) {
196 note_span_content(&mut open, start, end);
197 }
198 match event {
199 Event::Start(Tag::Link { .. } | Tag::Image { .. }) => {
200 atomic.push((start, end));
201 links.push((start, end));
202 }
203 Event::Code(_) => {
204 atomic.push((start, end));
205 code_spans.push((start, end));
206 }
207 Event::InlineHtml(_) => {
208 atomic.push((start, end));
209 }
210 Event::Start(Tag::Emphasis | Tag::Strong | Tag::Strikethrough) => {
211 open.push(OpenSpan {
212 span: (start, end),
213 content: None,
214 });
215 }
216 Event::End(TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough) => {
217 if let Some(OpenSpan {
218 span: (span_start, span_end),
219 content,
220 }) = open.pop()
221 {
222 match content {
223 Some((content_start, content_end)) => {
227 markers.push((span_start, content_start));
228 markers.push((content_end, span_end));
229 marker_closers.push((content_end, span_end));
230 }
231 None => atomic.push((span_start, span_end)),
234 }
235 }
236 }
237 _ => {}
238 }
239 }
240
241 for span in all_link_spans(content, defined_references) {
248 atomic.push((span.start, span.end));
249 links.push((span.start, span.end));
250 }
251
252 for found in WIKI_LINK_REGEX.find_iter(content) {
256 atomic.push((found.start(), found.end()));
257 links.push((found.start(), found.end()));
258 }
259 atomic.extend(crate::utils::shortcode_utils::shortcode_ranges(content));
260
261 let held = merge_ranges(atomic.clone());
270 let mut masked = String::with_capacity(content.len());
271 let mut next_held = 0;
272 for (pos, ch) in content.char_indices() {
273 while held.get(next_held).is_some_and(|&(_, end)| end <= pos) {
274 next_held += 1;
275 }
276 if held.get(next_held).is_some_and(|&(start, _)| start <= pos) {
277 masked.extend(std::iter::repeat_n(' ', ch.len_utf8()));
278 } else {
279 masked.push(ch);
280 }
281 }
282 for found in DISPLAY_MATH_REGEX.find_iter(&masked) {
283 atomic.push((found.start(), found.end()));
284 }
285 let mut from = 0;
286 while let Ok(Some(found)) = INLINE_MATH_REGEX.find_from_pos(&masked, from) {
287 atomic.push((found.start(), found.end()));
288 from = found.end();
289 }
290
291 if attr_lists {
297 for found in ATTR_LIST_PATTERN.find_iter(content) {
298 atomic.push((found.start(), found.end()));
299 }
300 }
301
302 links.sort_unstable();
305 links.dedup();
306
307 NestedStructure {
312 atomic: merge_ranges(atomic),
313 markers: merge_ranges(markers),
314 marker_closers: merge_ranges(marker_closers),
315 links,
316 code_spans,
317 }
318}
319
320fn breakable_units<'a>(
343 content: &'a str,
344 defined_references: Option<&HashSet<String>>,
345 attr_lists: bool,
346) -> Option<Vec<&'a str>> {
347 if !content.contains(['`', '*', '_', '~', '[', '<', '$', '{']) {
350 return Some(split_breakable_words(content).collect());
351 }
352
353 let NestedStructure { atomic, markers, .. } = nested_structure(content, defined_references, attr_lists);
354
355 let mut units = Vec::new();
356 let mut unit_start = None;
357 let mut next_atomic = 0;
358 let mut next_marker = 0;
359 for (offset, ch) in content.char_indices() {
360 while atomic.get(next_atomic).is_some_and(|&(_, end)| end <= offset) {
361 next_atomic += 1;
362 }
363 if atomic.get(next_atomic).is_some_and(|&(start, _)| offset >= start) {
364 if unit_start.is_none() {
367 unit_start = Some(offset);
368 }
369 continue;
370 }
371 while markers.get(next_marker).is_some_and(|&(_, end)| end <= offset) {
372 next_marker += 1;
373 }
374 if matches!(ch, '`' | '*' | '_' | '~') && markers.get(next_marker).is_none_or(|&(start, _)| offset < start) {
375 return None;
376 }
377 if is_breakable_whitespace(ch) {
378 if let Some(start) = unit_start.take() {
379 units.push(&content[start..offset]);
380 }
381 } else if unit_start.is_none() {
382 unit_start = Some(offset);
383 }
384 }
385 if let Some(start) = unit_start {
386 units.push(&content[start..]);
387 }
388 Some(units)
389}
390
391pub(crate) fn link_text_break_units<'a>(
411 inner: &'a str,
412 suffix: &str,
413 budget: usize,
414 mode: ReflowLengthMode,
415 defined_references: Option<&HashSet<String>>,
416 attr_lists: bool,
417) -> Option<Vec<&'a str>> {
418 if display_len(suffix, mode) > budget && display_len(inner, mode) + 2 <= budget {
419 return None;
420 }
421 let units = breakable_units(inner, defined_references, attr_lists)?;
422 if units.len() < 2 {
423 return None;
424 }
425 let tail = format!("{}{suffix}", units[units.len() - 1]);
426 if display_len(&tail, mode) > budget && !last_token_overflow_only(&tail, budget, mode) {
427 return None;
428 }
429 Some(units)
430}
431
432fn last_token_overflow_only(line: &str, budget: usize, mode: ReflowLengthMode) -> bool {
438 match line.rfind(char::is_whitespace) {
439 None => true,
440 Some(pos) => {
441 let ws_len = line[pos..].chars().next().map_or(1, char::len_utf8);
442 display_len(&line[..pos + ws_len], mode) < budget
443 }
444 }
445}
446
447#[derive(Clone)]
449pub struct ReflowOptions {
450 pub line_length: usize,
452 pub break_on_sentences: bool,
454 pub preserve_breaks: bool,
456 pub sentence_per_line: bool,
458 pub sentence_pack: bool,
460 pub first_line_length: Option<usize>,
462 pub semantic_line_breaks: bool,
464 pub abbreviations: Option<Vec<String>>,
468 pub length_mode: ReflowLengthMode,
470 pub attr_lists: bool,
473 pub myst_roles: bool,
477 pub require_sentence_capital: bool,
482 pub max_list_continuation_indent: Option<usize>,
486 pub defined_references: Option<HashSet<String>>,
500 pub atomic_spans: bool,
504 pub wrap_code_spans: bool,
508 pub break_link_text: bool,
517 pub length_exemptions: LengthExemptions,
520 pub cjk_soft_break: CjkSoftBreak,
522}
523
524#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
531pub struct LengthExemptions {
532 pub link_urls: bool,
535 pub code_spans: bool,
537}
538
539impl LengthExemptions {
540 fn any(&self) -> bool {
543 self.link_urls || self.code_spans
544 }
545}
546
547impl Default for ReflowOptions {
548 fn default() -> Self {
549 Self {
550 line_length: 80,
551 break_on_sentences: true,
552 preserve_breaks: false,
553 sentence_per_line: false,
554 sentence_pack: false,
555 first_line_length: None,
556 semantic_line_breaks: false,
557 abbreviations: None,
558 length_mode: ReflowLengthMode::default(),
559 attr_lists: false,
560 myst_roles: false,
561 require_sentence_capital: true,
562 max_list_continuation_indent: None,
563 defined_references: None,
564 atomic_spans: true,
565 wrap_code_spans: true,
566 break_link_text: false,
567 length_exemptions: LengthExemptions::default(),
568 cjk_soft_break: CjkSoftBreak::default(),
569 }
570 }
571}
572
573#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
580struct LineWidth {
581 link_exempt: usize,
583 code_exempt: usize,
585}
586
587impl LineWidth {
588 fn plain(width: usize) -> Self {
591 Self {
592 link_exempt: width,
593 code_exempt: width,
594 }
595 }
596
597 fn effective(self) -> usize {
600 self.link_exempt.min(self.code_exempt)
601 }
602
603 fn fits(self, line_length: usize) -> bool {
604 self.effective() <= line_length
605 }
606
607 fn is_empty(self) -> bool {
611 self.link_exempt == 0 && self.code_exempt == 0
612 }
613}
614
615impl std::ops::Add for LineWidth {
616 type Output = Self;
617
618 fn add(self, other: Self) -> Self {
619 Self {
620 link_exempt: self.link_exempt + other.link_exempt,
621 code_exempt: self.code_exempt + other.code_exempt,
622 }
623 }
624}
625
626impl std::ops::AddAssign for LineWidth {
627 fn add_assign(&mut self, other: Self) {
628 *self = *self + other;
629 }
630}
631
632pub fn normalize_reference_label(label: &str) -> String {
639 label.split_whitespace().collect::<Vec<_>>().join(" ").to_lowercase()
640}
641
642fn footnote_ref_end(chars: &[char], start: usize) -> Option<usize> {
648 if chars.get(start) != Some(&'[') || chars.get(start + 1) != Some(&'^') {
649 return None;
650 }
651 let label_start = start + 2;
652 let mut label_end = label_start;
653 while matches!(chars.get(label_end), Some(c) if c.is_ascii_alphanumeric() || *c == '_' || *c == '-') {
654 label_end += 1;
655 }
656 (label_end > label_start && chars.get(label_end) == Some(&']')).then_some(label_end + 1)
657}
658
659fn footnote_refs_end(chars: &[char], start: usize) -> Option<usize> {
663 let mut end = footnote_ref_end(chars, start)?;
664 while let Some(after_ref) = footnote_ref_end(chars, end) {
665 end = after_ref;
666 }
667 Some(end)
668}
669
670fn char_byte_offsets(chars: &[char]) -> Vec<usize> {
673 let mut offsets = Vec::with_capacity(chars.len() + 1);
674 let mut offset = 0;
675 for c in chars {
676 offsets.push(offset);
677 offset += c.len_utf8();
678 }
679 offsets.push(offset);
680 offsets
681}
682
683struct SentenceText<'a> {
688 text: &'a str,
689 chars: &'a [char],
690 char_offsets: &'a [usize],
691 links: &'a [(usize, usize)],
692 code_spans: &'a [(usize, usize)],
693 markers: &'a [(usize, usize)],
694 marker_closers: &'a [(usize, usize)],
695 paragraph: Option<ParagraphStructure<'a>>,
700 cjk_soft_break: CjkSoftBreak,
703}
704
705impl SentenceText<'_> {
706 fn in_span_delimiter(&self, pos: usize) -> bool {
711 let Some(&offset) = self.char_offsets.get(pos) else {
712 return false;
713 };
714 match self.markers.binary_search_by_key(&offset, |&(start, _)| start) {
715 Ok(_) => true,
716 Err(0) => false,
717 Err(i) => offset < self.markers[i - 1].1,
718 }
719 }
720
721 fn span_closer_end(&self, pos: usize) -> Option<usize> {
727 let &offset = self.char_offsets.get(pos)?;
728 let covering = match self.marker_closers.binary_search_by_key(&offset, |&(start, _)| start) {
729 Ok(i) => i,
730 Err(0) => return None,
731 Err(i) if offset < self.marker_closers[i - 1].1 => i - 1,
732 Err(_) => return None,
733 };
734 let end = self.marker_closers[covering].1;
735 Some(self.char_offsets.binary_search(&end).unwrap_or_else(|i| i))
736 }
737
738 fn opens_code_span(&self, pos: usize) -> bool {
743 self.char_offsets
744 .get(pos)
745 .is_some_and(|&start| self.code_spans.binary_search_by_key(&start, |&(s, _)| s).is_ok())
746 }
747
748 fn link_end_at(&self, pos: usize) -> Option<usize> {
757 let range_start = match self.chars.get(pos) {
758 Some('[') => pos,
759 Some('!') if self.chars.get(pos + 1) == Some(&'[') => match self.link_range_end_at(pos) {
760 Some(end) => return Some(end),
761 None => pos + 1,
762 },
763 _ => return None,
764 };
765 self.link_range_end_at(range_start)
766 }
767
768 fn link_range_end_at(&self, pos: usize) -> Option<usize> {
770 let start = self.char_offsets[pos];
771 let idx = self.links.binary_search_by_key(&start, |&(s, _)| s).ok()?;
772 let end = self.links[idx].1;
773 Some(self.char_offsets.binary_search(&end).unwrap_or_else(|i| i))
774 }
775
776 fn byte_at(&self, pos: usize) -> usize {
778 self.char_offsets.get(pos).copied().unwrap_or(self.text.len())
779 }
780
781 fn cut_keeps_text(&self, cut: usize, resume: usize) -> bool {
790 if cut < resume {
791 return true;
792 }
793 let resume_byte = self.byte_at(resume);
794 let rest = match self.paragraph {
795 Some(paragraph) => ¶graph.text[paragraph.base + resume_byte..],
796 None => &self.text[resume_byte..],
797 };
798 !starts_block_construct(rest)
799 }
800}
801
802#[derive(Clone, Copy)]
815struct ParagraphStructure<'a> {
816 text: &'a str,
817 structure: &'a NestedStructure,
818 base: usize,
819}
820
821impl ParagraphStructure<'_> {
822 fn window(&self, len: usize) -> NestedStructure {
834 let base = self.base;
835 let window_end = base + len;
836 let inside = |ranges: &[(usize, usize)]| -> Vec<(usize, usize)> {
837 let first = ranges.partition_point(|&(_, end)| end <= base);
838 let past = first + ranges[first..].partition_point(|&(start, _)| start < window_end);
839 ranges[first..past]
840 .iter()
841 .map(|&(start, end)| (start.saturating_sub(base), (end - base).min(len)))
842 .collect()
843 };
844 let starting_inside = |ranges: &[(usize, usize)]| -> Vec<(usize, usize)> {
845 let first = ranges.partition_point(|&(start, _)| start < base);
846 let past = first + ranges[first..].partition_point(|&(start, _)| start < window_end);
847 ranges[first..past]
848 .iter()
849 .map(|&(start, end)| (start - base, (end - base).min(len)))
850 .collect()
851 };
852 NestedStructure {
853 atomic: inside(&self.structure.atomic),
854 markers: inside(&self.structure.markers),
855 marker_closers: inside(&self.structure.marker_closers),
856 links: starting_inside(&self.structure.links),
857 code_spans: starting_inside(&self.structure.code_spans),
858 }
859 }
860}
861
862fn marker_run_extent(chars: &[char], from: usize) -> usize {
869 let mut end = from;
870 while end < chars.len() && matches!(chars[end], '*' | '_' | '~') {
871 end += 1;
872 }
873 end
874}
875
876fn delimiter_run_extent(chars: &[char], from: usize) -> usize {
882 let Some(&first) = chars.get(from) else {
883 return from;
884 };
885 if !matches!(first, '*' | '_' | '~') {
886 return from;
887 }
888 let mut end = from;
889 while chars.get(end) == Some(&first) {
890 end += 1;
891 }
892 end
893}
894
895fn cjk_sentence_end(st: &SentenceText<'_>, pos: usize) -> Option<usize> {
933 let mut end = pos + 1;
934 loop {
935 if let Some(after_ref) = footnote_ref_end(st.chars, end)
936 && st.link_range_end_at(end).is_none_or(|link_end| link_end == after_ref)
937 {
938 end = after_ref;
939 continue;
940 }
941 let Some(&next) = st.chars.get(end) else {
942 return Some(end);
943 };
944 if is_closing_bracket(next) || is_closing_quote(next) {
945 end += 1;
946 continue;
947 }
948 if !matches!(next, '*' | '_' | '~') {
949 return Some(end);
950 }
951 if let Some(closer_end) = st.span_closer_end(end) {
952 if st
953 .chars
954 .get(closer_end)
955 .is_some_and(|after| Some(after) == st.chars.get(closer_end - 1))
956 {
957 return None;
958 }
959 end = closer_end;
960 continue;
961 }
962 if st.in_span_delimiter(end) {
963 return Some(end);
964 }
965 let run_end = delimiter_run_extent(st.chars, end);
966 match st.chars.get(run_end) {
967 None => return Some(run_end),
968 Some(&after) if is_breakable_whitespace(after) => return Some(run_end),
969 Some(_) => return None,
970 }
971 }
972}
973
974fn sentence_boundary(
986 st: &SentenceText<'_>,
987 pos: usize,
988 abbreviations: &HashSet<String>,
989 require_sentence_capital: bool,
990) -> Option<usize> {
991 let SentenceText { text, chars, .. } = *st;
992 if pos + 1 >= chars.len() {
993 return None;
994 }
995 let byte_offset_after_punct = st.char_offsets[pos + 1];
996
997 let c = chars[pos];
998 let next_char = chars[pos + 1];
999
1000 if is_cjk_sentence_ending(c) {
1003 let cut = cjk_sentence_end(st, pos)?;
1008
1009 let mut after_punct_pos = cut;
1011 while after_punct_pos < chars.len() && is_breakable_whitespace(chars[after_punct_pos]) {
1012 after_punct_pos += 1;
1013 }
1014 let resume = after_punct_pos;
1015
1016 if after_punct_pos >= chars.len() {
1019 return None;
1020 }
1021
1022 let break_dropped = st.cjk_soft_break == CjkSoftBreak::Join
1029 && joins_cjk_soft_break(chars[cut - 1])
1030 && joins_cjk_soft_break(chars[resume]);
1031 if (resume > cut) == break_dropped {
1032 return None;
1033 }
1034
1035 if continues_clause(chars[resume]) {
1039 return None;
1040 }
1041
1042 if opens_ordered_list_marker(&chars[after_punct_pos..]) {
1045 return None;
1046 }
1047
1048 while after_punct_pos < chars.len()
1050 && (chars[after_punct_pos] == '*' || chars[after_punct_pos] == '_' || chars[after_punct_pos] == '~')
1051 {
1052 after_punct_pos += 1;
1053 }
1054
1055 if after_punct_pos >= chars.len() {
1056 return None;
1057 }
1058
1059 return st.cut_keeps_text(cut, resume).then_some(cut);
1062 }
1063
1064 if c != '.' && c != '!' && c != '?' {
1066 return None;
1067 }
1068
1069 let inside_quotation = is_closing_quote(next_char);
1072
1073 let (space_pos, after_space_pos) = if matches!(next_char, ' ' | '\n') {
1076 (pos + 1, pos + 2)
1078 } else if is_closing_quote(next_char) && pos + 2 < chars.len() {
1079 match marker_run_end(chars, pos + 2) {
1082 Some(end) => (end, end + 1),
1083 None => return None,
1084 }
1085 } else if matches!(next_char, '*' | '_' | '~') {
1086 match marker_run_end(chars, pos + 1) {
1090 Some(end) => (end, end + 1),
1091 None => return None,
1092 }
1093 } else if next_char == '[' {
1094 match footnote_refs_end(chars, pos + 1) {
1100 Some(end_pos) if matches!(chars.get(end_pos), Some(' ' | '\n')) => (end_pos, end_pos + 1),
1101 _ => return None,
1102 }
1103 } else {
1104 return None;
1105 };
1106
1107 let mut next_char_pos = after_space_pos;
1109 while next_char_pos < chars.len() && is_breakable_whitespace(chars[next_char_pos]) {
1110 next_char_pos += 1;
1111 }
1112
1113 if next_char_pos >= chars.len() {
1115 return None;
1116 }
1117
1118 if opens_ordered_list_marker(&chars[next_char_pos..]) {
1127 return None;
1128 }
1129
1130 let mut first_letter_pos = next_char_pos;
1136 while first_letter_pos < chars.len() {
1137 let ch = chars[first_letter_pos];
1138 if let Some(end) = st.link_end_at(first_letter_pos) {
1139 first_letter_pos += link_opener_len(chars, first_letter_pos, end);
1140 } else if matches!(ch, '*' | '_' | '~') || is_opening_quote(ch) {
1141 first_letter_pos += 1;
1142 } else {
1143 break;
1144 }
1145 }
1146
1147 if first_letter_pos >= chars.len() {
1149 return None;
1150 }
1151
1152 let first_char = chars[first_letter_pos];
1153
1154 if c == '!' || c == '?' {
1160 if inside_quotation && require_sentence_capital && !opens_sentence_in_strict_mode(first_char) {
1161 return None;
1162 }
1163 return Some(space_pos);
1164 }
1165
1166 if pos > 0 {
1172 if matches!(next_char, ' ' | '\n') && chars[pos - 1].is_ascii_digit() {
1176 let mut digit_start = pos - 1;
1177 while digit_start > 0 && chars[digit_start - 1].is_ascii_digit() {
1178 digit_start -= 1;
1179 }
1180 let start = st.byte_at(digit_start);
1181 if st.markers.iter().any(|&(open, end)| {
1182 end == start
1183 && !st
1184 .marker_closers
1185 .iter()
1186 .any(|&(close, end)| close <= open && open < end)
1187 }) {
1188 return None;
1189 }
1190 }
1191
1192 if text_ends_with_abbreviation(&text[..byte_offset_after_punct], abbreviations) {
1194 return None;
1195 }
1196
1197 if chars[pos - 1].is_ascii_uppercase() && (pos == 1 || (pos >= 2 && chars[pos - 2].is_whitespace())) {
1201 return None;
1202 }
1203 }
1204
1205 let elision = pos > 0 && chars[pos - 1] == '.';
1216 let digit_run = pos > 0 && chars[pos - 1].is_numeric();
1217 let bare = space_pos == pos + 1;
1218
1219 let numeric_label = digit_run && bare && {
1224 let mut start = pos;
1225 while start > 0 && chars[start - 1].is_ascii_digit() {
1226 start -= 1;
1227 }
1228 start == 0 || chars[start - 1] == '.' || chars[..start].iter().rev().find(|c| !c.is_whitespace()) == Some(&':')
1229 };
1230 static SENTENCE_FINAL_ABBREVIATIONS: LazyLock<HashSet<String>> =
1234 LazyLock::new(|| ["etc", "inc", "ph.d", "u.s"].into_iter().map(String::from).collect());
1235 let ends_with_optional_abbreviation =
1236 text_ends_with_abbreviation(&text[..byte_offset_after_punct], &SENTENCE_FINAL_ABBREVIATIONS);
1237 if chars[space_pos..next_char_pos].contains(&'\n')
1238 && !inside_quotation
1239 && !elision
1240 && !numeric_label
1241 && !ends_with_optional_abbreviation
1242 {
1243 return Some(space_pos);
1244 }
1245
1246 if st.opens_code_span(first_letter_pos) && !elision && !(digit_run && bare) {
1252 return Some(space_pos);
1253 }
1254
1255 if require_sentence_capital && !opens_sentence_in_strict_mode(first_char) {
1258 return None;
1259 }
1260
1261 Some(space_pos)
1262}
1263
1264fn marker_run_end(chars: &[char], from: usize) -> Option<usize> {
1272 let end = marker_run_extent(chars, from);
1273 matches!(chars.get(end), Some(' ' | '\n')).then_some(end)
1274}
1275
1276fn opens_sentence_in_strict_mode(first_char: char) -> bool {
1284 first_char.is_uppercase() || first_char.is_numeric() || is_cjk_char(first_char)
1285}
1286
1287fn continues_clause(c: char) -> bool {
1290 matches!(
1291 c,
1292 ',' | ';' | ':' | '.' | '!' | '?' | '、' | ',' | ';' | ':' | '.' | '。' | '、'
1293 ) || is_cjk_sentence_ending(c)
1294}
1295
1296fn opens_ordered_list_marker(chars: &[char]) -> bool {
1302 let digits = chars.iter().take_while(|c| c.is_ascii_digit()).count();
1303 digits > 0
1304 && matches!(chars.get(digits), Some('.' | ')'))
1305 && matches!(chars.get(digits + 1), Some(' ' | '\t' | '\n'))
1306}
1307
1308fn link_opener_len(chars: &[char], pos: usize, end: usize) -> usize {
1314 let open = if chars[pos] == '!' { pos + 1 } else { pos };
1315 let body = open + 1;
1316 if chars.get(body) != Some(&'[') {
1317 return body - pos;
1318 }
1319 let body = body + 1;
1320 let alias = chars[body..end.saturating_sub(2).max(body)]
1321 .iter()
1322 .position(|&c| c == '|')
1323 .map_or(body, |p| body + p + 1);
1324 alias - pos
1325}
1326
1327pub fn split_into_sentences(
1340 text: &str,
1341 defined_references: Option<&HashSet<String>>,
1342 require_sentence_capital: bool,
1343 cjk_soft_break: CjkSoftBreak,
1344) -> Vec<String> {
1345 let abbreviations = get_abbreviations(&None);
1346 split_into_sentences_with_set(
1347 text,
1348 &abbreviations,
1349 require_sentence_capital,
1350 cjk_soft_break,
1351 defined_references,
1352 None,
1353 )
1354}
1355
1356fn split_into_sentences_with_set(
1364 text: &str,
1365 abbreviations: &HashSet<String>,
1366 require_sentence_capital: bool,
1367 cjk_soft_break: CjkSoftBreak,
1368 defined_references: Option<&HashSet<String>>,
1369 paragraph: Option<ParagraphStructure<'_>>,
1370) -> Vec<String> {
1371 split_into_sentence_ranges(
1372 text,
1373 abbreviations,
1374 require_sentence_capital,
1375 cjk_soft_break,
1376 defined_references,
1377 paragraph,
1378 )
1379 .into_iter()
1380 .map(|(start, end)| text[start..end].to_string())
1381 .collect()
1382}
1383
1384fn trim_range(text: &str, start: usize, end: usize) -> Option<(usize, usize)> {
1387 let slice = &text[start..end];
1388 let leading = slice.len() - slice.trim_start_matches(is_breakable_whitespace).len();
1389 let trailing = slice.len() - slice.trim_end_matches(is_breakable_whitespace).len();
1390 (leading + trailing < slice.len()).then(|| (start + leading, end - trailing))
1391}
1392
1393fn split_into_sentence_ranges(
1401 text: &str,
1402 abbreviations: &HashSet<String>,
1403 require_sentence_capital: bool,
1404 cjk_soft_break: CjkSoftBreak,
1405 defined_references: Option<&HashSet<String>>,
1406 paragraph: Option<ParagraphStructure<'_>>,
1407) -> Vec<(usize, usize)> {
1408 let char_vec: Vec<char> = text.chars().collect();
1409 let char_offsets = char_byte_offsets(&char_vec);
1410
1411 let NestedStructure {
1415 atomic,
1416 links,
1417 code_spans,
1418 markers,
1419 marker_closers,
1420 } = match paragraph {
1421 Some(paragraph) => paragraph.window(text.len()),
1422 None => sentence_structure(text, defined_references),
1423 };
1424 let mut atomic_it = atomic.iter().peekable();
1425 let st = SentenceText {
1426 text,
1427 chars: &char_vec,
1428 char_offsets: &char_offsets,
1429 links: &links,
1430 code_spans: &code_spans,
1431 markers: &markers,
1432 marker_closers: &marker_closers,
1433 paragraph,
1434 cjk_soft_break,
1435 };
1436
1437 let next_sentence_start = |cut: usize| if char_vec.get(cut) == Some(&' ') { cut + 1 } else { cut };
1440
1441 let mut cuts: Vec<usize> = Vec::new();
1445 let mut pos = 0;
1446
1447 while pos < char_vec.len() {
1448 let byte_idx = char_offsets[pos];
1449
1450 while let Some(&&(_, end)) = atomic_it.peek() {
1452 if end <= byte_idx {
1453 atomic_it.next();
1454 } else {
1455 break;
1456 }
1457 }
1458
1459 let in_atomic = atomic_it
1461 .peek()
1462 .is_some_and(|&&(start, end)| byte_idx >= start && byte_idx < end);
1463
1464 if !in_atomic && let Some(cut) = sentence_boundary(&st, pos, abbreviations, require_sentence_capital) {
1465 pos = next_sentence_start(cut);
1468 cuts.push(cut);
1469 continue;
1470 }
1471
1472 pos += 1;
1473 }
1474
1475 let mut sentences = Vec::new();
1476 let mut sentence_start = 0;
1478 for &cut in &cuts {
1479 if let Some(range) = trim_range(text, sentence_start, char_offsets[cut]) {
1485 sentences.push(range);
1486 }
1487 sentence_start = char_offsets[next_sentence_start(cut)];
1488 }
1489
1490 if let Some(range) = trim_range(text, sentence_start, text.len()) {
1492 sentences.push(range);
1493 }
1494 sentences
1495}
1496
1497fn sentence_structure(text: &str, defined_references: Option<&HashSet<String>>) -> NestedStructure {
1513 let holds_construct = text.contains(['`', '[', '<', '$']);
1518 let holds_emphasis = text.contains(['*', '_', '~']);
1519 if !holds_construct && !holds_emphasis {
1520 return NestedStructure {
1521 atomic: Vec::new(),
1522 markers: Vec::new(),
1523 marker_closers: Vec::new(),
1524 links: Vec::new(),
1525 code_spans: Vec::new(),
1526 };
1527 }
1528 nested_structure(text, defined_references, false)
1529}
1530
1531fn is_horizontal_rule(line: &str) -> bool {
1533 if line.len() < 3 {
1534 return false;
1535 }
1536
1537 let mut chars = line.chars();
1540 let Some(first_char) = chars.next() else {
1541 return false;
1542 };
1543 if first_char != '-' && first_char != '_' && first_char != '*' {
1544 return false;
1545 }
1546
1547 let mut non_space_count = 1usize; for c in chars {
1549 if c == ' ' {
1550 continue;
1551 }
1552 if c != first_char {
1553 return false;
1554 }
1555 non_space_count += 1;
1556 }
1557 non_space_count >= 3
1558}
1559
1560fn is_numbered_list_item(line: &str) -> bool {
1562 let mut chars = line.chars();
1563
1564 if !chars.next().is_some_and(char::is_numeric) {
1566 return false;
1567 }
1568
1569 while let Some(c) = chars.next() {
1571 if c == '.' {
1572 return chars.next() == Some(' ');
1575 }
1576 if !c.is_numeric() {
1577 return false;
1578 }
1579 }
1580
1581 false
1582}
1583
1584fn is_unordered_list_marker(s: &str) -> bool {
1586 matches!(s.as_bytes().first(), Some(b'-' | b'*' | b'+'))
1587 && !is_horizontal_rule(s)
1588 && (s.len() == 1 || s.as_bytes().get(1) == Some(&b' '))
1589}
1590
1591pub(crate) fn is_definition_list_marker(line: &str) -> bool {
1619 let trimmed = line.trim_start();
1620 trimmed.starts_with(':') && calculate_indentation_width_default(&line[..line.len() - trimmed.len()]) <= 3
1621}
1622
1623pub(crate) fn is_self_contained_display_math_line(line: &str) -> bool {
1645 let trimmed = line.trim();
1646 let inner = crate::utils::blockquote::parse_blockquote_prefix(trimmed).map_or(trimmed, |p| p.content.trim());
1647 let inner = inner.strip_suffix('\\').map_or(inner, str::trim_end);
1648 inner.len() >= 4
1649 && inner.starts_with("$$")
1650 && inner.ends_with("$$")
1651 && inner[2..].find("$$") == Some(inner.len() - 4)
1652}
1653
1654pub(crate) fn lines_touching_multiline_code_span(text: &str) -> Vec<bool> {
1677 let line_count = text.lines().count();
1678 if !text.contains('`') || !text.contains("$$") {
1679 return vec![false; line_count];
1680 }
1681 let code_spans = crate::utils::parser_options::code_span_ranges_without_math(text);
1682 let mut spans = code_spans.iter().copied().peekable();
1683 let mut starts_inside = Vec::with_capacity(line_count);
1684 let mut line_start = 0;
1685 for line in text.split_inclusive('\n') {
1686 while spans.next_if(|&(_, end)| end <= line_start).is_some() {}
1687 starts_inside.push(spans.peek().is_some_and(|&(start, _)| start < line_start));
1688 line_start += line.len();
1689 }
1690 (0..line_count)
1691 .map(|i| starts_inside[i] || starts_inside.get(i + 1).is_some_and(|&b| b))
1692 .collect()
1693}
1694
1695pub(crate) fn lines_ending_in_code_span(lines: &[&str]) -> Vec<bool> {
1701 if !lines.iter().any(|line| line.contains('`')) {
1702 return vec![false; lines.len()];
1703 }
1704 let text = lines.join("\n");
1705 let code_spans = nested_structure(&text, None, false).code_spans;
1706 let mut spans = code_spans.iter().copied().peekable();
1707 let mut line_end = 0;
1708 lines
1709 .iter()
1710 .map(|line| {
1711 line_end += line.len();
1712 while spans.next_if(|&(_, end)| end <= line_end).is_some() {}
1713 let inside = spans.peek().is_some_and(|&(start, _)| start <= line_end);
1714 line_end += 1;
1715 inside
1716 })
1717 .collect()
1718}
1719
1720fn has_block_line_above(lines: &[&str], index: usize) -> bool {
1729 let Some(previous) = index.checked_sub(1).map(|above| lines[above].trim()) else {
1730 return false;
1731 };
1732 !(previous.is_empty()
1733 || previous.starts_with('#')
1734 || previous.starts_with("```")
1735 || previous.starts_with("~~~")
1736 || previous.starts_with(":::")
1737 || is_horizontal_rule(previous))
1738}
1739
1740fn is_block_boundary_core(trimmed: &str) -> bool {
1743 trimmed.is_empty()
1744 || trimmed.starts_with('#')
1745 || trimmed.starts_with("```")
1746 || trimmed.starts_with("~~~")
1747 || trimmed.starts_with('>')
1748 || (trimmed.starts_with('[') && trimmed.contains("]:"))
1749 || is_horizontal_rule(trimmed)
1750 || is_unordered_list_marker(trimmed)
1751 || is_numbered_list_item(trimmed)
1752 || is_definition_list_marker(trimmed)
1753 || trimmed.starts_with(":::")
1754}
1755
1756fn is_block_boundary(trimmed: &str) -> bool {
1759 is_block_boundary_core(trimmed) || trimmed.starts_with('|')
1760}
1761
1762fn is_paragraph_boundary(trimmed: &str, line: &str) -> bool {
1766 is_block_boundary_core(trimmed)
1767 || calculate_indentation_width_default(line) >= 4
1768 || crate::utils::table_utils::TableUtils::is_potential_table_row(line)
1769}
1770
1771fn has_hard_break(line: &str) -> bool {
1777 let line = line.strip_suffix('\r').unwrap_or(line);
1778 line.ends_with(" ") || line.ends_with('\\')
1779}
1780
1781struct SoftBreakJoin {
1783 joined: String,
1784 joins: Vec<usize>,
1786 code_spans: Vec<(usize, usize)>,
1788}
1789
1790impl SoftBreakJoin {
1791 fn new<S: AsRef<str>>(lines: &[S]) -> Self {
1792 let mut joined = String::new();
1793 let mut joins = Vec::with_capacity(lines.len().saturating_sub(1));
1794 for (idx, line) in lines.iter().enumerate() {
1795 let line = line.as_ref();
1796 if idx + 1 == lines.len() {
1797 joined.push_str(line);
1798 } else {
1799 joined.push_str(line.strip_suffix('\r').unwrap_or(line));
1800 joins.push(joined.len());
1801 joined.push(' ');
1802 }
1803 }
1804 let code_spans = if !joins.is_empty() && joined.contains('`') {
1805 nested_structure(&joined, None, false).code_spans
1806 } else {
1807 Vec::new()
1808 };
1809 Self {
1810 joined,
1811 joins,
1812 code_spans,
1813 }
1814 }
1815}
1816
1817pub(crate) fn code_span_runs_into_indentation<S: AsRef<str>>(lines: &[S]) -> bool {
1828 let SoftBreakJoin {
1829 joined,
1830 joins,
1831 code_spans,
1832 } = SoftBreakJoin::new(lines);
1833 joins.iter().enumerate().any(|(k, &join)| {
1834 let next_line = &joined[join + 1..joins.get(k + 1).copied().unwrap_or(joined.len())];
1835 next_line.starts_with([' ', '\t']) && code_spans.iter().any(|&(start, end)| start <= join && join < end)
1836 })
1837}
1838
1839pub(crate) fn code_span_crosses_last_break<S: AsRef<str>>(lines: &[S]) -> bool {
1842 let SoftBreakJoin { joins, code_spans, .. } = SoftBreakJoin::new(lines);
1843 joins
1844 .last()
1845 .is_some_and(|&join| code_spans.iter().any(|&(start, end)| start <= join && join < end))
1846}
1847
1848pub(crate) fn join_soft_break_lines<S: AsRef<str>>(lines: &[S], cjk: CjkSoftBreak) -> String {
1873 join_soft_break_lines_with_sentences(lines, cjk, None)
1874}
1875
1876pub(crate) fn join_sentence_lines<S: AsRef<str>>(
1880 lines: &[S],
1881 cjk: CjkSoftBreak,
1882 abbreviations: &Option<Vec<String>>,
1883) -> String {
1884 join_soft_break_lines_with_sentences(lines, cjk, Some(abbreviations))
1885}
1886
1887fn join_reflow_lines<S: AsRef<str>>(lines: &[S], options: &ReflowOptions) -> String {
1888 if options.sentence_per_line {
1889 join_sentence_lines(lines, options.cjk_soft_break, &options.abbreviations)
1890 } else {
1891 join_soft_break_lines(lines, options.cjk_soft_break)
1892 }
1893}
1894
1895fn join_soft_break_lines_with_sentences<S: AsRef<str>>(
1896 lines: &[S],
1897 cjk: CjkSoftBreak,
1898 sentence_abbreviations: Option<&Option<Vec<String>>>,
1899) -> String {
1900 let SoftBreakJoin {
1901 mut joined,
1902 joins,
1903 code_spans,
1904 } = SoftBreakJoin::new(lines);
1905 if joins.is_empty() {
1906 return joined;
1907 }
1908 let sentence_ends = sentence_abbreviations.map(|abbreviations| {
1909 for &join in &joins {
1912 joined.replace_range(join..=join, "\n");
1913 }
1914 split_into_sentence_ranges(&joined, &get_abbreviations(abbreviations), true, cjk, None, None)
1915 .into_iter()
1916 .map(|(_, end)| end)
1917 .collect::<HashSet<_>>()
1918 });
1919 let mut trimmed = String::with_capacity(joined.len());
1925 let mut copied = 0;
1926 let mut space_pending = false;
1927 let mut sentence_break_pending = false;
1928 let mut spans = code_spans.iter().copied().peekable();
1929 for (k, &join) in joins.iter().enumerate() {
1930 while spans.next_if(|&(_, end)| end <= join).is_some() {}
1931 let inside_code_span = spans.peek().is_some_and(|&(start, _)| start <= join);
1932 let content_end = if inside_code_span {
1933 join
1934 } else {
1935 joined[..join].trim_end_matches([' ', '\t']).len().max(copied)
1936 };
1937 let wrote_text = content_end > copied;
1938 if wrote_text {
1939 if space_pending {
1940 trimmed.push(if sentence_break_pending { '\n' } else { ' ' });
1941 }
1942 trimmed.push_str(&joined[copied..content_end]);
1943 }
1944 let next_line = &joined[join + 1..joins.get(k + 1).copied().unwrap_or(joined.len())];
1945 let next_content = if inside_code_span {
1946 next_line
1947 } else {
1948 next_line.trim_start_matches([' ', '\t'])
1949 };
1950 copied = join + 1 + (next_line.len() - next_content.len());
1951 let break_removed = !inside_code_span
1952 && cjk == CjkSoftBreak::Join
1953 && joined[..content_end]
1954 .chars()
1955 .next_back()
1956 .is_some_and(joins_cjk_soft_break)
1957 && next_content.chars().next().is_some_and(joins_cjk_soft_break);
1958 space_pending = !break_removed || (space_pending && !wrote_text);
1959 sentence_break_pending = sentence_ends.as_ref().is_some_and(|ends| ends.contains(&content_end));
1960 }
1961 if space_pending {
1962 trimmed.push(if sentence_break_pending { '\n' } else { ' ' });
1963 }
1964 trimmed.push_str(&joined[copied..]);
1965 trimmed
1966}
1967
1968fn ends_with_sentence_punct(text: &str) -> bool {
1970 text.ends_with('.') || text.ends_with('!') || text.ends_with('?')
1971}
1972
1973fn ends_cjk_sentence_with_closer(text: &str) -> bool {
1980 let stripped = text.trim_end_matches(|c| is_closing_bracket(c) || is_closing_quote(c));
1981 stripped.len() < text.len() && stripped.chars().next_back().is_some_and(is_cjk_sentence_ending)
1982}
1983
1984fn trim_preserving_hard_break(s: &str) -> String {
1990 let s = s.strip_suffix('\r').unwrap_or(s);
1992
1993 if s.ends_with('\\') {
1995 return s.to_string();
1997 }
1998
1999 if s.ends_with(" ") {
2001 let content_end = s.trim_end().len();
2003 if content_end == 0 {
2004 return String::new();
2006 }
2007 format!("{} ", &s[..content_end])
2009 } else {
2010 s.trim_end().to_string()
2012 }
2013}
2014
2015fn parse_elements(text: &str, options: &ReflowOptions) -> Vec<Element> {
2017 parse_markdown_elements_inner(
2018 text,
2019 options.attr_lists,
2020 options.myst_roles,
2021 options.defined_references.as_ref(),
2022 )
2023}
2024
2025pub fn reflow_line(line: &str, options: &ReflowOptions) -> Vec<String> {
2035 let reflowed = reflow_line_unchecked(line, options);
2036 if preserves_content(line, &reflowed) {
2037 reflowed
2038 } else {
2039 vec![line.to_string()]
2040 }
2041}
2042
2043fn preserves_content(original: &str, reflowed: &[String]) -> bool {
2050 let (original_text, original_breaks) = visible_text_and_breaks(original.chars());
2051 let (reflowed_text, reflowed_breaks) =
2052 visible_text_and_breaks(reflowed.iter().flat_map(|line| line.chars().chain(['\n'])));
2053
2054 original_text == reflowed_text && contains_all(&reflowed_breaks, &original_breaks)
2055}
2056
2057fn visible_text_and_breaks(text: impl Iterator<Item = char>) -> (String, Vec<usize>) {
2060 let mut visible = String::new();
2061 let mut breaks = Vec::new();
2062 let mut count = 0usize;
2063 let mut pending_break = false;
2064
2065 for c in text {
2066 if c.is_whitespace() {
2067 pending_break = count > 0;
2068 } else {
2069 if pending_break {
2070 breaks.push(count);
2071 pending_break = false;
2072 }
2073 visible.push(c);
2074 count += 1;
2075 }
2076 }
2077
2078 (visible, breaks)
2079}
2080
2081fn contains_all(superset: &[usize], subset: &[usize]) -> bool {
2083 let mut candidates = superset.iter();
2084 subset
2085 .iter()
2086 .all(|wanted| candidates.by_ref().any(|found| found == wanted))
2087}
2088
2089fn reflow_line_unchecked(line: &str, options: &ReflowOptions) -> Vec<String> {
2090 let lines = keep_first_line_out_of_backtick_fence(line, reflow_lines(line, options));
2091 keep_first_line_out_of_html_block(lines)
2092}
2093
2094fn keep_first_line_out_of_backtick_fence(source: &str, mut lines: Vec<String>) -> Vec<String> {
2099 let first_source = source.lines().next().unwrap_or("").trim_start_matches([' ', '\t']);
2100 let ticks = first_source.bytes().take_while(|&b| b == b'`').count();
2101 if ticks < 3 || !first_source[ticks..].contains('`') {
2102 return lines;
2103 }
2104 while lines.len() > 1 {
2105 let first = lines[0].trim_start_matches([' ', '\t']);
2106 let ticks = first.bytes().take_while(|&b| b == b'`').count();
2107 if ticks < 3 || first[ticks..].contains('`') {
2108 break;
2109 }
2110 let next = lines.remove(1);
2111 lines[0].push(' ');
2112 lines[0].push_str(next.trim_start_matches([' ', '\t']));
2113 }
2114 lines
2115}
2116
2117fn reflow_lines(line: &str, options: &ReflowOptions) -> Vec<String> {
2118 if options.sentence_pack {
2119 let elements = parse_elements(line, options);
2120 return reflow_elements_sentence_pack(&elements, options);
2121 }
2122
2123 if options.sentence_per_line {
2125 let elements = parse_elements(line, options);
2126 return merge_block_construct_continuations(reflow_elements_sentence_per_line(&elements, options));
2127 }
2128
2129 if options.semantic_line_breaks {
2131 let elements = parse_elements(line, options);
2132 return merge_block_construct_continuations(reflow_elements_semantic(&elements, options));
2133 }
2134
2135 if options.line_length == 0 || line_fits(line, options) {
2138 return vec![line.to_string()];
2139 }
2140
2141 let elements = parse_elements(line, options);
2143
2144 merge_block_construct_continuations(reflow_elements(&elements, options))
2146}
2147
2148#[derive(Debug, Clone)]
2150enum Element {
2151 Text(String),
2153 Link(String),
2155 ReferenceLink(String),
2157 EmptyReferenceLink(String),
2159 ShortcutReference(String),
2161 InlineImage(String),
2163 ReferenceImage(String),
2165 EmptyReferenceImage(String),
2167 LinkedImage(String),
2169 FootnoteReference(String),
2171 Strikethrough {
2173 content: String,
2174 double: bool,
2176 },
2177 WikiLink(String),
2179 InlineMath(String),
2181 DisplayMath(String),
2183 EmojiShortcode(String),
2185 Autolink(String),
2187 HtmlTag(String),
2189 HtmlEntity(String),
2191 HugoShortcode(String),
2193 AttrList(String),
2195 MystRole(String),
2199 Code { content: String, marker: String },
2201 Bold {
2203 content: String,
2204 underscore: bool,
2206 },
2207 Italic {
2209 content: String,
2210 underscore: bool,
2212 },
2213}
2214
2215impl Element {
2216 fn opens_with_bracket(&self) -> bool {
2221 matches!(
2222 self,
2223 Element::Link(_)
2224 | Element::ReferenceLink(_)
2225 | Element::EmptyReferenceLink(_)
2226 | Element::ShortcutReference(_)
2227 | Element::FootnoteReference(_)
2228 | Element::InlineImage(_)
2229 | Element::ReferenceImage(_)
2230 | Element::EmptyReferenceImage(_)
2231 | Element::LinkedImage(_)
2232 | Element::WikiLink(_)
2233 )
2234 }
2235}
2236
2237impl std::fmt::Display for Element {
2238 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2239 match self {
2240 Element::Text(s) => write!(f, "{s}"),
2241 Element::Link(s) => write!(f, "{s}"),
2242 Element::ReferenceLink(s) => write!(f, "{s}"),
2243 Element::EmptyReferenceLink(s) => write!(f, "{s}"),
2244 Element::ShortcutReference(s) => write!(f, "{s}"),
2245 Element::InlineImage(s) => write!(f, "{s}"),
2246 Element::ReferenceImage(s) => write!(f, "{s}"),
2247 Element::EmptyReferenceImage(s) => write!(f, "{s}"),
2248 Element::LinkedImage(s) => write!(f, "{s}"),
2249 Element::FootnoteReference(s) => write!(f, "{s}"),
2250 Element::Strikethrough { content, double } => {
2251 let marker = if *double { "~~" } else { "~" };
2252 write!(f, "{marker}{content}{marker}")
2253 }
2254 Element::WikiLink(s) => write!(f, "[[{s}]]"),
2255 Element::InlineMath(s) => write!(f, "${s}$"),
2256 Element::DisplayMath(s) => write!(f, "$${s}$$"),
2257 Element::EmojiShortcode(s) => write!(f, ":{s}:"),
2258 Element::Autolink(s) => write!(f, "{s}"),
2259 Element::HtmlTag(s) => write!(f, "{s}"),
2260 Element::HtmlEntity(s) => write!(f, "{s}"),
2261 Element::HugoShortcode(s) => write!(f, "{s}"),
2262 Element::AttrList(s) => write!(f, "{s}"),
2263 Element::MystRole(s) => write!(f, "{s}"),
2264 Element::Code { content, marker } => write!(f, "{marker}{content}{marker}"),
2265 Element::Bold { content, underscore } => {
2266 if *underscore {
2267 write!(f, "__{content}__")
2268 } else {
2269 write!(f, "**{content}**")
2270 }
2271 }
2272 Element::Italic { content, underscore } => {
2273 if *underscore {
2274 write!(f, "_{content}_")
2275 } else {
2276 write!(f, "*{content}*")
2277 }
2278 }
2279 }
2280 }
2281}
2282
2283impl Element {
2284 fn display_len(&self, mode: ReflowLengthMode) -> usize {
2285 match self {
2286 Element::Text(s)
2287 | Element::Link(s)
2288 | Element::ReferenceLink(s)
2289 | Element::EmptyReferenceLink(s)
2290 | Element::ShortcutReference(s)
2291 | Element::InlineImage(s)
2292 | Element::ReferenceImage(s)
2293 | Element::EmptyReferenceImage(s)
2294 | Element::LinkedImage(s)
2295 | Element::FootnoteReference(s)
2296 | Element::Autolink(s)
2297 | Element::HtmlTag(s)
2298 | Element::HtmlEntity(s)
2299 | Element::HugoShortcode(s)
2300 | Element::AttrList(s)
2301 | Element::MystRole(s) => display_len(s, mode),
2302 Element::WikiLink(s) => display_len(s, mode) + 4,
2303 Element::InlineMath(s) => display_len(s, mode) + 2,
2304 Element::DisplayMath(s) => display_len(s, mode) + 4,
2305 Element::EmojiShortcode(s) => display_len(s, mode) + 2,
2306 Element::Strikethrough { content, double } => display_len(content, mode) + if *double { 4 } else { 2 },
2307 Element::Code { content, marker } => display_len(content, mode) + display_len(marker, mode) * 2,
2308 Element::Bold { content, .. } => display_len(content, mode) + 4,
2309 Element::Italic { content, .. } => display_len(content, mode) + 2,
2310 }
2311 }
2312
2313 fn exempt_width(&self, mode: ReflowLengthMode, exemptions: LengthExemptions) -> LineWidth {
2324 let full = self.display_len(mode);
2325 let mut width = LineWidth::plain(full);
2326 match self {
2327 Element::Link(s) | Element::LinkedImage(s) if exemptions.link_urls => {
2328 if let Some(text) = bracketed_text(s, 0) {
2329 width.link_exempt = (2 + display_len(text, mode)).min(full);
2330 }
2331 }
2332 Element::InlineImage(s) if exemptions.link_urls => {
2333 if let Some(alt) = bracketed_text(s, 1) {
2334 width.link_exempt = (3 + display_len(alt, mode)).min(full);
2335 }
2336 }
2337 Element::Code { .. } if exemptions.code_spans => width.code_exempt = 0,
2338 _ => {}
2339 }
2340 width
2341 }
2342}
2343
2344pub(crate) fn bracketed_text(s: &str, open: usize) -> Option<&str> {
2351 let bytes = s.as_bytes();
2352 if bytes.get(open) != Some(&b'[') {
2353 return None;
2354 }
2355 let mut depth = 0usize;
2356 let mut in_code_span = false;
2357 let mut escaped = false;
2358 for (i, &byte) in bytes.iter().enumerate().skip(open + 1) {
2359 if escaped {
2360 escaped = false;
2361 continue;
2362 }
2363 match byte {
2364 b'\\' => escaped = true,
2365 b'`' => in_code_span = !in_code_span,
2366 b'[' if !in_code_span => depth += 1,
2367 b']' if !in_code_span => match depth.checked_sub(1) {
2368 Some(next) => depth = next,
2369 None => return s.get(open + 1..i),
2370 },
2371 _ => {}
2372 }
2373 }
2374 None
2375}
2376
2377#[derive(Debug, Clone)]
2379struct EmphasisSpan {
2380 start: usize,
2382 end: usize,
2384 content: String,
2386 is_strong: bool,
2388 is_strikethrough: bool,
2390 uses_underscore: bool,
2392 strikethrough_double: bool,
2395}
2396
2397fn extract_emphasis_and_code_spans(text: &str) -> (Vec<EmphasisSpan>, Vec<CodeSpan>) {
2407 let has_emphasis = text.contains(['*', '_', '~']);
2409 let has_code = text.contains('`');
2410 if !has_emphasis && !has_code {
2411 return (Vec::new(), Vec::new());
2412 }
2413
2414 let mut emphasis_spans = Vec::new();
2415 let mut code_spans = Vec::new();
2416
2417 let mut options = Options::empty();
2418 if has_emphasis {
2419 options.insert(Options::ENABLE_STRIKETHROUGH);
2420 }
2421
2422 let mut emphasis_stack: Vec<(usize, bool)> = Vec::new(); let mut strong_stack: Vec<(usize, bool)> = Vec::new();
2425 let mut strikethrough_stack: Vec<usize> = Vec::new();
2426
2427 let parser = Parser::new_ext(text, options).into_offset_iter();
2428
2429 for (event, range) in parser {
2430 match event {
2431 Event::Code(_) => {
2432 code_spans.push(CodeSpan {
2433 start: range.start,
2434 end: range.end,
2435 });
2436 }
2437 Event::Start(Tag::Emphasis) => {
2438 let uses_underscore = text.get(range.start..range.start + 1) == Some("_");
2440 emphasis_stack.push((range.start, uses_underscore));
2441 }
2442 Event::End(TagEnd::Emphasis) => {
2443 if let Some((start_byte, uses_underscore)) = emphasis_stack.pop() {
2444 let content_start = start_byte + 1;
2445 let content_end = range.end - 1;
2446 if content_end > content_start
2447 && let Some(content) = text.get(content_start..content_end)
2448 {
2449 emphasis_spans.push(EmphasisSpan {
2450 start: start_byte,
2451 end: range.end,
2452 content: content.to_string(),
2453 is_strong: false,
2454 is_strikethrough: false,
2455 uses_underscore,
2456 strikethrough_double: false,
2457 });
2458 }
2459 }
2460 }
2461 Event::Start(Tag::Strong) => {
2462 let uses_underscore = text.get(range.start..range.start + 2) == Some("__");
2463 strong_stack.push((range.start, uses_underscore));
2464 }
2465 Event::End(TagEnd::Strong) => {
2466 if let Some((start_byte, uses_underscore)) = strong_stack.pop() {
2467 let content_start = start_byte + 2;
2468 let content_end = range.end - 2;
2469 if content_end > content_start
2470 && let Some(content) = text.get(content_start..content_end)
2471 {
2472 emphasis_spans.push(EmphasisSpan {
2473 start: start_byte,
2474 end: range.end,
2475 content: content.to_string(),
2476 is_strong: true,
2477 is_strikethrough: false,
2478 uses_underscore,
2479 strikethrough_double: false,
2480 });
2481 }
2482 }
2483 }
2484 Event::Start(Tag::Strikethrough) => {
2485 strikethrough_stack.push(range.start);
2486 }
2487 Event::End(TagEnd::Strikethrough) => {
2488 if let Some(start_byte) = strikethrough_stack.pop() {
2489 let double = text.get(start_byte..start_byte + 2) == Some("~~");
2490 let marker_len = if double { 2 } else { 1 };
2491 let content_start = start_byte + marker_len;
2492 let content_end = range.end - marker_len;
2493 if content_end > content_start
2494 && let Some(content) = text.get(content_start..content_end)
2495 {
2496 emphasis_spans.push(EmphasisSpan {
2497 start: start_byte,
2498 end: range.end,
2499 content: content.to_string(),
2500 is_strong: false,
2501 is_strikethrough: true,
2502 uses_underscore: false,
2503 strikethrough_double: double,
2504 });
2505 }
2506 }
2507 }
2508 _ => {}
2509 }
2510 }
2511
2512 emphasis_spans.sort_by_key(|s| s.start);
2513 (emphasis_spans, code_spans)
2514}
2515
2516#[derive(Debug, Clone)]
2517struct CodeSpan {
2518 start: usize,
2519 end: usize,
2520}
2521
2522#[derive(Debug, Clone)]
2523struct LinkSpan {
2524 start: usize,
2525 end: usize,
2526 link_type: Option<LinkType>,
2527 is_image: bool,
2528 is_footnote: bool,
2529 depth: usize,
2532}
2533
2534fn extract_link_spans(text: &str, defined_references: Option<&HashSet<String>>) -> Vec<LinkSpan> {
2538 let mut spans = all_link_spans(text, defined_references);
2539 spans.retain(|span| span.depth == 0);
2540 spans
2541}
2542
2543fn all_link_spans(text: &str, defined_references: Option<&HashSet<String>>) -> Vec<LinkSpan> {
2546 if !text.contains('[') {
2549 return Vec::new();
2550 }
2551
2552 let mut spans = Vec::new();
2553 let mut options = Options::empty();
2554 options.insert(Options::ENABLE_FOOTNOTES);
2555
2556 let resolve = move |link: BrokenLink<'_>| -> Option<(CowStr<'_>, CowStr<'_>)> {
2573 let atomic = match link.link_type {
2578 LinkType::Shortcut | LinkType::ShortcutUnknown => match defined_references {
2579 Some(defs) => defs.contains(&normalize_reference_label(link.reference.as_ref())),
2580 None => true,
2581 },
2582 _ => true,
2583 };
2584 atomic.then_some((CowStr::Borrowed(""), CowStr::Borrowed("")))
2585 };
2586 let parser = Parser::new_with_broken_link_callback(text, options, Some(resolve)).into_offset_iter();
2587 let mut stack = Vec::new();
2588
2589 for (event, range) in parser {
2590 match event {
2591 Event::Start(Tag::Link { link_type, .. }) => {
2592 stack.push((range.start, Some(link_type), false));
2593 }
2594 Event::Start(Tag::Image { link_type, .. }) => {
2595 stack.push((range.start, Some(link_type), true));
2596 }
2597 Event::End(TagEnd::Link | TagEnd::Image) => {
2598 if let Some((start_byte, link_type, is_image)) = stack.pop() {
2599 let mut end = range.end;
2600 if matches!(link_type, Some(LinkType::Collapsed) | Some(LinkType::CollapsedUnknown))
2601 && text[end..].starts_with("[]")
2602 {
2603 end += 2;
2604 }
2605 spans.push(LinkSpan {
2606 start: start_byte,
2607 end,
2608 link_type,
2609 is_image,
2610 is_footnote: false,
2611 depth: stack.len(),
2612 });
2613 }
2614 }
2615 Event::FootnoteReference(_) => {
2616 spans.push(LinkSpan {
2617 start: range.start,
2618 end: range.end,
2619 link_type: None,
2620 is_image: false,
2621 is_footnote: true,
2622 depth: stack.len(),
2623 });
2624 }
2625 _ => {}
2626 }
2627 }
2628
2629 spans.sort_by_key(|s| s.start);
2630 spans
2631}
2632
2633fn myst_role_len_at(text: &str, absolute_pos: usize, code_spans: &[CodeSpan]) -> Option<usize> {
2641 let bytes = text.as_bytes();
2642 if bytes.first() != Some(&b'{') {
2643 return None;
2644 }
2645
2646 let mut j = 1;
2648 match bytes.get(j) {
2649 Some(&b) if b.is_ascii_alphabetic() || b == b'_' => {}
2650 _ => return None,
2651 }
2652 while let Some(&b) = bytes.get(j) {
2653 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b':' | b'.') {
2654 j += 1;
2655 } else {
2656 break;
2657 }
2658 }
2659 if bytes.get(j) != Some(&b'}') {
2660 return None;
2661 }
2662 j += 1; let code_span_start = absolute_pos + j;
2666 if let Ok(idx) = code_spans.binary_search_by_key(&code_span_start, |span| span.start) {
2667 let span = &code_spans[idx];
2668 let code_span_len = span.end - span.start;
2669 return Some(j + code_span_len);
2670 }
2671
2672 None
2673}
2674
2675fn inline_math_len_at_start(s: &str) -> Option<usize> {
2682 let bytes = s.as_bytes();
2683 if bytes.first() != Some(&b'$') || bytes.get(1) == Some(&b'$') {
2685 return None;
2686 }
2687 let close = 1 + s[1..].find('$')?;
2690 if bytes.get(close + 1) == Some(&b'$') {
2692 return None;
2693 }
2694 Some(close + 1)
2695}
2696
2697#[derive(Clone, Copy, Debug)]
2699struct PatternMatch {
2700 start: usize,
2701 end: usize,
2702}
2703
2704fn inline_html_comment(text: &str) -> Option<(usize, usize)> {
2710 let start = text.find("<!--")?;
2711 let body = &text[start + 4..];
2712 let end = if body.starts_with('>') {
2713 start + 5
2714 } else if body.starts_with("->") {
2715 start + 6
2716 } else {
2717 start + 4 + body.find("-->")? + 3
2718 };
2719 Some((start, end))
2720}
2721
2722#[derive(Clone, Copy)]
2736enum PatternCache {
2737 Unsearched,
2738 NotFound,
2739 Found(PatternMatch),
2740}
2741
2742impl PatternCache {
2743 fn earliest_in(
2747 &mut self,
2748 remaining: &str,
2749 cursor: usize,
2750 find: impl FnOnce(&str) -> Option<(usize, usize)>,
2751 ) -> Option<(usize, usize)> {
2752 let stale = match self {
2753 PatternCache::Found(pm) => pm.start < cursor,
2754 PatternCache::NotFound => false,
2755 PatternCache::Unsearched => true,
2756 };
2757 if stale {
2758 *self = match find(remaining) {
2759 Some((start, end)) => PatternCache::Found(PatternMatch {
2760 start: cursor + start,
2761 end: cursor + end,
2762 }),
2763 None => PatternCache::NotFound,
2764 };
2765 }
2766 match self {
2767 PatternCache::Found(pm) => Some((pm.start - cursor, pm.end - cursor)),
2768 _ => None,
2769 }
2770 }
2771}
2772
2773fn parse_markdown_elements_inner(
2784 text: &str,
2785 attr_lists: bool,
2786 myst_roles: bool,
2787 defined_references: Option<&HashSet<String>>,
2788) -> Vec<Element> {
2789 let mut elements = Vec::new();
2790 let mut remaining = text;
2791
2792 let (emphasis_spans, code_spans) = extract_emphasis_and_code_spans(text);
2797 let link_spans = extract_link_spans(text, defined_references);
2798
2799 let mut cached_wiki_link = PatternCache::Unsearched;
2802 let mut cached_display_math = PatternCache::Unsearched;
2803 let mut cached_inline_math = PatternCache::Unsearched;
2804 let mut cached_emoji = PatternCache::Unsearched;
2805 let mut cached_html_entity = PatternCache::Unsearched;
2806 let mut cached_hugo_shortcode = PatternCache::Unsearched;
2807 let mut cached_html_tag = PatternCache::Unsearched;
2808 let mut cached_next_curly = PatternCache::Unsearched;
2809
2810 let mut link_span_idx = 0usize;
2814 let mut emphasis_span_idx = 0usize;
2815 let mut code_span_idx = 0usize;
2816
2817 while !remaining.is_empty() {
2818 let current_offset = text.len() - remaining.len();
2820 let mut earliest_match: Option<(usize, usize, &str)> = None;
2823
2824 while link_span_idx < link_spans.len() && link_spans[link_span_idx].start < current_offset {
2826 link_span_idx += 1;
2827 }
2828 let next_link: Option<&LinkSpan> = link_spans.get(link_span_idx);
2829
2830 if let Some(span) = next_link {
2831 let pos_in_remaining = span.start - current_offset;
2832 if earliest_match
2833 .as_ref()
2834 .is_none_or(|(start, _, _)| pos_in_remaining < *start)
2835 {
2836 let match_end = span.end - current_offset;
2837 earliest_match = Some((pos_in_remaining, match_end, "link_span"));
2838 }
2839 }
2840
2841 if let Some((start, end)) = cached_wiki_link.earliest_in(remaining, current_offset, |suffix| {
2843 WIKI_LINK_REGEX.find(suffix).map(|m| (m.start(), m.end()))
2844 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
2845 {
2846 earliest_match = Some((start, end, "wiki_link"));
2847 }
2848
2849 if let Some((start, end)) = cached_display_math.earliest_in(remaining, current_offset, |suffix| {
2851 DISPLAY_MATH_REGEX.find(suffix).map(|m| (m.start(), m.end()))
2852 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
2853 {
2854 earliest_match = Some((start, end, "display_math"));
2855 }
2856
2857 let inline_math_probe = if current_offset > 0 && text.as_bytes()[current_offset - 1] == b'$' {
2871 inline_math_len_at_start(remaining).map(|len| (0, len))
2872 } else {
2873 None
2874 };
2875 if let Some((start, end)) = inline_math_probe.or_else(|| {
2876 cached_inline_math.earliest_in(remaining, current_offset, |suffix| {
2877 INLINE_MATH_REGEX
2878 .find(suffix)
2879 .ok()
2880 .flatten()
2881 .map(|m| (m.start(), m.end()))
2882 })
2883 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
2884 {
2885 earliest_match = Some((start, end, "inline_math"));
2886 }
2887
2888 if let Some((start, end)) = cached_emoji.earliest_in(remaining, current_offset, |suffix| {
2890 EMOJI_SHORTCODE_REGEX.find(suffix).map(|m| (m.start(), m.end()))
2891 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
2892 {
2893 earliest_match = Some((start, end, "emoji"));
2894 }
2895
2896 if let Some((start, end)) = cached_html_entity.earliest_in(remaining, current_offset, |suffix| {
2898 HTML_ENTITY_REGEX.find(suffix).map(|m| (m.start(), m.end()))
2899 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
2900 {
2901 earliest_match = Some((start, end, "html_entity"));
2902 }
2903
2904 if let Some((start, end)) = cached_hugo_shortcode.earliest_in(remaining, current_offset, |suffix| {
2907 crate::utils::shortcode_utils::shortcode_ranges(suffix).next()
2908 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
2909 {
2910 earliest_match = Some((start, end, "hugo_shortcode"));
2911 }
2912
2913 if let Some((start, end)) = cached_html_tag.earliest_in(remaining, current_offset, |suffix| {
2921 let comment = inline_html_comment(suffix);
2922 let mut from = 0;
2923 while let Some(m) = HTML_TAG_PATTERN.find(&suffix[from..]) {
2924 let (tag_start, tag_end) = (from + m.start(), from + m.end());
2925 let tag = &suffix[tag_start..tag_end];
2926 let is_url_autolink = tag.starts_with("<http://")
2928 || tag.starts_with("<https://")
2929 || tag.starts_with("<mailto:")
2930 || tag.starts_with("<ftp://")
2931 || tag.starts_with("<ftps://");
2932 let is_email_autolink = {
2935 let content = tag.trim_start_matches('<').trim_end_matches('>');
2936 EMAIL_PATTERN.is_match(content)
2937 };
2938 if is_url_autolink || is_email_autolink {
2939 from = tag_end;
2940 } else {
2941 return match comment {
2942 Some(comment) if comment.0 < tag_start => Some(comment),
2943 _ => Some((tag_start, tag_end)),
2944 };
2945 }
2946 }
2947 comment
2948 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
2949 {
2950 earliest_match = Some((start, end, "html_tag"));
2951 }
2952
2953 let mut next_special = remaining.len();
2955 let mut special_type = "";
2956 let mut pulldown_emphasis: Option<&EmphasisSpan> = None;
2957 let mut attr_list_len: usize = 0;
2958 let mut myst_role_len: usize = 0;
2959
2960 while code_span_idx < code_spans.len() && code_spans[code_span_idx].start < current_offset {
2962 code_span_idx += 1;
2963 }
2964 let next_code_span: Option<&CodeSpan> = code_spans.get(code_span_idx);
2965 if let Some(span) = next_code_span {
2966 let pos_in_remaining = span.start - current_offset;
2967 if pos_in_remaining < next_special {
2968 next_special = pos_in_remaining;
2969 special_type = "pulldown_code";
2970 }
2971 }
2972
2973 let next_curly_pos = cached_next_curly
2976 .earliest_in(remaining, current_offset, |suffix| {
2977 suffix.find('{').map(|pos| (pos, pos + 1))
2978 })
2979 .map(|(start, _)| start);
2980
2981 if myst_roles
2986 && let Some(pos) = next_curly_pos
2987 && pos < next_special
2988 && let Some(role_len) = myst_role_len_at(&remaining[pos..], current_offset + pos, &code_spans)
2989 {
2990 next_special = pos;
2991 special_type = "myst_role";
2992 myst_role_len = role_len;
2993 }
2994
2995 if attr_lists
2997 && let Some(pos) = next_curly_pos
2998 && pos < next_special
2999 && let Some(m) = ATTR_LIST_PATTERN.find(&remaining[pos..])
3000 && m.start() == 0
3001 {
3002 next_special = pos;
3003 special_type = "attr_list";
3004 attr_list_len = m.end();
3005 }
3006
3007 while emphasis_span_idx < emphasis_spans.len() && emphasis_spans[emphasis_span_idx].start < current_offset {
3009 emphasis_span_idx += 1;
3010 }
3011 if let Some(span) = emphasis_spans.get(emphasis_span_idx) {
3012 let pos_in_remaining = span.start - current_offset;
3013 if pos_in_remaining < next_special {
3014 next_special = pos_in_remaining;
3015 special_type = "pulldown_emphasis";
3016 pulldown_emphasis = Some(span);
3017 }
3018 }
3019
3020 let should_process_markdown_link = if let Some((pos, _, _)) = earliest_match {
3022 pos < next_special
3023 } else {
3024 false
3025 };
3026
3027 if should_process_markdown_link {
3028 let (pos, match_end, pattern_type) = earliest_match.unwrap();
3029
3030 if pos > 0 {
3032 elements.push(Element::Text(remaining[..pos].to_string()));
3033 }
3034
3035 match pattern_type {
3037 "link_span" => {
3038 let span = next_link.unwrap();
3039 let raw_text = remaining[pos..match_end].to_string();
3040 if span.is_footnote {
3041 elements.push(Element::FootnoteReference(raw_text));
3042 } else if span.is_image {
3043 match span.link_type {
3044 Some(LinkType::Inline) => elements.push(Element::InlineImage(raw_text)),
3045 Some(LinkType::Reference)
3048 | Some(LinkType::ReferenceUnknown)
3049 | Some(LinkType::Shortcut)
3050 | Some(LinkType::ShortcutUnknown) => elements.push(Element::ReferenceImage(raw_text)),
3051 Some(LinkType::Collapsed) | Some(LinkType::CollapsedUnknown) => {
3052 elements.push(Element::EmptyReferenceImage(raw_text))
3053 }
3054 _ => elements.push(Element::InlineImage(raw_text)),
3055 }
3056 } else {
3057 match span.link_type {
3058 Some(LinkType::Inline) => {
3059 if raw_text.starts_with('[') && raw_text.contains("![") {
3060 elements.push(Element::LinkedImage(raw_text));
3061 } else {
3062 elements.push(Element::Link(raw_text));
3063 }
3064 }
3065 Some(LinkType::Reference) | Some(LinkType::ReferenceUnknown) => {
3068 elements.push(Element::ReferenceLink(raw_text))
3069 }
3070 Some(LinkType::Collapsed) | Some(LinkType::CollapsedUnknown) => {
3071 elements.push(Element::EmptyReferenceLink(raw_text))
3072 }
3073 Some(LinkType::Shortcut) | Some(LinkType::ShortcutUnknown) => {
3074 elements.push(Element::ShortcutReference(raw_text))
3075 }
3076 Some(LinkType::Autolink) | Some(LinkType::Email) => {
3077 elements.push(Element::Autolink(raw_text))
3078 }
3079 _ => elements.push(Element::Link(raw_text)),
3080 }
3081 }
3082 remaining = &remaining[match_end..];
3083 }
3084 "wiki_link" => {
3085 if let Some(caps) = WIKI_LINK_REGEX.captures(remaining) {
3086 let content = caps.get(1).map_or("", |m| m.as_str());
3087 elements.push(Element::WikiLink(content.to_string()));
3088 remaining = &remaining[match_end..];
3089 } else {
3090 elements.push(Element::Text("[[".to_string()));
3091 remaining = &remaining[2..];
3092 }
3093 }
3094 "display_math" => {
3095 if let Some(caps) = DISPLAY_MATH_REGEX.captures(remaining) {
3096 let math = caps.get(1).map_or("", |m| m.as_str());
3097 elements.push(Element::DisplayMath(math.to_string()));
3098 remaining = &remaining[match_end..];
3099 } else {
3100 elements.push(Element::Text("$$".to_string()));
3101 remaining = &remaining[2..];
3102 }
3103 }
3104 "inline_math" => {
3105 if let Ok(Some(caps)) = INLINE_MATH_REGEX.captures(remaining) {
3106 let math = caps.get(1).map_or("", |m| m.as_str());
3107 elements.push(Element::InlineMath(math.to_string()));
3108 remaining = &remaining[match_end..];
3109 } else {
3110 elements.push(Element::Text("$".to_string()));
3111 remaining = &remaining[1..];
3112 }
3113 }
3114 "emoji" => {
3115 if let Some(caps) = EMOJI_SHORTCODE_REGEX.captures(remaining) {
3116 let emoji = caps.get(1).map_or("", |m| m.as_str());
3117 elements.push(Element::EmojiShortcode(emoji.to_string()));
3118 remaining = &remaining[match_end..];
3119 } else {
3120 elements.push(Element::Text(":".to_string()));
3121 remaining = &remaining[1..];
3122 }
3123 }
3124 "html_entity" => {
3125 elements.push(Element::HtmlEntity(remaining[pos..match_end].to_string()));
3127 remaining = &remaining[match_end..];
3128 }
3129 "hugo_shortcode" => {
3130 elements.push(Element::HugoShortcode(remaining[pos..match_end].to_string()));
3132 remaining = &remaining[match_end..];
3133 }
3134 "html_tag" => {
3135 elements.push(Element::HtmlTag(remaining[pos..match_end].to_string()));
3137 remaining = &remaining[match_end..];
3138 }
3139 _ => unreachable!("unknown pattern type: {}", pattern_type),
3140 }
3141 } else {
3142 if next_special > 0 && next_special < remaining.len() {
3146 elements.push(Element::Text(remaining[..next_special].to_string()));
3147 remaining = &remaining[next_special..];
3148 }
3149
3150 match special_type {
3152 "pulldown_code" => {
3153 let span = next_code_span.unwrap();
3154 let span_len = span.end - span.start;
3155 let code_raw = &remaining[..span_len];
3156 if let Some((content, marker)) = decompose_code_span(code_raw) {
3157 elements.push(Element::Code {
3158 content: content.to_string(),
3159 marker: marker.to_string(),
3160 });
3161 } else {
3162 elements.push(Element::Text(code_raw.to_string()));
3163 }
3164 remaining = &remaining[span_len..];
3165 }
3166 "attr_list" => {
3167 elements.push(Element::AttrList(remaining[..attr_list_len].to_string()));
3168 remaining = &remaining[attr_list_len..];
3169 }
3170 "myst_role" => {
3171 elements.push(Element::MystRole(remaining[..myst_role_len].to_string()));
3172 remaining = &remaining[myst_role_len..];
3173 }
3174 "pulldown_emphasis" => {
3175 let span = pulldown_emphasis.expect("pulldown_emphasis must be set");
3177 let span_len = span.end - span.start;
3178 if span.is_strikethrough {
3179 elements.push(Element::Strikethrough {
3180 content: span.content.clone(),
3181 double: span.strikethrough_double,
3182 });
3183 } else if span.is_strong {
3184 elements.push(Element::Bold {
3185 content: span.content.clone(),
3186 underscore: span.uses_underscore,
3187 });
3188 } else {
3189 elements.push(Element::Italic {
3190 content: span.content.clone(),
3191 underscore: span.uses_underscore,
3192 });
3193 }
3194 remaining = &remaining[span_len..];
3195 }
3196 _ => {
3197 elements.push(Element::Text(remaining.to_string()));
3199 break;
3200 }
3201 }
3202 }
3203 }
3204
3205 let mut merged_elements = Vec::new();
3207 for el in elements {
3208 match el {
3209 Element::Text(s) => {
3210 if let Some(Element::Text(last_s)) = merged_elements.last_mut() {
3211 last_s.push_str(&s);
3212 } else {
3213 merged_elements.push(Element::Text(s));
3214 }
3215 }
3216 other => merged_elements.push(other),
3217 }
3218 }
3219 merged_elements
3220}
3221
3222fn source_gap_before(elements: &[Element], idx: usize) -> &str {
3237 let Some(Element::Text(previous)) = idx.checked_sub(1).map(|prev| &elements[prev]) else {
3238 return "";
3239 };
3240
3241 let gap = &previous[previous.trim_end_matches(char::is_whitespace).len()..];
3242 if gap.is_empty() {
3243 ""
3244 } else if gap.contains(is_significant_whitespace) {
3245 gap
3246 } else {
3247 " "
3248 }
3249}
3250
3251fn push_source_gap(current_line: &mut String, gap: &str) {
3254 if !gap.is_empty() && !current_line.is_empty() && !current_line.ends_with(char::is_whitespace) {
3255 current_line.push_str(gap);
3256 }
3257}
3258
3259fn is_setext_or_thematic(text: &str) -> bool {
3265 let mut marker = 0u8;
3266 let mut count = 0usize;
3267 let mut has_space = false;
3268 for &b in text.as_bytes() {
3269 match b {
3270 b' ' | b'\t' => has_space = true,
3271 b'-' | b'=' | b'*' | b'_' => {
3272 if marker == 0 {
3273 marker = b;
3274 } else if b != marker {
3275 return false;
3276 }
3277 count += 1;
3278 }
3279 _ => return false,
3280 }
3281 }
3282 match marker {
3283 b'=' => !has_space,
3284 b'-' => !has_space || count >= 3,
3285 b'*' | b'_' => count >= 3,
3286 _ => false,
3287 }
3288}
3289
3290pub(crate) fn is_table_delimiter_row(text: &str) -> bool {
3297 let text = text.trim_matches([' ', '\t']);
3298 if !text.contains(['|', ':']) {
3299 return false;
3300 }
3301 let text = text.strip_prefix('|').unwrap_or(text);
3302 let text = text.strip_suffix('|').unwrap_or(text);
3303 text.split('|').all(|cell| {
3304 let cell = cell.trim_matches([' ', '\t']);
3305 let cell = cell.strip_prefix(':').unwrap_or(cell);
3306 let cell = cell.strip_suffix(':').unwrap_or(cell);
3307 !cell.is_empty() && cell.bytes().all(|b| b == b'-')
3308 })
3309}
3310
3311fn starts_block_construct(text: &str) -> bool {
3327 let text = text.trim_start();
3328 let bytes = text.as_bytes();
3329 let Some(&first) = bytes.first() else {
3330 return false;
3331 };
3332 let marker_then_boundary = |len: usize| bytes.len() == len || bytes[len] == b' ' || bytes[len] == b'\t';
3333 match first {
3334 b'>' => true,
3336 b'-' => marker_then_boundary(1) || is_setext_or_thematic(text) || is_table_delimiter_row(text),
3337 b'*' | b'+' => marker_then_boundary(1) || is_setext_or_thematic(text),
3338 b'_' | b'=' => is_setext_or_thematic(text),
3339 b':' => true,
3341 b'|' => true,
3342 b'#' => {
3343 let hashes = bytes.iter().take_while(|&&b| b == b'#').count();
3344 hashes <= 6 && marker_then_boundary(hashes)
3345 }
3346 b'`' => bytes.iter().take_while(|&&b| b == b'`').count() >= 3,
3347 b'~' => bytes.iter().take_while(|&&b| b == b'~').count() >= 3,
3348 b'0'..=b'9' => {
3355 let digits = bytes.iter().take_while(|b| b.is_ascii_digit()).count();
3356 digits <= 9
3357 && text[..digits].trim_start_matches('0') == "1"
3358 && bytes.len() > digits + 1
3359 && (bytes[digits] == b'.' || bytes[digits] == b')')
3360 && (bytes[digits + 1] == b' ' || bytes[digits + 1] == b'\t')
3361 }
3362 b'[' => {
3370 let mut escaped = false;
3371 let mut label_close = None;
3372 for (i, &b) in bytes.iter().enumerate().skip(1) {
3373 if escaped {
3374 escaped = false;
3375 } else if b == b'\\' {
3376 escaped = true;
3377 } else if b == b']' {
3378 label_close = Some(i);
3379 break;
3380 }
3381 }
3382 label_close.is_some_and(|i| bytes.get(i + 1) == Some(&b':'))
3383 }
3384 b'<' => {
3388 crate::utils::html_block::parse_html_block_start(text).is_some()
3389 || crate::utils::html_block::opens_untagged_html_block(text)
3390 }
3391 _ => false,
3392 }
3393}
3394
3395fn ends_with_unescaped_backslash(line: &str) -> bool {
3398 line.bytes().rev().take_while(|&b| b == b'\\').count() % 2 == 1
3399}
3400
3401fn merge_block_construct_continuations(lines: Vec<String>) -> Vec<String> {
3414 let mut merged: Vec<String> = Vec::with_capacity(lines.len());
3415 for line in lines {
3416 merged.push(line);
3417 while merged.len() > 1
3421 && (starts_block_construct(merged.last().expect("non-empty"))
3422 || ends_with_unescaped_backslash(&merged[merged.len() - 2]))
3423 {
3424 let last = merged.pop().expect("non-empty");
3425 let prev = merged.last_mut().expect("len > 1");
3426 prev.push(' ');
3427 prev.push_str(last.trim_start());
3428 }
3429 }
3430 merged
3431}
3432
3433fn keep_first_line_out_of_html_block(mut lines: Vec<String>) -> Vec<String> {
3443 let Some(first) = lines.first() else {
3444 return lines;
3445 };
3446 if !crate::utils::html_block::opens_tag_line_html_block(first.trim_start()) {
3447 return lines;
3448 }
3449 if lines.len() > 1 {
3450 let next = lines.remove(1);
3451 lines[0].push(' ');
3452 lines[0].push_str(next.trim_start());
3453 return lines;
3454 }
3455 let tag = lines.remove(0);
3456 let indent = tag.len() - tag.trim_start().len();
3457 let tag_len = crate::utils::html_block::complete_tag_len(&tag[indent..]).unwrap_or(0);
3458 let mut quote = None;
3459 let mut split = None;
3460 let mut quoted_split = None;
3461 for (offset, byte) in tag.bytes().enumerate().skip(indent).take(tag_len) {
3462 let breakable = matches!(byte, b' ' | b'\t') && !starts_block_construct(&tag[offset..]);
3463 match (quote, byte) {
3464 (None, b'"' | b'\'') => quote = Some(byte),
3465 (Some(open), _) if open == byte => quote = None,
3466 (None, _) if breakable => split = Some(offset),
3467 (Some(_), _) if breakable => quoted_split = Some(offset),
3468 _ => {}
3469 }
3470 }
3471 match split.or(quoted_split) {
3472 Some(offset) => vec![
3473 tag[..offset].trim_end().to_string(),
3474 tag[offset..].trim_start().to_string(),
3475 ],
3476 None => vec![tag],
3477 }
3478}
3479
3480fn elements_source_text(elements: &[Element]) -> (String, Vec<(usize, usize)>) {
3488 let mut text = String::new();
3489 let mut ranges = Vec::with_capacity(elements.len());
3490 for (idx, element) in elements.iter().enumerate() {
3491 let gap = source_gap_before(elements, idx);
3492 let piece = match element {
3493 Element::Text(content) => content.clone(),
3495 Element::Italic { content, underscore } => {
3496 wrap_emphasis(content, if *underscore { "_" } else { "*" }, &mut text, gap)
3497 }
3498 Element::Bold { content, underscore } => {
3499 wrap_emphasis(content, if *underscore { "__" } else { "**" }, &mut text, gap)
3500 }
3501 Element::Strikethrough { content, double } => {
3502 wrap_emphasis(content, if *double { "~~" } else { "~" }, &mut text, gap)
3503 }
3504 _ => {
3505 push_source_gap(&mut text, gap);
3506 element.to_string()
3507 }
3508 };
3509 let start = text.len();
3510 text.push_str(&piece);
3511 ranges.push((start, text.len()));
3512 }
3513 (text, ranges)
3514}
3515
3516fn reflow_elements_sentence_per_line(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
3518 let abbreviations = get_abbreviations(&options.abbreviations);
3519 let require_sentence_capital = options.require_sentence_capital;
3520 let mut lines = Vec::new();
3521
3522 let (paragraph_text, piece_ranges) = elements_source_text(elements);
3530 let paragraph = sentence_structure(¶graph_text, options.defined_references.as_ref());
3531 let structure_from = |base: usize| ParagraphStructure {
3532 text: ¶graph_text,
3533 structure: ¶graph,
3534 base,
3535 };
3536 let mut line: Option<usize> = None;
3544 let absorb = |line_start: usize, (piece_start, piece_end): (usize, usize)| {
3552 let in_front = line_start <= piece_start;
3553 debug_assert!(
3554 in_front,
3555 "line under construction begins at {line_start}, past the element at {piece_start} of {paragraph_text:?}"
3556 );
3557 if in_front {
3558 (line_start, piece_end, Some(structure_from(line_start)))
3559 } else {
3560 (piece_start, piece_end, None)
3561 }
3562 };
3563
3564 for (idx, element) in elements.iter().enumerate() {
3565 let (line_start, line_end, structure) = absorb(line.unwrap_or(piece_ranges[idx].0), piece_ranges[idx]);
3566 let combined = ¶graph_text[line_start..line_end];
3567
3568 let splits = matches!(
3574 element,
3575 Element::Text(_) | Element::Italic { .. } | Element::Bold { .. } | Element::Strikethrough { .. }
3576 );
3577
3578 if splits {
3579 let sentences = match structure {
3582 Some(structure) => split_into_sentence_ranges(
3583 combined,
3584 &abbreviations,
3585 require_sentence_capital,
3586 options.cjk_soft_break,
3587 options.defined_references.as_ref(),
3588 Some(structure),
3589 ),
3590 None => trim_range(combined, 0, combined.len()).into_iter().collect(),
3591 };
3592
3593 let glued_to_next = piece_ranges
3604 .get(idx + 1)
3605 .is_some_and(|&(next_start, _)| !paragraph_text[..next_start].ends_with(is_breakable_whitespace));
3606 let next_bracketed = elements
3607 .get(idx + 1)
3608 .filter(|next| next.opens_with_bracket())
3609 .map(|next| {
3610 let trailing = &combined[combined.trim_end_matches(is_breakable_whitespace).len()..];
3611 let gap = if trailing.contains('\n') {
3612 "\n"
3613 } else {
3614 source_gap_before(elements, idx + 1)
3615 };
3616 (gap, next.to_string())
3617 });
3618 let closes_before_next = |sentence: &str| -> bool {
3619 if glued_to_next {
3620 return false;
3621 }
3622 let Some((gap, next_str)) = &next_bracketed else {
3623 return true;
3624 };
3625 let mut probe = sentence.to_string();
3626 push_source_gap(&mut probe, gap);
3627 probe.push_str(next_str);
3628 let probe_sentences = split_into_sentences_with_set(
3629 &probe,
3630 &abbreviations,
3631 require_sentence_capital,
3632 options.cjk_soft_break,
3633 options.defined_references.as_ref(),
3634 None,
3635 );
3636 probe_sentences.last().is_some_and(|last| last == next_str)
3637 };
3638
3639 if sentences.len() > 1 {
3640 let mut pending_start = None;
3646 let last = sentences.len() - 1;
3647 for (i, &(start, end)) in sentences.iter().enumerate() {
3648 let pending = &combined[*pending_start.get_or_insert(start)..end];
3649
3650 let closed = i < last || (ends_with_sentence_punct(pending) && closes_before_next(pending));
3655 if closed && !text_ends_with_abbreviation(pending, &abbreviations) {
3656 lines.push(pending.to_string());
3657 pending_start = None;
3658 }
3659 }
3660 line = pending_start.map(|held| line_start + held);
3661 } else {
3662 let trimmed = combined.trim();
3664
3665 if trimmed.is_empty() {
3669 continue;
3670 }
3671
3672 let ends_with_sentence_punct = ends_with_sentence_punct(trimmed);
3673
3674 if ends_with_sentence_punct
3675 && !text_ends_with_abbreviation(trimmed, &abbreviations)
3676 && closes_before_next(trimmed)
3677 {
3678 lines.push(combined.trim_matches(is_breakable_whitespace).to_string());
3681 line = None;
3682 } else {
3683 line = Some(line_start);
3685 }
3686 }
3687 } else {
3688 line = Some(line_start);
3691 }
3692 }
3693
3694 if let Some(line_start) = line {
3702 let tail = ¶graph_text[line_start..];
3706 lines.extend(split_into_sentences_with_set(
3707 tail,
3708 &abbreviations,
3709 require_sentence_capital,
3710 options.cjk_soft_break,
3711 options.defined_references.as_ref(),
3712 Some(structure_from(line_start)),
3713 ));
3714 }
3715 lines
3716}
3717
3718#[derive(Clone)]
3719struct SentencePackSeparator {
3720 text: String,
3721 preserve_inline: bool,
3722}
3723
3724struct SentencePackUnit {
3725 text: String,
3726 separator_before: SentencePackSeparator,
3727}
3728
3729fn sentence_pack_separators(
3737 elements: &[Element],
3738 sentences: &[String],
3739 options: &ReflowOptions,
3740) -> Vec<SentencePackSeparator> {
3741 let mut separators = vec![
3742 SentencePackSeparator {
3743 text: " ".to_string(),
3744 preserve_inline: false,
3745 };
3746 sentences.len().saturating_sub(1)
3747 ];
3748 if options.cjk_soft_break != CjkSoftBreak::Join {
3749 return separators;
3750 }
3751
3752 let (source, _) = elements_source_text(elements);
3753 let mut cursor = 0;
3754 for (idx, pair) in sentences.windows(2).enumerate() {
3755 let Some(current_offset) = source[cursor..].find(&pair[0]) else {
3756 continue;
3757 };
3758 let current_end = cursor + current_offset + pair[0].len();
3759 let Some(next_offset) = source[current_end..].find(&pair[1]) else {
3760 cursor = current_end;
3761 continue;
3762 };
3763 let next_start = current_end + next_offset;
3764 cursor = next_start;
3765
3766 if pair[0].chars().next_back().is_some_and(joins_cjk_soft_break)
3767 && pair[1].chars().next().is_some_and(joins_cjk_soft_break)
3768 {
3769 let text = source[current_end..next_start].to_string();
3770 separators[idx] = SentencePackSeparator {
3771 preserve_inline: !text.is_empty(),
3772 text,
3773 };
3774 }
3775 }
3776 separators
3777}
3778
3779fn sentence_pack_units(elements: &[Element], options: &ReflowOptions) -> Vec<SentencePackUnit> {
3781 let sentences = reflow_elements_sentence_per_line(elements, options);
3782 let separators = sentence_pack_separators(elements, &sentences, options);
3783 let mut units: Vec<SentencePackUnit> = Vec::with_capacity(sentences.len());
3784
3785 for (idx, text) in sentences.into_iter().enumerate() {
3786 let separator_before = idx.checked_sub(1).map_or(
3787 SentencePackSeparator {
3788 text: String::new(),
3789 preserve_inline: false,
3790 },
3791 |separator| separators[separator].clone(),
3792 );
3793 units.push(SentencePackUnit { text, separator_before });
3794
3795 while units.len() > 1 && starts_block_construct(&units.last().expect("non-empty").text) {
3796 let last = units.pop().expect("non-empty");
3797 let previous = &mut units.last_mut().expect("len > 1").text;
3798 previous.push(' ');
3799 previous.push_str(last.text.trim_start());
3800 }
3801 }
3802 units
3803}
3804
3805fn sentence_pack_groups(elements: &[Element], options: &ReflowOptions) -> Vec<SentencePackUnit> {
3807 let units = sentence_pack_units(elements, options);
3808 let mut groups: Vec<SentencePackUnit> = Vec::with_capacity(units.len());
3809
3810 for unit in units {
3811 if unit.separator_before.preserve_inline
3812 && let Some(previous) = groups.last_mut()
3813 {
3814 previous.text.push_str(&unit.separator_before.text);
3815 previous.text.push_str(&unit.text);
3816 continue;
3817 }
3818 groups.push(unit);
3819 }
3820 groups
3821}
3822
3823fn reflow_elements_sentence_pack(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
3825 let mut lines: Vec<String> = Vec::new();
3826 for unit in sentence_pack_groups(elements, options) {
3827 let budget = if lines.len() == 1 {
3828 options.first_line_length.unwrap_or(options.line_length)
3829 } else {
3830 options.line_length
3831 };
3832 if let Some(current) = lines.last_mut() {
3833 let previous_len = current.len();
3834 current.push_str(&unit.separator_before.text);
3835 current.push_str(&unit.text);
3836 if budget == 0 || budget == usize::MAX || line_width(current, options) <= budget {
3837 continue;
3838 }
3839 current.truncate(previous_len);
3840 }
3841 lines.push(unit.text);
3842 }
3843 lines
3844}
3845
3846fn wrap_emphasis(content: &str, marker: &str, current_line: &mut String, gap: &str) -> String {
3850 push_source_gap(current_line, gap);
3851 format!("{marker}{content}{marker}")
3852}
3853
3854const BREAK_WORDS: &[&str] = &[
3858 "and",
3859 "or",
3860 "but",
3861 "nor",
3862 "yet",
3863 "so",
3864 "for",
3865 "which",
3866 "that",
3867 "because",
3868 "when",
3869 "if",
3870 "while",
3871 "where",
3872 "although",
3873 "though",
3874 "unless",
3875 "since",
3876 "after",
3877 "before",
3878 "until",
3879 "as",
3880 "once",
3881 "whether",
3882 "however",
3883 "therefore",
3884 "moreover",
3885 "furthermore",
3886 "nevertheless",
3887 "whereas",
3888];
3889
3890fn is_clause_punctuation(c: char) -> bool {
3892 matches!(c, ',' | ';' | ':' | '\u{2014}') }
3894
3895fn clause_break_allowed_after(chars: &[char], i: usize) -> bool {
3906 match chars.get(i + 1) {
3907 None => true,
3908 Some(next) => is_breakable_whitespace(*next),
3909 }
3910}
3911
3912fn paren_group_end<'a>(slice: &'a str, element_spans: &[ElementSpan], offset: usize) -> Option<(usize, &'a str)> {
3926 debug_assert!(slice.starts_with('('));
3927 let mut depth: i32 = 0;
3928 for (local_byte, c) in slice.char_indices() {
3929 let global_byte = offset + local_byte;
3930 if depth > 0 && is_inside_element(global_byte, element_spans) {
3935 continue;
3936 }
3937 match c {
3938 '(' => depth += 1,
3939 ')' => {
3940 depth -= 1;
3941 if depth == 0 {
3942 let end = local_byte + 1;
3943 let inner = &slice[1..local_byte];
3944 return Some((end, inner));
3945 }
3946 }
3947 _ => {}
3948 }
3949 }
3950 None
3951}
3952
3953fn split_at_parenthetical(
3970 text: &str,
3971 line_length: usize,
3972 element_spans: &[ElementSpan],
3973 length_mode: ReflowLengthMode,
3974) -> Option<(String, String)> {
3975 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
3976
3977 if text.starts_with('(')
3979 && let Some((end_local, inner)) = paren_group_end(text, element_spans, 0)
3980 && inner.contains(' ')
3981 {
3982 let mut first_end = end_local;
3989 loop {
3990 first_end += text[first_end..]
3991 .char_indices()
3992 .take_while(|(_, c)| !is_breakable_whitespace(*c))
3993 .last()
3994 .map_or(0, |(idx, c)| idx + c.len_utf8());
3995 match element_containing(first_end, element_spans) {
3996 Some(span) => first_end = span.end,
3997 None => break,
3998 }
3999 }
4000 let rest_start = first_end;
4001 let first = &text[..first_end];
4002 if measure(first, 0, element_spans, length_mode).fits(line_length) {
4005 let rest = text[rest_start..].trim_start();
4006 if !rest.is_empty() {
4007 return Some((first.to_string(), rest.to_string()));
4008 }
4009 }
4010 }
4011
4012 let mut best_open_byte: Option<usize> = None;
4014 let mut pos = 0usize;
4015 while pos < text.len() {
4016 if text.as_bytes()[pos] != b'(' {
4018 let c = text[pos..].chars().next().unwrap();
4019 pos += c.len_utf8();
4020 continue;
4021 }
4022 if is_inside_element(pos, element_spans) {
4024 pos += 1;
4025 continue;
4026 }
4027 if let Some((end_local, inner)) = paren_group_end(&text[pos..], element_spans, pos) {
4028 let first = text[..pos].trim_end_matches(is_breakable_whitespace);
4029 let first_len = measure(first, 0, element_spans, length_mode).effective();
4030 if first.len() < pos
4033 && !first.is_empty()
4034 && first_len >= min_first_len
4035 && first_len <= line_length
4036 && inner.contains(' ')
4037 && best_open_byte.is_none_or(|prev| pos > prev)
4038 {
4039 best_open_byte = Some(pos);
4040 }
4041 pos += end_local;
4042 } else {
4043 pos += 1;
4044 }
4045 }
4046
4047 let open_byte = best_open_byte?;
4048 let first = text[..open_byte].trim_end_matches(is_breakable_whitespace).to_string();
4049 let rest = text[open_byte..].to_string();
4050 if first.is_empty() || rest.trim().is_empty() {
4051 return None;
4052 }
4053 Some((first, rest))
4054}
4055
4056#[derive(Clone, Copy, Debug, PartialEq, Eq)]
4064struct ElementSpan {
4065 start: usize,
4066 end: usize,
4067 full: usize,
4068 link_saving: usize,
4071 code_saving: usize,
4073 is_hard: bool,
4075}
4076
4077impl ElementSpan {
4078 fn new(start: usize, len: usize, full: usize, width: LineWidth, is_hard: bool) -> Self {
4081 Self {
4082 start,
4083 end: start + len,
4084 full,
4085 link_saving: full - width.link_exempt,
4086 code_saving: full - width.code_exempt,
4087 is_hard,
4088 }
4089 }
4090
4091 fn contains(&self, pos: usize) -> bool {
4092 pos > self.start && pos < self.end
4093 }
4094
4095 fn within(&self, start: usize, end: usize) -> bool {
4096 self.start >= start && self.end <= end
4097 }
4098
4099 fn exempt_width(&self) -> LineWidth {
4100 LineWidth {
4101 link_exempt: self.full - self.link_saving,
4102 code_exempt: self.full - self.code_saving,
4103 }
4104 }
4105}
4106
4107fn link_text_parts(element: &Element, break_link_text: bool) -> Option<(&str, &str, &str)> {
4113 if !break_link_text {
4114 return None;
4115 }
4116 let (raw, open) = match element {
4117 Element::Link(raw)
4118 | Element::ReferenceLink(raw)
4119 | Element::EmptyReferenceLink(raw)
4120 | Element::ShortcutReference(raw) => (raw.as_str(), 0),
4121 Element::InlineImage(raw) | Element::ReferenceImage(raw) | Element::EmptyReferenceImage(raw) => {
4122 (raw.as_str(), 1)
4123 }
4124 _ => return None,
4125 };
4126 let inner = bracketed_text(raw, open)?;
4127 Some((&raw[..=open], inner, &raw[open + 1 + inner.len()..]))
4128}
4129
4130fn element_is_hard(element: &Element, break_link_text: bool) -> bool {
4138 match element {
4139 Element::Bold { content, .. } | Element::Italic { content, .. } | Element::Strikethrough { content, .. } => {
4140 content.contains(['[', '`', '<', '$', '{'])
4141 }
4142 _ => match link_text_parts(element, break_link_text) {
4143 Some((_, inner, suffix)) => {
4144 inner.contains(['[', '`', '<', '$', '{']) || suffix.chars().any(char::is_whitespace)
4145 }
4146 None => true,
4147 },
4148 }
4149}
4150
4151fn compute_element_spans(
4157 elements: &[Element],
4158 mode: ReflowLengthMode,
4159 exemptions: LengthExemptions,
4160 break_link_text: bool,
4161) -> Vec<ElementSpan> {
4162 let mut spans = Vec::new();
4163 let mut offset = 0;
4164 for element in elements {
4165 let len = element.display_len(ReflowLengthMode::Bytes);
4166 if !matches!(element, Element::Text(_)) {
4167 let full = element.display_len(mode);
4168 let width = element.exempt_width(mode, exemptions);
4169 spans.push(ElementSpan::new(
4170 offset,
4171 len,
4172 full,
4173 width,
4174 element_is_hard(element, break_link_text),
4175 ));
4176 }
4177 offset += len;
4178 }
4179 spans
4180}
4181
4182fn measure(text: &str, offset: usize, spans: &[ElementSpan], mode: ReflowLengthMode) -> LineWidth {
4190 let full = display_len(text, mode);
4191 let end = offset + text.len();
4192 let mut width = LineWidth::plain(full);
4193 for span in spans.iter().filter(|span| span.within(offset, end)) {
4194 width.link_exempt -= span.link_saving;
4195 width.code_exempt -= span.code_saving;
4196 }
4197 width
4198}
4199
4200fn line_width_components(line: &str, options: &ReflowOptions) -> LineWidth {
4205 let raw = display_len(line, options.length_mode);
4206 if !options.length_exemptions.any() {
4207 return LineWidth::plain(raw);
4208 }
4209 let elements = parse_markdown_elements_inner(
4210 line,
4211 options.attr_lists,
4212 options.myst_roles,
4213 options.defined_references.as_ref(),
4214 );
4215 let spans = compute_element_spans(
4216 &elements,
4217 options.length_mode,
4218 options.length_exemptions,
4219 options.break_link_text,
4220 );
4221 measure(line, 0, &spans, options.length_mode)
4222}
4223
4224fn line_width(line: &str, options: &ReflowOptions) -> usize {
4226 line_width_components(line, options).effective()
4227}
4228
4229fn line_fits(line: &str, options: &ReflowOptions) -> bool {
4235 display_len(line, options.length_mode) <= options.line_length || line_width(line, options) <= options.line_length
4236}
4237
4238fn element_containing(pos: usize, spans: &[ElementSpan]) -> Option<ElementSpan> {
4240 spans.iter().copied().find(|span| span.contains(pos))
4241}
4242
4243fn is_inside_element(pos: usize, spans: &[ElementSpan]) -> bool {
4245 element_containing(pos, spans).is_some()
4246}
4247
4248const MIN_SPLIT_RATIO: f64 = 0.3;
4251
4252fn split_at_clause_punctuation(
4256 text: &str,
4257 line_length: usize,
4258 element_spans: &[ElementSpan],
4259 length_mode: ReflowLengthMode,
4260) -> Option<(String, String)> {
4261 let chars: Vec<char> = text.chars().collect();
4262 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
4263
4264 let mut width_acc = LineWidth::default();
4270 let mut search_end_char = 0;
4271 let mut byte = 0usize;
4272 let mut idx = 0usize;
4273 while idx < chars.len() {
4274 let (advance_chars, advance_bytes, width) = match element_spans.iter().find(|s| s.start == byte) {
4275 Some(span) => {
4276 let source = &text[span.start..span.end];
4277 (
4278 source.chars().count(),
4279 source.len(),
4280 measure(source, span.start, element_spans, length_mode),
4281 )
4282 }
4283 None => {
4284 let c = chars[idx];
4285 (
4286 1,
4287 c.len_utf8(),
4288 LineWidth::plain(display_len(&c.to_string(), length_mode)),
4289 )
4290 }
4291 };
4292 if !(width_acc + width).fits(line_length) {
4293 break;
4294 }
4295 width_acc += width;
4296 byte += advance_bytes;
4297 idx += advance_chars;
4298 search_end_char = idx;
4299 }
4300
4301 let mut paren_depth: i32 = 0;
4308 let mut best_pos = None;
4309 for i in (0..search_end_char).rev() {
4310 let byte_start: usize = chars[..i].iter().map(|c| c.len_utf8()).sum();
4312 let byte_after: usize = byte_start + chars[i].len_utf8();
4314
4315 if !is_inside_element(byte_start, element_spans) {
4316 match chars[i] {
4317 ')' => paren_depth += 1,
4318 '(' => paren_depth = paren_depth.saturating_sub(1),
4319 _ => {}
4320 }
4321 }
4322
4323 if paren_depth == 0
4324 && is_clause_punctuation(chars[i])
4325 && clause_break_allowed_after(&chars, i)
4326 && !is_inside_element(byte_after, element_spans)
4327 {
4328 best_pos = Some(i);
4329 break;
4330 }
4331 }
4332
4333 let pos = best_pos?;
4334
4335 let first: String = chars[..=pos].iter().collect();
4337 if measure(&first, 0, element_spans, length_mode).effective() < min_first_len {
4338 return None;
4339 }
4340
4341 let rest: String = chars[pos + 1..].iter().collect();
4343 let rest = rest.trim_start().to_string();
4344
4345 if rest.is_empty() {
4346 return None;
4347 }
4348
4349 Some((first, rest))
4350}
4351
4352fn paren_depth_map(text: &str, element_spans: &[ElementSpan]) -> Vec<i32> {
4359 let mut map = vec![0i32; text.len()];
4360 let mut depth = 0i32;
4361 for (byte, c) in text.char_indices() {
4362 if !is_inside_element(byte, element_spans) {
4363 match c {
4364 '(' => depth += 1,
4365 ')' => depth = depth.saturating_sub(1),
4366 _ => {}
4367 }
4368 }
4369 let end = (byte + c.len_utf8()).min(map.len());
4371 for slot in &mut map[byte..end] {
4372 *slot = depth;
4373 }
4374 }
4375 map
4376}
4377
4378fn is_standalone_parenthetical(line: &str) -> bool {
4387 let trimmed = line.trim();
4388 if !trimmed.starts_with('(') {
4389 return false;
4390 }
4391 let Some(close) = trimmed.rfind(')') else {
4394 return false;
4395 };
4396 if trimmed[close + 1..].contains(char::is_whitespace) {
4397 return false;
4398 }
4399 let core = &trimmed[..=close];
4400 let inner = &core[1..core.len() - 1];
4402 if !inner.contains(' ') {
4403 return false;
4404 }
4405 let mut depth = 0i32;
4407 for c in core.chars() {
4408 match c {
4409 '(' => depth += 1,
4410 ')' => depth -= 1,
4411 _ => {}
4412 }
4413 if depth < 0 {
4414 return false;
4415 }
4416 }
4417 depth == 0
4418}
4419
4420fn split_at_break_word(
4424 text: &str,
4425 line_length: usize,
4426 element_spans: &[ElementSpan],
4427 length_mode: ReflowLengthMode,
4428) -> Option<(String, String)> {
4429 let lower = text.to_lowercase();
4430 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
4431 let mut best_split: Option<(usize, usize)> = None; let depth_map = paren_depth_map(text, element_spans);
4436
4437 for &word in BREAK_WORDS {
4438 let mut search_start = 0;
4439 while let Some(pos) = lower[search_start..].find(word) {
4440 let abs_pos = search_start + pos;
4441
4442 let preceded_by_space = abs_pos == 0 || text.as_bytes().get(abs_pos - 1) == Some(&b' ');
4444 let followed_by_space = text.as_bytes().get(abs_pos + word.len()) == Some(&b' ');
4445
4446 if preceded_by_space && followed_by_space {
4447 let first_part = text[..abs_pos].trim_end();
4449 let first_part_len = measure(first_part, 0, element_spans, length_mode).effective();
4450
4451 let inside_paren = depth_map.get(abs_pos).is_some_and(|&d| d > 0);
4453
4454 if first_part_len >= min_first_len
4455 && first_part_len <= line_length
4456 && !is_inside_element(abs_pos, element_spans)
4457 && !inside_paren
4458 {
4459 if best_split.is_none_or(|(prev_pos, _)| abs_pos > prev_pos) {
4461 best_split = Some((abs_pos, word.len()));
4462 }
4463 }
4464 }
4465
4466 search_start = abs_pos + word.len();
4467 }
4468 }
4469
4470 let (byte_start, _word_len) = best_split?;
4471
4472 let first = text[..byte_start].trim_end().to_string();
4473 let rest = text[byte_start..].to_string();
4474
4475 if first.is_empty() || rest.trim().is_empty() {
4476 return None;
4477 }
4478
4479 Some((first, rest))
4480}
4481
4482fn replaces_whitespace(text: &str, first: &str, rest: &str, element_spans: &[ElementSpan]) -> bool {
4493 if !text.starts_with(first) || !text.ends_with(rest) {
4494 return false;
4495 }
4496 let gap_end = text.len() - rest.len();
4497 gap_end > first.len()
4498 && text[first.len()..gap_end].chars().all(is_breakable_whitespace)
4499 && !element_spans
4500 .iter()
4501 .any(|span| first.len() < span.end && span.start < gap_end)
4502}
4503
4504fn cascade_split_line(text: &str, options: &ReflowOptions) -> Vec<String> {
4515 let line_length = options.line_length;
4516 let length_mode = options.length_mode;
4517 let attr_lists = options.attr_lists;
4518 let myst_roles = options.myst_roles;
4519 let defined_references = options.defined_references.as_ref();
4520 if line_length == 0 || display_len(text, length_mode) <= line_length {
4521 return vec![text.to_string()];
4522 }
4523
4524 let elements = parse_markdown_elements_inner(text, attr_lists, myst_roles, defined_references);
4525 let element_spans = compute_element_spans(
4526 &elements,
4527 length_mode,
4528 options.length_exemptions,
4529 options.break_link_text,
4530 );
4531
4532 if measure(text, 0, &element_spans, length_mode).fits(line_length) {
4535 return vec![text.to_string()];
4536 }
4537
4538 let rebased_spans = |start: usize| -> Vec<ElementSpan> {
4542 if start == 0 {
4543 return element_spans.clone();
4544 }
4545 element_spans
4546 .iter()
4547 .filter(|span| span.end > start)
4548 .map(|span| ElementSpan {
4549 start: span.start.saturating_sub(start),
4550 end: span.end.saturating_sub(start),
4551 ..*span
4552 })
4553 .collect()
4554 };
4555
4556 let mut result = Vec::new();
4557 let mut start = 0usize;
4558
4559 loop {
4560 let remaining = &text[start..];
4561 let spans = rebased_spans(start);
4562 if measure(remaining, 0, &spans, length_mode).fits(line_length) {
4563 result.push(remaining.to_string());
4564 return result;
4565 }
4566
4567 let at_whitespace = |candidate: Option<(String, String)>| {
4576 candidate.filter(|(first, rest)| replaces_whitespace(remaining, first, rest, &spans))
4577 };
4578 let split = at_whitespace(split_at_parenthetical(remaining, line_length, &spans, length_mode))
4579 .or_else(|| at_whitespace(split_at_clause_punctuation(remaining, line_length, &spans, length_mode)))
4580 .or_else(|| at_whitespace(split_at_break_word(remaining, line_length, &spans, length_mode)));
4581
4582 if let Some((first, rest)) = split {
4583 let consumed = remaining.len().saturating_sub(rest.len());
4584 if consumed == 0 {
4587 break;
4588 }
4589 result.push(first);
4590 start += consumed;
4591 continue;
4592 }
4593
4594 break;
4596 }
4597
4598 let mut fallback_options = options.clone();
4600 fallback_options.break_on_sentences = false;
4601 fallback_options.preserve_breaks = false;
4602 fallback_options.sentence_per_line = false;
4603 fallback_options.semantic_line_breaks = false;
4604 fallback_options.require_sentence_capital = true;
4605 fallback_options.max_list_continuation_indent = None;
4606 fallback_options.defined_references = None;
4607 let remaining = &text[start..];
4608 let tail_elements = if start == 0 {
4609 elements
4610 } else {
4611 parse_markdown_elements_inner(remaining, attr_lists, myst_roles, defined_references)
4612 };
4613 result.extend(reflow_elements(&tail_elements, &fallback_options));
4614 result
4615}
4616
4617fn reflow_elements_semantic(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
4621 let sentence_lines = reflow_elements_sentence_per_line(elements, options);
4623
4624 if options.line_length == 0 {
4627 return sentence_lines;
4628 }
4629
4630 let mut result = Vec::new();
4631 for line in sentence_lines {
4632 if line_fits(&line, options) {
4633 result.push(line);
4634 } else {
4635 result.extend(cascade_split_line(&line, options));
4636 }
4637 }
4638
4639 let min_line_len = ((options.line_length as f64) * MIN_SPLIT_RATIO) as usize;
4642 let mut merged: Vec<String> = Vec::with_capacity(result.len());
4643 for line in result {
4644 if !merged.is_empty() && line_width(&line, options) < min_line_len && !line.trim().is_empty() {
4645 if is_standalone_parenthetical(&line) {
4648 merged.push(line);
4649 continue;
4650 }
4651
4652 let prev_ends_at_sentence = {
4654 let trimmed = merged.last().unwrap().trim_end();
4655 trimmed
4656 .chars()
4657 .rev()
4658 .find(|&c| !(is_closing_quote(c) || is_closing_bracket(c)))
4659 .is_some_and(|c| matches!(c, '.' | '!' | '?') || is_cjk_sentence_ending(c))
4660 };
4661
4662 if !prev_ends_at_sentence {
4663 let prev = merged.last_mut().unwrap();
4664 let combined = format!("{prev} {line}");
4665 if line_fits(&combined, options) {
4667 *prev = combined;
4668 continue;
4669 }
4670 }
4671 }
4672 merged.push(line);
4673 }
4674 merged
4675}
4676
4677fn rfind_safe_space(
4688 line: &str,
4689 element_spans: &[ElementSpan],
4690 options: &ReflowOptions,
4691 relax_soft_spans: bool,
4692) -> Option<usize> {
4693 line.char_indices().rev().map(|(pos, _)| pos).find(|&pos| {
4694 line.as_bytes()[pos] == b' '
4695 && !is_inside_element_filtered(pos, element_spans, options, relax_soft_spans)
4696 && !starts_block_construct(&line[pos + 1..])
4697 && !ends_with_unescaped_backslash(line[..pos].trim_end_matches(is_breakable_whitespace))
4698 })
4699}
4700
4701fn is_inside_element_filtered(
4702 pos: usize,
4703 spans: &[ElementSpan],
4704 options: &ReflowOptions,
4705 relax_soft_spans: bool,
4706) -> bool {
4707 spans.iter().any(|span| {
4708 span.contains(pos)
4709 && (!relax_soft_spans
4710 || span.is_hard
4711 || (options.atomic_spans && span.exempt_width().fits(options.line_length)))
4712 })
4713}
4714
4715#[derive(Clone, Copy)]
4720struct Attached<'a> {
4721 text: &'a str,
4722 width: LineWidth,
4723 separator: &'a str,
4724}
4725
4726fn break_before_attached(
4743 lines: &mut Vec<String>,
4744 current_line: &mut String,
4745 current_width: &mut LineWidth,
4746 element_spans: &mut Vec<ElementSpan>,
4747 attach: Attached<'_>,
4748 options: &ReflowOptions,
4749) -> Option<usize> {
4750 let length_mode = options.length_mode;
4751 let last_space = rfind_safe_space(current_line, element_spans, options, false)
4752 .or_else(|| rfind_safe_space(current_line, element_spans, options, true))?;
4753 let before = current_line[..last_space]
4754 .trim_end_matches(is_breakable_whitespace)
4755 .to_string();
4756 let after = current_line[last_space + 1..].to_string();
4757 let after_width = measure(&after, last_space + 1, element_spans, length_mode);
4758 lines.push(before);
4759 let carried = after.len();
4760 let Attached { text, width, separator } = attach;
4761 *current_line = format!("{after}{separator}{text}");
4762 *current_width = after_width + LineWidth::plain(display_len(separator, length_mode)) + width;
4763 rebase_spans_after_break(element_spans, last_space + 1);
4764 Some(carried)
4765}
4766
4767fn rebase_spans_after_break(element_spans: &mut Vec<ElementSpan>, carried_start: usize) {
4776 element_spans.retain(|span| span.end > carried_start);
4777 for span in element_spans.iter_mut() {
4778 span.start = span.start.saturating_sub(carried_start);
4779 span.end -= carried_start;
4780 }
4781}
4782
4783fn reflow_elements(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
4785 let mut lines = Vec::new();
4786 let mut current_line = String::new();
4787 let mut current_width = LineWidth::default();
4790 let mut current_line_element_spans: Vec<ElementSpan> = Vec::new();
4792 let length_mode = options.length_mode;
4793 let exemptions = options.length_exemptions;
4794
4795 for (idx, element) in elements.iter().enumerate() {
4796 let element_len = element.display_len(length_mode);
4797 let element_width = element.exempt_width(length_mode, exemptions);
4798 let is_hard = element_is_hard(element, options.break_link_text);
4799
4800 let is_adjacent_to_prev = if idx > 0 {
4809 match (&elements[idx - 1], element) {
4810 (Element::Text(t), _) => !t.is_empty() && !t.ends_with(is_breakable_whitespace),
4811 (_, Element::Text(t)) => !t.is_empty() && !t.starts_with(is_breakable_whitespace),
4812 _ => true,
4813 }
4814 } else {
4815 false
4816 };
4817
4818 if let Element::Text(text) = element {
4820 let has_leading_space = text.starts_with(is_breakable_whitespace);
4822 let words: Vec<&str> = split_breakable_words(text).collect();
4824
4825 for (i, word) in words.iter().enumerate() {
4826 let word_width = LineWidth::plain(display_len(word, length_mode));
4828 let is_trailing_punct = word.chars().all(|c| {
4834 matches!(c, ',' | '.' | ':' | ';' | '!' | '?' | ')' | ']' | '}') || is_significant_whitespace(c)
4835 });
4836
4837 let is_first_adjacent = i == 0 && is_adjacent_to_prev;
4840
4841 if is_first_adjacent {
4842 if !(current_width + word_width).fits(options.line_length)
4844 && !current_width.is_empty()
4845 && break_before_attached(
4846 &mut lines,
4847 &mut current_line,
4848 &mut current_width,
4849 &mut current_line_element_spans,
4850 Attached {
4851 text: word,
4852 width: word_width,
4853 separator: "",
4854 },
4855 options,
4856 )
4857 .is_some()
4858 {
4859 } else {
4864 current_line.push_str(word);
4865 current_width += word_width;
4866 }
4867 } else if !current_width.is_empty()
4868 && !(current_width + LineWidth::plain(1) + word_width).fits(options.line_length)
4869 {
4870 if is_trailing_punct {
4871 if break_before_attached(
4878 &mut lines,
4879 &mut current_line,
4880 &mut current_width,
4881 &mut current_line_element_spans,
4882 Attached {
4883 text: word,
4884 width: word_width,
4885 separator: " ",
4886 },
4887 options,
4888 )
4889 .is_none()
4890 {
4891 current_line.push(' ');
4892 current_line.push_str(word);
4893 current_width += LineWidth::plain(1) + word_width;
4894 }
4895 } else if !starts_block_construct(word)
4896 && !ends_with_unescaped_backslash(current_line.trim_end_matches(is_breakable_whitespace))
4897 {
4898 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
4900 current_line = word.to_string();
4901 current_width = word_width;
4902 current_line_element_spans.clear();
4903 } else if break_before_attached(
4904 &mut lines,
4905 &mut current_line,
4906 &mut current_width,
4907 &mut current_line_element_spans,
4908 Attached {
4909 text: word,
4910 width: word_width,
4911 separator: " ",
4912 },
4913 options,
4914 )
4915 .is_some()
4916 {
4917 } else {
4923 if i > 0 || has_leading_space {
4926 current_line.push(' ');
4927 current_width += LineWidth::plain(1);
4928 }
4929 current_line.push_str(word);
4930 current_width += word_width;
4931 }
4932 } else {
4933 let add_space = !current_width.is_empty() && (i > 0 || has_leading_space);
4945 if add_space {
4946 current_line.push(' ');
4947 current_width += LineWidth::plain(1);
4948 }
4949 current_line.push_str(word);
4950 current_width += word_width;
4951 }
4952 }
4953 } else {
4954 let link_parts = link_text_parts(element, options.break_link_text);
4955 let span_info = match element {
4956 Element::Italic { content, underscore } => {
4957 let marker = if *underscore { "_" } else { "*" };
4958 Some((content.as_str(), marker, marker, false))
4959 }
4960 Element::Bold { content, underscore } => {
4961 let marker = if *underscore { "__" } else { "**" };
4962 Some((content.as_str(), marker, marker, false))
4963 }
4964 Element::Strikethrough { content, double } => {
4965 let marker = if *double { "~~" } else { "~" };
4966 Some((content.as_str(), marker, marker, false))
4967 }
4968 Element::Code { content, marker } => Some((content.as_str(), marker.as_str(), marker.as_str(), true)),
4969 _ => link_parts.map(|(prefix, inner, suffix)| (inner, prefix, suffix, false)),
4970 };
4971 let is_link = link_parts.is_some();
4972
4973 let breakable: Option<Vec<&str>> = match span_info {
4980 Some((content, _, suffix, is_code)) => {
4981 if is_code {
4982 (!options.atomic_spans && options.wrap_code_spans && code_span_wraps_losslessly(content))
4983 .then(|| split_breakable_words(content).collect())
4984 } else if is_link {
4985 (!options.atomic_spans || !element_width.fits(options.line_length))
4986 .then(|| {
4987 link_text_break_units(
4988 content,
4989 suffix,
4990 options.line_length,
4991 length_mode,
4992 options.defined_references.as_ref(),
4993 options.attr_lists,
4994 )
4995 })
4996 .flatten()
4997 } else {
4998 (!options.atomic_spans || element_len > options.line_length)
4999 .then(|| breakable_units(content, options.defined_references.as_ref(), options.attr_lists))
5000 .flatten()
5001 }
5002 }
5003 None => None,
5004 };
5005
5006 if let Some(words) = breakable {
5007 let (_, prefix, suffix, is_code) = span_info.expect("breakable implies a span");
5008 let n = words.len();
5009 if n == 0 {
5010 let full = format!("{prefix}{suffix}");
5012 let full_width = LineWidth::plain(display_len(&full, length_mode));
5013 if !is_adjacent_to_prev && !current_width.is_empty() {
5014 current_line.push(' ');
5015 current_width += LineWidth::plain(1);
5016 }
5017 current_line.push_str(&full);
5018 current_width += full_width;
5019 } else {
5020 let suffix_span_width = LineWidth::plain(display_len(suffix, length_mode));
5026
5027 for (i, word) in words.iter().enumerate() {
5028 let is_first = i == 0;
5029 let is_last = i == n - 1;
5030
5031 let space_start = if is_first && is_code && word.starts_with('`') {
5032 " "
5033 } else {
5034 ""
5035 };
5036 let space_end = if is_last && is_code && word.ends_with('`') {
5037 " "
5038 } else {
5039 ""
5040 };
5041
5042 let word_str: String = match (is_first, is_last) {
5043 (true, true) => format!("{prefix}{space_start}{word}{space_end}{suffix}"),
5044 (true, false) => format!("{prefix}{space_start}{word}"),
5045 (false, true) => format!("{word}{space_end}{suffix}"),
5046 (false, false) => word.to_string(),
5047 };
5048 let word_elements = parse_elements(&word_str, options);
5049 let word_spans =
5050 compute_element_spans(&word_elements, length_mode, exemptions, options.break_link_text);
5051 let word_width = measure(&word_str, 0, &word_spans, length_mode);
5052
5053 let needs_space = if is_first {
5054 !is_adjacent_to_prev && !current_width.is_empty()
5055 } else {
5056 !current_width.is_empty()
5057 };
5058
5059 if needs_space
5060 && !(current_width + LineWidth::plain(1) + word_width).fits(options.line_length)
5061 && !starts_block_construct(&word_str)
5062 {
5063 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
5064 current_line = word_str;
5065 current_width = word_width;
5066 current_line_element_spans.clear();
5067 for span in word_spans {
5068 current_line_element_spans.push(span);
5069 }
5070 if is_link && is_last {
5071 current_line_element_spans.push(ElementSpan::new(
5072 word.len(),
5073 suffix.len(),
5074 display_len(suffix, length_mode),
5075 suffix_span_width,
5076 true,
5077 ));
5078 }
5079 } else {
5080 let mut start_pos = current_line.len();
5081 if needs_space {
5082 current_line.push(' ');
5083 current_width += LineWidth::plain(1);
5084 start_pos += 1;
5085 }
5086 current_line.push_str(&word_str);
5087 current_width += word_width;
5088 for mut span in word_spans {
5089 span.start += start_pos;
5090 span.end += start_pos;
5091 current_line_element_spans.push(span);
5092 }
5093 if is_link && is_last {
5094 current_line_element_spans.push(ElementSpan::new(
5095 start_pos + word.len(),
5096 suffix.len(),
5097 display_len(suffix, length_mode),
5098 suffix_span_width,
5099 true,
5100 ));
5101 }
5102 }
5103 }
5104 }
5105 } else {
5106 let element_str = format!("{element}");
5109
5110 if is_adjacent_to_prev {
5111 if !(current_width + element_width).fits(options.line_length)
5113 && let Some(carried) = break_before_attached(
5114 &mut lines,
5115 &mut current_line,
5116 &mut current_width,
5117 &mut current_line_element_spans,
5118 Attached {
5119 text: &element_str,
5120 width: element_width,
5121 separator: "",
5122 },
5123 options,
5124 )
5125 {
5126 current_line_element_spans.push(ElementSpan::new(
5130 carried,
5131 element_str.len(),
5132 element_len,
5133 element_width,
5134 is_hard,
5135 ));
5136 } else {
5137 let start = current_line.len();
5138 current_line.push_str(&element_str);
5139 current_width += element_width;
5140 current_line_element_spans.push(ElementSpan::new(
5141 start,
5142 element_str.len(),
5143 element_len,
5144 element_width,
5145 is_hard,
5146 ));
5147 }
5148 } else if !current_width.is_empty()
5149 && !(current_width + LineWidth::plain(1) + element_width).fits(options.line_length)
5150 {
5151 if !starts_block_construct(&element_str) {
5152 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
5154 current_line.clone_from(&element_str);
5155 current_width = element_width;
5156 current_line_element_spans.clear();
5157 current_line_element_spans.push(ElementSpan::new(
5158 0,
5159 element_str.len(),
5160 element_len,
5161 element_width,
5162 is_hard,
5163 ));
5164 } else if let Some(carried) = break_before_attached(
5165 &mut lines,
5166 &mut current_line,
5167 &mut current_width,
5168 &mut current_line_element_spans,
5169 Attached {
5170 text: &element_str,
5171 width: element_width,
5172 separator: " ",
5173 },
5174 options,
5175 ) {
5176 let start = carried + 1;
5180 current_line_element_spans.push(ElementSpan::new(
5181 start,
5182 element_str.len(),
5183 element_len,
5184 element_width,
5185 is_hard,
5186 ));
5187 } else {
5188 let ends_with_opener =
5191 current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
5192 if !ends_with_opener {
5193 current_line.push(' ');
5194 current_width += LineWidth::plain(1);
5195 }
5196 let start = current_line.len();
5197 current_line.push_str(&element_str);
5198 current_width += element_width;
5199 current_line_element_spans.push(ElementSpan::new(
5200 start,
5201 element_str.len(),
5202 element_len,
5203 element_width,
5204 is_hard,
5205 ));
5206 }
5207 } else {
5208 let ends_with_opener =
5210 current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
5211 if !current_width.is_empty() && !ends_with_opener {
5212 current_line.push(' ');
5213 current_width += LineWidth::plain(1);
5214 }
5215 let start = current_line.len();
5216 current_line.push_str(&element_str);
5217 current_width += element_width;
5218 current_line_element_spans.push(ElementSpan::new(
5219 start,
5220 element_str.len(),
5221 element_len,
5222 element_width,
5223 is_hard,
5224 ));
5225 }
5226 }
5227 }
5228 }
5229
5230 if !current_line.is_empty() {
5232 lines.push(current_line.trim_end_matches(is_breakable_whitespace).to_string());
5233 }
5234
5235 lines
5236}
5237
5238pub fn reflow_markdown(content: &str, options: &ReflowOptions) -> String {
5240 let lines: Vec<&str> = content.lines().collect();
5241 let inside_code_span = lines_touching_multiline_code_span(content);
5243 let mut result = Vec::new();
5244 let mut i = 0;
5245
5246 while i < lines.len() {
5247 let line = lines[i];
5248 let trimmed = line.trim();
5249
5250 if trimmed.is_empty() {
5252 result.push(String::new());
5253 i += 1;
5254 continue;
5255 }
5256
5257 if trimmed.starts_with('#') {
5259 result.push(line.to_string());
5260 i += 1;
5261 continue;
5262 }
5263
5264 if trimmed.starts_with(":::") {
5266 result.push(line.to_string());
5267 i += 1;
5268 continue;
5269 }
5270
5271 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
5273 result.push(line.to_string());
5274 i += 1;
5275 while i < lines.len() {
5277 result.push(lines[i].to_string());
5278 if lines[i].trim().starts_with("```") || lines[i].trim().starts_with("~~~") {
5279 i += 1;
5280 break;
5281 }
5282 i += 1;
5283 }
5284 continue;
5285 }
5286
5287 if calculate_indentation_width_default(line) >= 4 {
5289 result.push(line.to_string());
5291 i += 1;
5292 while i < lines.len() {
5293 let next_line = lines[i];
5294 if calculate_indentation_width_default(next_line) >= 4 || next_line.trim().is_empty() {
5296 result.push(next_line.to_string());
5297 i += 1;
5298 } else {
5299 break;
5300 }
5301 }
5302 continue;
5303 }
5304
5305 if trimmed.starts_with('>') {
5307 let gt_pos = line.find('>').expect("'>' must exist since trimmed.starts_with('>')");
5310 let quote_prefix = line[0..=gt_pos].to_string();
5311 let quote_content = &line[quote_prefix.len()..].trim_start();
5312
5313 let reflowed = reflow_line(quote_content, options);
5314 for reflowed_line in &reflowed {
5315 result.push(format!("{quote_prefix} {reflowed_line}"));
5316 }
5317 i += 1;
5318 continue;
5319 }
5320
5321 if is_horizontal_rule(trimmed) {
5323 result.push(line.to_string());
5324 i += 1;
5325 continue;
5326 }
5327
5328 if is_unordered_list_marker(trimmed) || is_numbered_list_item(trimmed) {
5330 let indent = line.len() - line.trim_start().len();
5332 let indent_str = " ".repeat(indent);
5333
5334 let mut marker_end = indent;
5337 let mut content_start = indent;
5338
5339 if trimmed.chars().next().is_some_and(char::is_numeric) {
5340 if let Some(period_pos) = line[indent..].find('.') {
5342 marker_end = indent + period_pos + 1; content_start = marker_end;
5344 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
5348 content_start += 1;
5349 }
5350 }
5351 } else {
5352 marker_end = indent + 1; content_start = marker_end;
5355 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
5359 content_start += 1;
5360 }
5361 }
5362
5363 let min_continuation_indent = content_start;
5365
5366 let rest = &line[content_start..];
5369 if rest.starts_with("[ ] ") || rest.starts_with("[x] ") || rest.starts_with("[X] ") {
5370 marker_end = content_start + 3; content_start += 4; }
5373
5374 let marker = &line[indent..marker_end];
5375
5376 let mut list_content = vec![trim_preserving_hard_break(&line[content_start..])];
5379 i += 1;
5380
5381 while i < lines.len() {
5385 let next_line = lines[i];
5386 let next_trimmed = next_line.trim();
5387
5388 if is_block_boundary(next_trimmed) {
5390 break;
5391 }
5392
5393 let next_indent = next_line.len() - next_line.trim_start().len();
5395 if next_indent >= min_continuation_indent {
5396 let trimmed_start = next_line.trim_start();
5399 list_content.push(trim_preserving_hard_break(trimmed_start));
5400 i += 1;
5401 } else {
5402 break;
5404 }
5405 }
5406
5407 let combined_content = if options.preserve_breaks {
5410 list_content[0].clone()
5411 } else {
5412 let has_hard_breaks = list_content.iter().any(|line| has_hard_break(line));
5414 if has_hard_breaks {
5415 list_content.join("\n")
5417 } else {
5418 join_reflow_lines(&list_content, options)
5419 }
5420 };
5421
5422 let trimmed_marker = marker;
5424 let continuation_spaces = if let Some(max_indent) = options.max_list_continuation_indent {
5425 indent + (content_start - indent).min(max_indent)
5428 } else {
5429 content_start
5430 };
5431
5432 let prefix_length = indent + trimmed_marker.len() + 1;
5434
5435 let adjusted_options = ReflowOptions {
5437 line_length: options.line_length.saturating_sub(prefix_length),
5438 ..options.clone()
5439 };
5440
5441 let reflowed = reflow_line(&combined_content, &adjusted_options);
5442 for (j, reflowed_line) in reflowed.iter().enumerate() {
5443 if j == 0 {
5444 result.push(format!("{indent_str}{trimmed_marker} {reflowed_line}"));
5445 } else {
5446 let continuation_indent = " ".repeat(continuation_spaces);
5448 result.push(format!("{continuation_indent}{reflowed_line}"));
5449 }
5450 }
5451 continue;
5452 }
5453
5454 if crate::utils::table_utils::TableUtils::is_potential_table_row(line) {
5456 result.push(line.to_string());
5457 i += 1;
5458 continue;
5459 }
5460
5461 if trimmed.starts_with('[') && line.contains("]:") {
5463 result.push(line.to_string());
5464 i += 1;
5465 continue;
5466 }
5467
5468 if is_definition_list_marker(trimmed) && has_block_line_above(&lines, i) {
5474 result.push(line.to_string());
5475 i += 1;
5476 continue;
5477 }
5478
5479 let mut is_single_line_paragraph = true;
5481 if i + 1 < lines.len() {
5482 let next_trimmed = lines[i + 1].trim();
5483 if !is_block_boundary(next_trimmed) {
5485 is_single_line_paragraph = false;
5486 }
5487 }
5488
5489 if is_single_line_paragraph && line_fits(line, options) {
5491 result.push(line.to_string());
5492 i += 1;
5493 continue;
5494 }
5495
5496 let mut paragraph_parts: Vec<(String, bool)> = Vec::new();
5500 let mut current_part = vec![line];
5501 i += 1;
5502
5503 if options.preserve_breaks {
5505 let hard_break_type = if line.strip_suffix('\r').unwrap_or(line).ends_with('\\') {
5507 Some("\\")
5508 } else if line.ends_with(" ") {
5509 Some(" ")
5510 } else {
5511 None
5512 };
5513 let reflowed = reflow_line(line, options);
5514
5515 if let Some(break_marker) = hard_break_type {
5517 if !reflowed.is_empty() {
5518 let mut reflowed_with_break = reflowed;
5519 let last_idx = reflowed_with_break.len() - 1;
5520 if !has_hard_break(&reflowed_with_break[last_idx]) {
5521 reflowed_with_break[last_idx].push_str(break_marker);
5522 }
5523 result.extend(reflowed_with_break);
5524 }
5525 } else {
5526 result.extend(reflowed);
5527 }
5528 } else {
5529 let paragraph_start = i - 1;
5543 let paragraph_scan = options.sentence_per_line.then(|| {
5544 let mut paragraph_end = i;
5545 while paragraph_end < lines.len() && !is_block_boundary(lines[paragraph_end].trim()) {
5546 paragraph_end += 1;
5547 }
5548 let joined_paragraph = lines[paragraph_start..paragraph_end].join(" ");
5549 let joined_atomic = sentence_structure(&joined_paragraph, options.defined_references.as_ref()).atomic;
5550 let mut line_starts = Vec::with_capacity(paragraph_end - paragraph_start);
5551 let mut line_offset = 0;
5552 for paragraph_line in &lines[paragraph_start..paragraph_end] {
5553 line_starts.push(line_offset);
5554 line_offset += paragraph_line.len() + 1;
5555 }
5556 (joined_atomic, line_starts)
5557 });
5558
5559 while i < lines.len() {
5560 let prev_line = if !current_part.is_empty() {
5561 current_part.last().unwrap()
5562 } else {
5563 ""
5564 };
5565 let next_line = lines[i];
5566 let next_trimmed = next_line.trim();
5567
5568 if is_block_boundary(next_trimmed) {
5570 break;
5571 }
5572
5573 let prev_trimmed = prev_line.trim();
5576 let abbreviations = get_abbreviations(&options.abbreviations);
5577 let ends_with_sentence = (prev_trimmed.ends_with('.')
5578 || prev_trimmed.ends_with('!')
5579 || prev_trimmed.ends_with('?')
5580 || prev_trimmed.ends_with(".*")
5581 || prev_trimmed.ends_with("!*")
5582 || prev_trimmed.ends_with("?*")
5583 || prev_trimmed.ends_with("._")
5584 || prev_trimmed.ends_with("!_")
5585 || prev_trimmed.ends_with("?_")
5586 || prev_trimmed.ends_with(".\"")
5588 || prev_trimmed.ends_with("!\"")
5589 || prev_trimmed.ends_with("?\"")
5590 || prev_trimmed.ends_with(".'")
5591 || prev_trimmed.ends_with("!'")
5592 || prev_trimmed.ends_with("?'")
5593 || prev_trimmed.ends_with(".\u{201D}")
5594 || prev_trimmed.ends_with("!\u{201D}")
5595 || prev_trimmed.ends_with("?\u{201D}")
5596 || prev_trimmed.ends_with(".\u{2019}")
5597 || prev_trimmed.ends_with("!\u{2019}")
5598 || prev_trimmed.ends_with("?\u{2019}")
5599 || ends_cjk_sentence_with_closer(prev_trimmed))
5604 && !text_ends_with_abbreviation(
5605 prev_trimmed.trim_end_matches(['*', '_', '"', '\'', '\u{201D}', '\u{2019}']),
5606 &abbreviations,
5607 );
5608
5609 let inside_construct = paragraph_scan.as_ref().is_some_and(|(joined_atomic, line_starts)| {
5612 let join_offset = line_starts[i - paragraph_start] - 1;
5613 joined_atomic
5614 .iter()
5615 .any(|&(start, end)| start <= join_offset && join_offset < end)
5616 });
5617
5618 let ends_at_hard_break = has_hard_break(prev_line);
5625 if ends_at_hard_break
5626 || (is_self_contained_display_math_line(prev_line) && !inside_code_span[i - 1])
5627 || (is_self_contained_display_math_line(next_line) && !inside_code_span[i])
5628 || (options.sentence_per_line && ends_with_sentence && !inside_construct)
5629 {
5630 paragraph_parts.push((join_reflow_lines(¤t_part, options), ends_at_hard_break));
5632 current_part = vec![next_line];
5633 } else {
5634 current_part.push(next_line);
5635 }
5636 i += 1;
5637 }
5638
5639 if !current_part.is_empty() {
5641 if current_part.len() == 1 {
5642 paragraph_parts.push((current_part[0].to_string(), false));
5644 } else {
5645 paragraph_parts.push((join_reflow_lines(¤t_part, options), false));
5646 }
5647 }
5648
5649 for (j, (part, ends_at_hard_break)) in paragraph_parts.iter().enumerate() {
5651 let reflowed = reflow_line(part, options);
5652 result.extend(reflowed);
5653
5654 if *ends_at_hard_break
5660 && j < paragraph_parts.len() - 1
5661 && !result.is_empty()
5662 && !options.sentence_per_line
5663 {
5664 let last_idx = result.len() - 1;
5665 if !has_hard_break(&result[last_idx]) {
5666 result[last_idx].push_str(" ");
5667 }
5668 }
5669 }
5670 }
5671 }
5672
5673 let mut result_text = result.join("\n");
5676 if content.ends_with('\n') {
5677 result_text.push('\n');
5678 }
5679 result_text
5680}
5681
5682#[derive(Debug, Clone)]
5684pub struct ParagraphReflow {
5685 pub start_byte: usize,
5687 pub end_byte: usize,
5689 pub reflowed_text: String,
5691}
5692
5693#[derive(Debug, Clone)]
5699pub struct BlockquoteLineData {
5700 pub(crate) content: String,
5702 pub(crate) is_explicit: bool,
5704 pub(crate) prefix: Option<String>,
5706}
5707
5708impl BlockquoteLineData {
5709 pub fn explicit(content: String, prefix: String) -> Self {
5711 Self {
5712 content,
5713 is_explicit: true,
5714 prefix: Some(prefix),
5715 }
5716 }
5717
5718 pub fn lazy(content: String) -> Self {
5720 Self {
5721 content,
5722 is_explicit: false,
5723 prefix: None,
5724 }
5725 }
5726}
5727
5728#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5730pub enum BlockquoteContinuationStyle {
5731 Explicit,
5732 Lazy,
5733}
5734
5735pub fn blockquote_continuation_style(lines: &[BlockquoteLineData]) -> BlockquoteContinuationStyle {
5743 let mut explicit_count = 0usize;
5744 let mut lazy_count = 0usize;
5745
5746 for line in lines.iter().skip(1) {
5747 if line.is_explicit {
5748 explicit_count += 1;
5749 } else {
5750 lazy_count += 1;
5751 }
5752 }
5753
5754 if explicit_count > 0 && lazy_count == 0 {
5755 BlockquoteContinuationStyle::Explicit
5756 } else if lazy_count > 0 && explicit_count == 0 {
5757 BlockquoteContinuationStyle::Lazy
5758 } else if explicit_count >= lazy_count {
5759 BlockquoteContinuationStyle::Explicit
5760 } else {
5761 BlockquoteContinuationStyle::Lazy
5762 }
5763}
5764
5765pub fn dominant_blockquote_prefix(lines: &[BlockquoteLineData], fallback: &str) -> String {
5770 let mut counts: std::collections::HashMap<String, (usize, usize)> = std::collections::HashMap::new();
5771
5772 for (idx, line) in lines.iter().enumerate() {
5773 let Some(prefix) = line.prefix.as_ref() else {
5774 continue;
5775 };
5776 counts
5777 .entry(prefix.clone())
5778 .and_modify(|entry| entry.0 += 1)
5779 .or_insert((1, idx));
5780 }
5781
5782 counts
5783 .into_iter()
5784 .max_by(|(_, (count_a, first_idx_a)), (_, (count_b, first_idx_b))| {
5785 count_a.cmp(count_b).then_with(|| first_idx_b.cmp(first_idx_a))
5786 })
5787 .map_or_else(|| fallback.to_string(), |(prefix, _)| prefix)
5788}
5789
5790pub(crate) fn should_force_explicit_blockquote_line(content_line: &str) -> bool {
5795 let trimmed = content_line.trim_start();
5796 trimmed.starts_with('>')
5797 || trimmed.starts_with('#')
5798 || trimmed.starts_with("```")
5799 || trimmed.starts_with("~~~")
5800 || is_unordered_list_marker(trimmed)
5801 || is_numbered_list_item(trimmed)
5802 || is_horizontal_rule(trimmed)
5803 || is_definition_list_marker(trimmed)
5804 || (trimmed.starts_with('[') && trimmed.contains("]:"))
5805 || trimmed.starts_with(":::")
5806 || (trimmed.starts_with('<')
5807 && !trimmed.starts_with("<http")
5808 && !trimmed.starts_with("<https")
5809 && !trimmed.starts_with("<mailto:"))
5810}
5811
5812pub fn reflow_blockquote_content(
5821 lines: &[BlockquoteLineData],
5822 explicit_prefix: &str,
5823 continuation_style: BlockquoteContinuationStyle,
5824 options: &ReflowOptions,
5825) -> Vec<String> {
5826 let content_strs: Vec<&str> = lines.iter().map(|l| l.content.as_str()).collect();
5827 let ends_in_code_span = lines_ending_in_code_span(&content_strs);
5828 let segments = split_into_segments_strs(&content_strs, &ends_in_code_span);
5829 let mut reflowed_content_lines: Vec<String> = Vec::new();
5830
5831 for segment in segments {
5832 let hard_break_type = segment.last().and_then(|&(line, in_code_span)| {
5833 let line = line.strip_suffix('\r').unwrap_or(line);
5834 if in_code_span {
5835 None
5836 } else if line.ends_with('\\') {
5837 Some("\\")
5838 } else if line.ends_with(" ") {
5839 Some(" ")
5840 } else {
5841 None
5842 }
5843 });
5844
5845 let pieces: Vec<&str> = segment
5846 .iter()
5847 .map(|&(line, in_code_span)| {
5848 if in_code_span {
5849 line
5850 } else if let Some(l) = line.strip_suffix('\\') {
5851 l.trim_end_matches(is_breakable_whitespace)
5852 } else if let Some(l) = line.strip_suffix(" ") {
5853 l.trim_end_matches(is_breakable_whitespace)
5854 } else {
5855 line.trim_end_matches(is_breakable_whitespace)
5856 }
5857 })
5858 .collect();
5859
5860 let segment_text = join_reflow_lines(&pieces, options);
5861 let segment_text = trim_breakable_whitespace(&segment_text);
5862 if segment_text.is_empty() {
5863 continue;
5864 }
5865
5866 let mut segment_options = options.clone();
5867 if options.sentence_pack
5868 && !reflowed_content_lines.is_empty()
5869 && !should_force_explicit_blockquote_line(segment_text)
5870 {
5871 segment_options.first_line_length = None;
5872 }
5873 let mut reflowed = reflow_line(segment_text, &segment_options);
5874 if let Some(break_marker) = hard_break_type
5875 && !reflowed.is_empty()
5876 {
5877 let last_idx = reflowed.len() - 1;
5878 if !has_hard_break(&reflowed[last_idx]) {
5879 reflowed[last_idx].push_str(break_marker);
5880 }
5881 }
5882 reflowed_content_lines.extend(reflowed);
5883 }
5884
5885 let mut styled_lines: Vec<String> = Vec::new();
5886 for (idx, line) in reflowed_content_lines.iter().enumerate() {
5887 let force_explicit = idx == 0
5888 || continuation_style == BlockquoteContinuationStyle::Explicit
5889 || should_force_explicit_blockquote_line(line);
5890 if force_explicit {
5891 styled_lines.push(format!("{explicit_prefix}{line}"));
5892 } else {
5893 styled_lines.push(line.clone());
5894 }
5895 }
5896
5897 styled_lines
5898}
5899
5900fn is_blockquote_content_boundary(content: &str) -> bool {
5901 let trimmed = content.trim();
5902 trimmed.is_empty()
5903 || is_block_boundary(trimmed)
5904 || crate::utils::table_utils::TableUtils::is_potential_table_row(content)
5905 || trimmed.starts_with(":::")
5906 || crate::utils::is_template_directive_only(content)
5907 || is_standalone_attr_list(content)
5908 || is_snippet_block_delimiter(content)
5909}
5910
5911fn split_into_segments_strs<'a>(lines: &[&'a str], ends_in_code_span: &[bool]) -> Vec<Vec<(&'a str, bool)>> {
5915 let mut segments = Vec::new();
5916 let mut current = Vec::new();
5917 let inside_code_span = lines_touching_multiline_code_span(&lines.join("\n"));
5921
5922 for (idx, &line) in lines.iter().enumerate() {
5923 let is_display_math =
5928 is_self_contained_display_math_line(line) && !inside_code_span.get(idx).is_some_and(|&inside| inside);
5929 if is_display_math && !current.is_empty() {
5930 segments.push(std::mem::take(&mut current));
5931 }
5932 current.push((line, ends_in_code_span[idx]));
5933 if (has_hard_break(line) && !ends_in_code_span[idx]) || is_display_math {
5934 segments.push(std::mem::take(&mut current));
5935 }
5936 }
5937
5938 if !current.is_empty() {
5939 segments.push(current);
5940 }
5941
5942 segments
5943}
5944
5945fn reflow_blockquote_paragraph_at_line(
5946 content: &str,
5947 lines: &[&str],
5948 target_idx: usize,
5949 options: &ReflowOptions,
5950) -> Option<ParagraphReflow> {
5951 let mut anchor_idx = target_idx;
5952 let mut target_level = if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[target_idx]) {
5953 parsed.nesting_level
5954 } else {
5955 let mut found = None;
5956 let mut idx = target_idx;
5957 loop {
5958 if lines[idx].trim().is_empty() {
5959 break;
5960 }
5961 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[idx]) {
5962 found = Some((idx, parsed.nesting_level));
5963 break;
5964 }
5965 if idx == 0 {
5966 break;
5967 }
5968 idx -= 1;
5969 }
5970 let (idx, level) = found?;
5971 anchor_idx = idx;
5972 level
5973 };
5974
5975 let mut para_start = anchor_idx;
5977 while para_start > 0 {
5978 let prev_idx = para_start - 1;
5979 let prev_line = lines[prev_idx];
5980
5981 if prev_line.trim().is_empty() {
5982 break;
5983 }
5984
5985 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(prev_line) {
5986 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
5987 break;
5988 }
5989 para_start = prev_idx;
5990 continue;
5991 }
5992
5993 let prev_lazy = prev_line.trim_start();
5994 if is_blockquote_content_boundary(prev_lazy) {
5995 break;
5996 }
5997 para_start = prev_idx;
5998 }
5999
6000 while para_start < lines.len() {
6002 let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[para_start]) else {
6003 para_start += 1;
6004 continue;
6005 };
6006 target_level = parsed.nesting_level;
6007 break;
6008 }
6009
6010 if para_start >= lines.len() || para_start > target_idx {
6011 return None;
6012 }
6013
6014 let mut collected: Vec<(usize, BlockquoteLineData)> = Vec::new();
6017 let mut idx = para_start;
6018 while idx < lines.len() {
6019 if !collected.is_empty() && has_hard_break(&collected[collected.len() - 1].1.content) {
6020 break;
6021 }
6022
6023 let line = lines[idx];
6024 if line.trim().is_empty() {
6025 break;
6026 }
6027
6028 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(line) {
6029 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
6030 break;
6031 }
6032 collected.push((
6033 idx,
6034 BlockquoteLineData::explicit(trim_preserving_hard_break(parsed.content), parsed.prefix.to_string()),
6035 ));
6036 idx += 1;
6037 continue;
6038 }
6039
6040 let lazy_content = line.trim_start();
6041 if is_blockquote_content_boundary(lazy_content) {
6042 break;
6043 }
6044
6045 collected.push((idx, BlockquoteLineData::lazy(trim_preserving_hard_break(lazy_content))));
6046 idx += 1;
6047 }
6048
6049 if collected.is_empty() {
6050 return None;
6051 }
6052
6053 let para_end = collected[collected.len() - 1].0;
6054 if target_idx < para_start || target_idx > para_end {
6055 return None;
6056 }
6057
6058 let line_data: Vec<BlockquoteLineData> = collected.iter().map(|(_, d)| d.clone()).collect();
6059
6060 let fallback_prefix = line_data
6061 .iter()
6062 .find_map(|d| d.prefix.clone())
6063 .unwrap_or_else(|| "> ".to_string());
6064 let explicit_prefix = dominant_blockquote_prefix(&line_data, &fallback_prefix);
6065 let continuation_style = blockquote_continuation_style(&line_data);
6066
6067 let adjusted_line_length = options
6068 .line_length
6069 .saturating_sub(display_len(&explicit_prefix, options.length_mode))
6070 .max(1);
6071
6072 let adjusted_options = if options.sentence_pack && continuation_style == BlockquoteContinuationStyle::Lazy {
6073 ReflowOptions {
6074 first_line_length: if options.line_length == 0 || options.line_length == usize::MAX {
6075 None
6076 } else {
6077 Some(adjusted_line_length)
6078 },
6079 ..options.clone()
6080 }
6081 } else {
6082 ReflowOptions {
6083 line_length: adjusted_line_length,
6084 first_line_length: None,
6085 ..options.clone()
6086 }
6087 };
6088
6089 let styled_lines = reflow_blockquote_content(&line_data, &explicit_prefix, continuation_style, &adjusted_options);
6090
6091 if styled_lines.is_empty() {
6092 return None;
6093 }
6094
6095 let mut start_byte = 0;
6097 for line in lines.iter().take(para_start) {
6098 start_byte += line.len() + 1;
6099 }
6100
6101 let mut end_byte = start_byte;
6102 for line in lines.iter().take(para_end + 1).skip(para_start) {
6103 end_byte += line.len() + 1;
6104 }
6105
6106 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
6107 if !includes_trailing_newline {
6108 end_byte -= 1;
6109 }
6110
6111 let reflowed_joined = styled_lines.join("\n");
6112 let reflowed_text = if includes_trailing_newline {
6113 if reflowed_joined.ends_with('\n') {
6114 reflowed_joined
6115 } else {
6116 format!("{reflowed_joined}\n")
6117 }
6118 } else if reflowed_joined.ends_with('\n') {
6119 reflowed_joined.trim_end_matches('\n').to_string()
6120 } else {
6121 reflowed_joined
6122 };
6123
6124 Some(ParagraphReflow {
6125 start_byte,
6126 end_byte,
6127 reflowed_text,
6128 })
6129}
6130
6131pub fn reflow_paragraph_at_line(content: &str, line_number: usize, line_length: usize) -> Option<ParagraphReflow> {
6149 reflow_paragraph_at_line_with_mode(content, line_number, line_length, ReflowLengthMode::default())
6150}
6151
6152pub fn reflow_paragraph_at_line_with_mode(
6154 content: &str,
6155 line_number: usize,
6156 line_length: usize,
6157 length_mode: ReflowLengthMode,
6158) -> Option<ParagraphReflow> {
6159 let options = ReflowOptions {
6160 line_length,
6161 length_mode,
6162 ..Default::default()
6163 };
6164 reflow_paragraph_at_line_with_options(content, line_number, &options)
6165}
6166
6167pub fn reflow_paragraph_at_line_with_options(
6178 content: &str,
6179 line_number: usize,
6180 options: &ReflowOptions,
6181) -> Option<ParagraphReflow> {
6182 if line_number == 0 {
6183 return None;
6184 }
6185
6186 let lines: Vec<&str> = content.lines().collect();
6187
6188 if line_number > lines.len() {
6190 return None;
6191 }
6192
6193 let target_idx = line_number - 1; let target_line = lines[target_idx];
6195 let trimmed = target_line.trim();
6196
6197 if let Some(blockquote_reflow) = reflow_blockquote_paragraph_at_line(content, &lines, target_idx, options) {
6200 return Some(blockquote_reflow);
6201 }
6202
6203 if is_paragraph_boundary(trimmed, target_line) {
6205 return None;
6206 }
6207
6208 let mut para_start = target_idx;
6210 while para_start > 0 {
6211 let prev_idx = para_start - 1;
6212 let prev_line = lines[prev_idx];
6213 let prev_trimmed = prev_line.trim();
6214
6215 if is_paragraph_boundary(prev_trimmed, prev_line) {
6217 break;
6218 }
6219
6220 para_start = prev_idx;
6221 }
6222
6223 let mut para_end = target_idx;
6225 while para_end + 1 < lines.len() {
6226 let next_idx = para_end + 1;
6227 let next_line = lines[next_idx];
6228 let next_trimmed = next_line.trim();
6229
6230 if is_paragraph_boundary(next_trimmed, next_line) {
6232 break;
6233 }
6234
6235 para_end = next_idx;
6236 }
6237
6238 let paragraph_lines = &lines[para_start..=para_end];
6240
6241 let mut start_byte = 0;
6243 for line in lines.iter().take(para_start) {
6244 start_byte += line.len() + 1; }
6246
6247 let mut end_byte = start_byte;
6248 for line in paragraph_lines {
6249 end_byte += line.len() + 1; }
6251
6252 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
6255
6256 if !includes_trailing_newline {
6258 end_byte -= 1;
6259 }
6260
6261 let paragraph_text = paragraph_lines.join("\n");
6263
6264 let reflowed = reflow_markdown(¶graph_text, options);
6266
6267 let reflowed_text = if includes_trailing_newline {
6271 if reflowed.ends_with('\n') {
6273 reflowed
6274 } else {
6275 format!("{reflowed}\n")
6276 }
6277 } else {
6278 if reflowed.ends_with('\n') {
6280 reflowed.trim_end_matches('\n').to_string()
6281 } else {
6282 reflowed
6283 }
6284 };
6285
6286 Some(ParagraphReflow {
6287 start_byte,
6288 end_byte,
6289 reflowed_text,
6290 })
6291}
6292fn decompose_code_span(raw: &str) -> Option<(&str, &str)> {
6298 let marker_len = raw.bytes().take_while(|&b| b == b'`').count();
6299 if marker_len == 0 {
6300 return None;
6301 }
6302 let marker = &raw[..marker_len];
6303 if raw.len() < marker_len * 2 {
6304 return None;
6305 }
6306 let content = &raw[marker_len..raw.len() - marker_len];
6307 Some((content, marker))
6308}
6309
6310#[cfg(test)]
6311mod tests {
6312 use super::*;
6313
6314 #[test]
6315 fn source_sentence_breaks_respect_custom_abbreviations_and_atomic_constructs() {
6316 let custom = Some(vec!["Widget".to_string()]);
6317 assert_eq!(
6318 join_sentence_lines(&["Use Widget.", "then retry."], CjkSoftBreak::Space, &custom),
6319 "Use Widget. then retry."
6320 );
6321 for lines in [
6322 vec!["Read `literal.", "text` here."],
6323 vec here."],
6324 vec!["Read <span title=\"literal.", "text\"> here."],
6325 ] {
6326 assert_eq!(join_sentence_lines(&lines, CjkSoftBreak::Space, &None), lines.join(" "));
6327 }
6328 assert_eq!(
6329 join_sentence_lines(&["First sentence.", "lowercase follows."], CjkSoftBreak::Space, &None),
6330 "First sentence.\nlowercase follows."
6331 );
6332 }
6333
6334 #[test]
6338 fn preserves_content_accepts_whitespace_changes_and_rejects_the_rest() {
6339 let accepted: &[(&str, &[&str])] = &[
6340 ("one two three", &["one two three"]),
6341 ("one two three", &["one two", "three"]),
6342 ("one two three", &["one", "two", "three"]),
6343 ("one two ", &["one two"]),
6345 ("日本語のテキスト", &["日本語の", "テキスト"]),
6347 ("_First. Second._", &["_First.", "Second._"]),
6349 ];
6350 for (original, reflowed) in accepted {
6351 let reflowed: Vec<String> = reflowed.iter().map(ToString::to_string).collect();
6352 assert!(
6353 preserves_content(original, &reflowed),
6354 "{original:?} -> {reflowed:?} only moves whitespace"
6355 );
6356 }
6357
6358 let rejected: &[(&str, &[&str])] = &[
6359 ("one two three", &["one two"]),
6361 ("one two", &["one two three"]),
6363 ("one two", &["two one"]),
6365 ("_First. Second._", &["_First._", "_Second._"]),
6367 ("alpha and beta", &["alpha", "andbeta"]),
6369 ("mot suivant : autre", &["mot suivant: autre"]),
6371 ];
6372 for (original, reflowed) in rejected {
6373 let reflowed: Vec<String> = reflowed.iter().map(ToString::to_string).collect();
6374 assert!(
6375 !preserves_content(original, &reflowed),
6376 "{original:?} -> {reflowed:?} changes the text, not just its line breaks"
6377 );
6378 }
6379 }
6380
6381 #[test]
6384 fn nested_structure_reads_past_indentation_starting_the_text() {
6385 for indent in [" ", "\t", " "] {
6386 let text = format!("{indent}run `cargo test` now");
6387 let start = text.find('`').unwrap();
6388 let end = text.rfind('`').unwrap() + 1;
6389 assert_eq!(
6390 nested_structure(&text, None, false).code_spans,
6391 vec![(start, end)],
6392 "{text:?}"
6393 );
6394 }
6395 assert!(code_span_runs_into_indentation(&[" `cargo test", " --all`"]));
6396 }
6397
6398 #[test]
6401 fn lines_touching_multiline_code_span_reads_an_indented_code_block_as_code() {
6402 assert_eq!(
6403 lines_touching_multiline_code_span(" `a\n $$x`$$\n"),
6404 [false, false]
6405 );
6406 assert_eq!(lines_touching_multiline_code_span("Run `a\n$$x`$$\n"), [true, true]);
6407 }
6408
6409 #[test]
6411 fn reflow_line_falls_back_to_the_input_when_content_would_change() {
6412 let options = ReflowOptions {
6413 line_length: 40,
6414 ..Default::default()
6415 };
6416 let line = "one two three four five six seven eight nine ten";
6417
6418 assert!(preserves_content(line, &reflow_line(line, &options)));
6419 assert_eq!(reflow_line(line, &options), reflow_line_unchecked(line, &options));
6420 }
6421
6422 #[test]
6423 fn cascade_split_line_handles_a_very_long_line_without_overflowing() {
6424 let words: Vec<String> = (0..4000).map(|i| format!("word{i}")).collect();
6430 let line = words.join(" ");
6431
6432 let options = ReflowOptions {
6433 line_length: 80,
6434 length_mode: ReflowLengthMode::Chars,
6435 ..Default::default()
6436 };
6437 let out = cascade_split_line(&line, &options);
6438
6439 assert!(out.len() > 1, "a very long line should split into many lines");
6440 for segment in &out {
6441 assert!(
6442 display_len(segment, ReflowLengthMode::Chars) <= 80 || !segment.contains(' '),
6443 "each wrapped line should fit the width (or be a single unbreakable token)"
6444 );
6445 }
6446 let rejoined = out.join(" ");
6448 let original_words: Vec<&str> = line.split(' ').collect();
6449 let result_words: Vec<&str> = rejoined.split_whitespace().collect();
6450 assert_eq!(original_words, result_words, "reflow must preserve all words in order");
6451 }
6452
6453 #[test]
6458 fn test_helper_function_text_ends_with_abbreviation() {
6459 let abbreviations = get_abbreviations(&None);
6461
6462 assert!(text_ends_with_abbreviation("Dr.", &abbreviations));
6464 assert!(text_ends_with_abbreviation("word Dr.", &abbreviations));
6465 assert!(text_ends_with_abbreviation("e.g.", &abbreviations));
6466 assert!(text_ends_with_abbreviation("i.e.", &abbreviations));
6467 assert!(text_ends_with_abbreviation("Mr.", &abbreviations));
6468 assert!(text_ends_with_abbreviation("Mrs.", &abbreviations));
6469 assert!(text_ends_with_abbreviation("Ms.", &abbreviations));
6470 assert!(text_ends_with_abbreviation("Prof.", &abbreviations));
6471
6472 assert!(!text_ends_with_abbreviation("etc.", &abbreviations));
6474 assert!(!text_ends_with_abbreviation("paradigms.", &abbreviations));
6475 assert!(!text_ends_with_abbreviation("programs.", &abbreviations));
6476 assert!(!text_ends_with_abbreviation("items.", &abbreviations));
6477 assert!(!text_ends_with_abbreviation("systems.", &abbreviations));
6478 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)); }
6484
6485 #[test]
6486 fn test_footnote_after_period_splits_sentence() {
6487 let text = "First sentence.[^1] Second sentence.";
6491 let sentences = split_into_sentences(text, None, true, CjkSoftBreak::Space);
6492 assert_eq!(
6493 sentences,
6494 vec!["First sentence.[^1]".to_string(), "Second sentence.".to_string()],
6495 "footnote glued to the period should keep the boundary and stay attached to the first sentence"
6496 );
6497 }
6498
6499 #[test]
6500 fn test_multiple_consecutive_footnotes_after_period_splits_sentence() {
6501 let text = "Notes here.[^1][^2] Second sentence.";
6503 let sentences = split_into_sentences(text, None, true, CjkSoftBreak::Space);
6504 assert_eq!(
6505 sentences,
6506 vec!["Notes here.[^1][^2]".to_string(), "Second sentence.".to_string()]
6507 );
6508 }
6509
6510 #[test]
6511 fn test_footnote_before_period_still_splits_sentence() {
6512 let text = "Annotation here[^1]. Second sentence.";
6516 let sentences = split_into_sentences(text, None, true, CjkSoftBreak::Space);
6517 assert_eq!(
6518 sentences,
6519 vec!["Annotation here[^1].".to_string(), "Second sentence.".to_string()]
6520 );
6521 }
6522
6523 #[test]
6524 fn test_mid_sentence_footnote_does_not_split() {
6525 let text = "The system word[^1] more words. Next sentence.";
6528 let sentences = split_into_sentences(text, None, true, CjkSoftBreak::Space);
6529 assert_eq!(
6530 sentences,
6531 vec![
6532 "The system word[^1] more words.".to_string(),
6533 "Next sentence.".to_string()
6534 ]
6535 );
6536 }
6537
6538 #[test]
6539 fn test_bare_numeric_bracket_after_period_does_not_split() {
6540 let text = "Citation here.[1] Second sentence.";
6543 let sentences = split_into_sentences(text, None, true, CjkSoftBreak::Space);
6544 assert_eq!(
6545 sentences,
6546 vec![text.to_string()],
6547 "a bare numeric bracket must not be treated as a sentence boundary"
6548 );
6549 }
6550
6551 #[test]
6552 fn test_footnote_glued_to_following_word_does_not_split() {
6553 let text = "First sentence.[^1]Continued glued text.";
6556 let sentences = split_into_sentences(text, None, true, CjkSoftBreak::Space);
6557 assert_eq!(sentences, vec![text.to_string()]);
6558 }
6559
6560 #[test]
6561 fn test_footnote_at_end_of_text_is_preserved() {
6562 let text = "Sentence.[^1]";
6565 let sentences = split_into_sentences(text, None, true, CjkSoftBreak::Space);
6566 assert_eq!(sentences, vec![text.to_string()]);
6567 }
6568
6569 #[test]
6570 fn test_abbreviation_before_footnote_does_not_split() {
6571 let text = "See the notes, e.g.[^1] this one.";
6574 let sentences = split_into_sentences(text, None, true, CjkSoftBreak::Space);
6575 assert_eq!(
6576 sentences,
6577 vec![text.to_string()],
6578 "e.g. is an abbreviation, not a sentence boundary"
6579 );
6580 }
6581
6582 #[test]
6583 fn sentence_boundary_never_falls_inside_an_atomic_construct() {
6584 let cases = [
6590 "Prefix [link. Still link](https://example.com) tail. Next sentence.",
6591 "Prefix [target](<https://example.com/First. Second>) tail. Next sentence.",
6592 "Prefix [text](url \"Title. More\") tail. Next sentence.",
6593 "Prefix  tail. Next sentence.",
6594 "Prefix [ref text. More][ref] tail. Next sentence.",
6595 "Prefix [collapsed. More][] tail. Next sentence.",
6596 "Prefix [[Page name. Title]] tail. Next sentence.",
6597 "Prefix $x. Y$ tail. Next sentence.",
6598 "Prefix $$x. Y$$ tail. Next sentence.",
6599 "Prefix <span title=\"A. B\">x</span> tail. Next sentence.",
6600 "Prefix `code. Still code` tail. Next sentence.",
6601 ];
6602 for text in cases {
6603 let sentences = split_into_sentences(text, None, true, CjkSoftBreak::Space);
6604 let (head, tail) = text.rsplit_once(" tail. ").expect("case has a tail");
6605 assert_eq!(
6606 sentences,
6607 vec![format!("{head} tail."), tail.to_string()],
6608 "input {text:?}"
6609 );
6610 }
6611
6612 let text = "Prefix [shortcut. More] tail. Next sentence.";
6616 let whole = vec![
6617 "Prefix [shortcut. More] tail.".to_string(),
6618 "Next sentence.".to_string(),
6619 ];
6620 let defined = HashSet::from(["shortcut. more".to_string()]);
6621 assert_eq!(
6622 split_into_sentences(text, Some(&defined), true, CjkSoftBreak::Space),
6623 whole
6624 );
6625 assert_eq!(split_into_sentences(text, None, true, CjkSoftBreak::Space), whole);
6626 assert_eq!(
6627 split_into_sentences(text, Some(&HashSet::new()), true, CjkSoftBreak::Space),
6628 vec!["Prefix [shortcut.", "More] tail.", "Next sentence."]
6629 );
6630 }
6631
6632 #[test]
6633 fn a_sentence_may_open_with_a_link_or_image() {
6634 for text in [
6639 "Opening sentence. [First. Second](https://example.com)",
6640 "Opening sentence. ",
6641 "Opening sentence. [[First. Second]]",
6642 "Opening sentence. [[first-note|First. Second]]",
6643 "Opening sentence. [Ref link][ref]",
6644 "Opening sentence. [](url) continues.",
6647 "Opening sentence. [][ref] continues.",
6648 "Opening sentence. [![First image][img]](url) continues.",
6651 "Opening sentence. [![First image][]](url) continues.",
6652 "Opening sentence. [![First image][img]][ref] continues.",
6653 ] {
6654 let (head, tail) = text.split_once(". ").expect("case has a boundary");
6655 assert_eq!(
6656 split_into_sentences(text, None, true, CjkSoftBreak::Space),
6657 vec![format!("{head}."), tail.to_string()],
6658 "input {text:?}"
6659 );
6660 }
6661 let text = "Opening sentence. [![First image]](url) continues.";
6664 let defined = HashSet::from(["first image".to_string()]);
6665 assert_eq!(
6666 split_into_sentences(text, Some(&defined), true, CjkSoftBreak::Space),
6667 vec!["Opening sentence.", "[![First image]](url) continues."]
6668 );
6669 assert_eq!(
6670 split_into_sentences(text, Some(&HashSet::new()), true, CjkSoftBreak::Space),
6671 vec![text.to_string()],
6672 "an undefined shortcut is bracketed text, and `!` opens no sentence"
6673 );
6674 assert_eq!(
6677 split_into_sentences(
6678 "Opening sentence. [](url) continues.",
6679 None,
6680 true,
6681 CjkSoftBreak::Space
6682 ),
6683 vec](url) continues."]
6684 );
6685 let defined = HashSet::from(["smith 2020".to_string()]);
6688 assert_eq!(
6689 split_into_sentences(
6690 "Claim ends here. [Smith 2020] more text.",
6691 Some(&defined),
6692 true,
6693 CjkSoftBreak::Space
6694 ),
6695 vec!["Claim ends here.", "[Smith 2020] more text."]
6696 );
6697 let none_defined = HashSet::new();
6703 for text in [
6704 "Opening sentence. [first link](https://example.com) continues.",
6705 "Opening sentence. [[first note]] continues.",
6706 "Opening sentence. [[First Note|first note]] continues.",
6707 "Opening sentence. [[Page continues.",
6708 "Opening sentence. [[First] stray]] continues.",
6709 "Opening sentence.  continues.",
6710 "Opening sentence. [1] is the citation.",
6711 "Opening sentence. [First](unterminated",
6712 "Opening sentence. [First][unterminated",
6713 "Opening sentence. [First] (aside) continues.",
6714 "Claim ends here. [Smith 2020]",
6715 "Claim ends here. [Smith 2020] more text.",
6716 "See the RFC. [RFC] More text.",
6717 "Claim ends here. [^Note] more text.",
6718 ] {
6719 assert_eq!(
6720 split_into_sentences(text, Some(&none_defined), true, CjkSoftBreak::Space),
6721 vec![text.to_string()],
6722 "input {text:?}"
6723 );
6724 }
6725 }
6726
6727 #[test]
6728 fn link_opener_is_read_off_the_parse() {
6729 let len = |text: &str, defs: Option<&HashSet<String>>| {
6732 let chars: Vec<char> = text.chars().collect();
6733 let char_offsets = char_byte_offsets(&chars);
6734 let NestedStructure { links, .. } = sentence_structure(text, defs);
6735 let st = SentenceText {
6736 text,
6737 chars: &chars,
6738 char_offsets: &char_offsets,
6739 links: &links,
6740 code_spans: &[],
6741 markers: &[],
6742 marker_closers: &[],
6743 paragraph: None,
6744 cjk_soft_break: CjkSoftBreak::Space,
6745 };
6746 st.link_end_at(0).map_or(0, |end| link_opener_len(&chars, 0, end))
6747 };
6748 let none = HashSet::new();
6749 assert_eq!(len("[text](url)", Some(&none)), 1);
6750 assert_eq!(
6751 len("[text][ref]", Some(&none)),
6752 1,
6753 "a full reference is a link whether or not defined"
6754 );
6755 assert_eq!(len("[text][]", Some(&none)), 1);
6756 assert_eq!(len("", Some(&none)), 2);
6757 assert_eq!(len("[[wiki]]", Some(&none)), 2);
6758 assert_eq!(
6759 len("[[wiki|shown]]", Some(&none)),
6760 7,
6761 "the displayed text starts after the alias pipe"
6762 );
6763 assert_eq!(len("![[img.png|100]]", Some(&none)), 11);
6764 assert_eq!(len("[[wiki|a|b]]", Some(&none)), 7, "the first pipe starts the alias");
6765 assert_eq!(
6766 len("[[wiki|shown]] [[a|b]]", Some(&none)),
6767 7,
6768 "a pipe past the closing `]]` is not this alias"
6769 );
6770 assert_eq!(
6771 len("[a \\] b](url)", Some(&none)),
6772 1,
6773 "an escaped bracket does not close the text"
6774 );
6775 assert_eq!(
6776 len("[](url)", Some(&none)),
6777 1,
6778 "the outer opener is skipped first"
6779 );
6780 for text in [
6784 "[^1]",
6785 "[text](unterminated",
6786 "[text][unterminated",
6787 "[text] (url)",
6788 "[[wiki",
6789 "[[wiki]",
6790 "[[First] stray]]",
6791 "[Smith 2020]",
6792 "[Smith 2020] (see also)",
6793 "[unclosed",
6794 "!bang",
6795 "text",
6796 ] {
6797 assert_eq!(len(text, Some(&none)), 0, "input {text:?}");
6798 }
6799 let smith = HashSet::from(["smith 2020".to_string()]);
6802 assert_eq!(len("[Smith 2020]", Some(&smith)), 1);
6803 assert_eq!(len("[Smith 2020]", None), 1);
6804 }
6805
6806 #[test]
6807 fn sentence_per_line_reflow_breaks_before_a_bracket_only_where_the_check_counts() {
6808 let defined = HashSet::from(["spec".to_string()]);
6816 let options = ReflowOptions {
6817 line_length: 120,
6818 sentence_per_line: true,
6819 defined_references: Some(defined.clone()),
6820 ..Default::default()
6821 };
6822 for (text, expected) in [
6823 (
6824 "Claim ends here. [Smith](https://example.com) more text. Second sentence.",
6825 vec more text.",
6828 "Second sentence.",
6829 ],
6830 ),
6831 (
6832 "Wow! [smith](https://example.com) more text. Second sentence.",
6833 vec more text.", "Second sentence."],
6834 ),
6835 (
6836 "Claim ends here. [smith](https://example.com) more text. Second sentence.",
6837 vec more text.",
6839 "Second sentence.",
6840 ],
6841 ),
6842 (
6843 "Claim ends here. [smith][ref] more text. Second sentence.",
6844 vec!["Claim ends here. [smith][ref] more text.", "Second sentence."],
6845 ),
6846 (
6847 "Claim ends here.  more text. Second sentence.",
6848 vec more text.", "Second sentence."],
6849 ),
6850 (
6851 "Claim ends here.[Link](https://example.com) more text. Second sentence.",
6852 vec more text.",
6854 "Second sentence.",
6855 ],
6856 ),
6857 (
6858 "See the RFC. [RFC] More text. Second sentence.",
6859 vec!["See the RFC. [RFC] More text.", "Second sentence."],
6860 ),
6861 (
6862 "See the spec. [Spec] More text. Second sentence.",
6863 vec!["See the spec.", "[Spec] More text.", "Second sentence."],
6864 ),
6865 (
6866 "See the spec. [spec] more text. Second sentence.",
6867 vec!["See the spec. [spec] more text.", "Second sentence."],
6868 ),
6869 (
6870 "Claim ends here. [[page|Second sentence]] continues. Third sentence.",
6871 vec![
6872 "Claim ends here.",
6873 "[[page|Second sentence]] continues.",
6874 "Third sentence.",
6875 ],
6876 ),
6877 (
6878 "Claim ends here. [[Page|second sentence]] continues. Third sentence.",
6879 vec![
6880 "Claim ends here. [[Page|second sentence]] continues.",
6881 "Third sentence.",
6882 ],
6883 ),
6884 ] {
6885 let lines = reflow_line(text, &options);
6886 assert_eq!(lines, expected, "input {text:?}");
6887 assert_eq!(
6890 split_into_sentences(text, Some(&defined), true, CjkSoftBreak::Space).len(),
6891 expected.len(),
6892 "check count for {text:?}"
6893 );
6894 for line in &lines {
6895 assert_eq!(
6896 split_into_sentences(line, Some(&defined), true, CjkSoftBreak::Space).len(),
6897 1,
6898 "line {line:?} of {text:?}"
6899 );
6900 }
6901 }
6902 }
6903
6904 #[test]
6905 fn sentence_per_line_reflow_holds_atomic_constructs_whole() {
6906 let options = ReflowOptions {
6910 line_length: 80,
6911 sentence_per_line: true,
6912 ..Default::default()
6913 };
6914 let lines = reflow_line(
6915 "Prefix `code. Still code` and [link. Still link](https://example.com) tail. Next sentence.",
6916 &options,
6917 );
6918 assert_eq!(
6919 lines,
6920 vec tail.".to_string(),
6922 "Next sentence.".to_string(),
6923 ]
6924 );
6925
6926 let lines = reflow_line(
6927 "Prefix  and [target](<https://example.com/First. Second>) tail. Next sentence.",
6928 &options,
6929 );
6930 assert_eq!(
6931 lines,
6932 vec and [target](<https://example.com/First. Second>) tail.".to_string(),
6934 "Next sentence.".to_string(),
6935 ]
6936 );
6937
6938 let lines = reflow_line("First one. Then [link](url) second. Third one.", &options);
6941 assert_eq!(
6942 lines,
6943 vec second.".to_string(),
6946 "Third one.".to_string(),
6947 ]
6948 );
6949 }
6950
6951 #[test]
6952 fn test_is_unordered_list_marker() {
6953 assert!(is_unordered_list_marker("- item"));
6955 assert!(is_unordered_list_marker("* item"));
6956 assert!(is_unordered_list_marker("+ item"));
6957 assert!(is_unordered_list_marker("-")); assert!(is_unordered_list_marker("*"));
6959 assert!(is_unordered_list_marker("+"));
6960
6961 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")); }
6972
6973 #[test]
6974 fn test_is_block_boundary() {
6975 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"));
6997 assert!(!is_block_boundary("*emphasis*")); assert!(!is_block_boundary("[link](url)")); assert!(!is_block_boundary("some words here"));
7000 }
7001
7002 #[test]
7003 fn test_definition_list_boundary_in_single_line_paragraph() {
7004 let options = ReflowOptions {
7007 line_length: 80,
7008 ..Default::default()
7009 };
7010 let input = "Term\n: Definition of the term";
7011 let result = reflow_markdown(input, &options);
7012 assert!(
7014 result.contains(": Definition"),
7015 "Definition list item should not be merged into previous line. Got: {result:?}"
7016 );
7017 let lines: Vec<&str> = result.lines().collect();
7018 assert_eq!(lines.len(), 2, "Should remain two separate lines. Got: {lines:?}");
7019 assert_eq!(lines[0], "Term");
7020 assert_eq!(lines[1], ": Definition of the term");
7021 }
7022
7023 #[test]
7024 fn test_is_paragraph_boundary() {
7025 assert!(is_paragraph_boundary("# Heading", "# Heading"));
7027 assert!(is_paragraph_boundary("- item", "- item"));
7028 assert!(is_paragraph_boundary(":::", ":::"));
7029 assert!(is_paragraph_boundary(": definition", ": definition"));
7030
7031 assert!(is_paragraph_boundary("code", " code"));
7033 assert!(is_paragraph_boundary("code", "\tcode"));
7034
7035 assert!(is_paragraph_boundary("| a | b |", "| a | b |"));
7037 assert!(is_paragraph_boundary("a | b", "a | b")); assert!(!is_paragraph_boundary("regular text", "regular text"));
7041 assert!(!is_paragraph_boundary("text", " text")); }
7043
7044 #[test]
7045 fn test_div_marker_boundary_in_reflow_paragraph_at_line() {
7046 let content = "Some paragraph text here.\n\n::: {.callout-note}\nThis is a callout.\n:::\n";
7049 let result = reflow_paragraph_at_line(content, 3, 80);
7051 assert!(result.is_none(), "Div marker line should not be reflowed");
7052 }
7053
7054 #[test]
7055 fn starts_block_construct_detects_block_openers() {
7056 for case in ["- item", "-", "* item", "*", "+ item", "+", "-\titem"] {
7058 assert!(starts_block_construct(case), "bullet: {case:?}");
7059 }
7060 for case in ["1. item", "1) item", "01. x", "000000001. x", "1.\titem"] {
7063 assert!(starts_block_construct(case), "ordered: {case:?}");
7064 }
7065 for case in ["> quote", ">quote", ">"] {
7067 assert!(starts_block_construct(case), "blockquote: {case:?}");
7068 }
7069 for case in ["# heading", "###### h6", "#", "##"] {
7071 assert!(starts_block_construct(case), "heading: {case:?}");
7072 }
7073 for case in ["```", "```rust", "````", "~~~", "~~~text"] {
7075 assert!(starts_block_construct(case), "fence: {case:?}");
7076 }
7077 for case in ["---", "--", "===", "=", "***", "___", "_ _ _", "- - -"] {
7079 assert!(starts_block_construct(case), "setext/thematic: {case:?}");
7080 }
7081 for case in [": definition", ":\tdefinition", ":", ": ", "::: note"] {
7084 assert!(starts_block_construct(case), "definition list: {case:?}");
7085 }
7086 for case in [
7089 "[^1]: text",
7090 "[^note]:",
7091 "[ref]: http://example.com",
7092 "[wat]: url follows",
7093 ] {
7094 assert!(starts_block_construct(case), "definition: {case:?}");
7095 }
7096 for case in ["<div>content", "</div>", "<p>text", "<table>", "<pre>code", "<h1>x"] {
7098 assert!(starts_block_construct(case), "html block: {case:?}");
7099 }
7100 for case in ["<!-- note -->", "<!-->", "<?php", "<!DOCTYPE html>", "<![CDATA[x]]>"] {
7102 assert!(starts_block_construct(case), "untagged html block: {case:?}");
7103 }
7104 for case in [
7107 "--- | ---",
7108 "------ | - |",
7109 "-|-",
7110 "--:|:--",
7111 "- | -",
7112 "---|",
7113 "-- |\t:-:",
7114 ] {
7115 assert!(starts_block_construct(case), "table delimiter row: {case:?}");
7116 }
7117 }
7118
7119 #[test]
7120 fn starts_block_construct_allows_ordinary_prose() {
7121 for case in [
7122 "",
7123 "word",
7124 "-5 degrees",
7125 "--flag",
7126 "-item",
7127 "#hashtag",
7128 "####### seven hashes is not a heading",
7129 "1.5 million",
7130 "1234567890. ten digits is not a list marker",
7131 "0000000001. ten digits is not a list marker either",
7132 "2. item",
7135 "7. item",
7136 "0. item",
7137 "42) x",
7138 "123456. item",
7139 "1.",
7140 "1)",
7141 "123456.",
7142 "123456)",
7143 "1.item",
7144 "1:30 pm",
7145 "*emphasis*",
7146 "**bold** text",
7147 "__bold__ text",
7148 "_emphasis_ text",
7149 "`code` span",
7150 "`` double backtick span ``",
7151 "~~strikethrough~~",
7152 "=x",
7153 "== ==",
7154 "(parenthetical)",
7155 "[link](url)",
7156 "[text][ref] more",
7157 "[bracketed] aside",
7158 "[a](b) [ref]: first bracket is a link, not a label",
7159 "[esc\\]: not a close] text",
7160 "<span>inline</span>",
7161 "<b>bold</b>",
7162 "<https://example.com> autolink",
7163 "<mailto:a@b.com>",
7164 "<notarealtag>",
7165 "--flag | grep x",
7166 "-- | it depends",
7167 "--- | -x |",
7168 ] {
7169 assert!(!starts_block_construct(case), "prose: {case:?}");
7170 }
7171 }
7172
7173 #[test]
7174 fn merge_block_construct_continuations_merges_marker_led_lines() {
7175 let lines = vec![
7176 "First sentence?".to_string(),
7177 "- looks like a list item".to_string(),
7178 "Second sentence.".to_string(),
7179 ];
7180 assert_eq!(
7181 merge_block_construct_continuations(lines),
7182 vec![
7183 "First sentence? - looks like a list item".to_string(),
7184 "Second sentence.".to_string(),
7185 ]
7186 );
7187
7188 let lines = vec!["- real list content".to_string(), "continuation".to_string()];
7191 assert_eq!(
7192 merge_block_construct_continuations(lines.clone()),
7193 lines,
7194 "first line must never be merged"
7195 );
7196
7197 let lines = vec!["prose".to_string(), "1.".to_string(), "[ref]:".to_string()];
7200 assert_eq!(
7201 merge_block_construct_continuations(lines),
7202 vec!["prose 1. [ref]:".to_string()],
7203 "a merge that creates an opener must fold again"
7204 );
7205 }
7206
7207 #[test]
7208 fn merge_block_construct_continuations_folds_a_line_after_a_backslash() {
7209 let lines = vec!["Press the \\".to_string(), "key.".to_string()];
7211 assert_eq!(
7212 merge_block_construct_continuations(lines),
7213 vec!["Press the \\ key.".to_string()]
7214 );
7215
7216 let lines = vec!["A path C:\\\\".to_string(), "here.".to_string()];
7219 assert_eq!(merge_block_construct_continuations(lines.clone()), lines);
7220
7221 let lines = vec!["Line one".to_string(), "ends here \\".to_string()];
7224 assert_eq!(merge_block_construct_continuations(lines.clone()), lines);
7225 }
7226
7227 #[test]
7228 fn wrap_never_starts_a_line_with_a_block_marker() {
7229 let options = ReflowOptions {
7230 line_length: 25,
7231 ..Default::default()
7232 };
7233 let lines = reflow_line(
7236 "Some words here and then - a dash clause that wraps around the limit.",
7237 &options,
7238 );
7239 assert_eq!(
7240 lines,
7241 vec![
7242 "Some words here and",
7243 "then - a dash clause that",
7244 "wraps around the limit."
7245 ]
7246 );
7247
7248 for input in [
7250 "Alpha beta gamma delta epsilon - dash clause here to wrap",
7251 "Alpha beta gamma delta epsilon > quote lookalike here to wrap",
7252 "Alpha beta gamma delta epsilon # heading lookalike here to wrap",
7253 "Alpha beta gamma delta epsilon 1. ordered lookalike here to wrap",
7254 "Alpha beta gamma delta epsilon * star clause here to wrap",
7255 "Alpha beta gamma delta epsilon + plus clause here to wrap",
7256 ] {
7257 for width in 10..40 {
7258 let options = ReflowOptions {
7259 line_length: width,
7260 ..Default::default()
7261 };
7262 for line in reflow_line(input, &options) {
7263 assert!(
7264 !starts_block_construct(&line),
7265 "width {width}: wrapped line opens a block construct: {line:?} (input {input:?})"
7266 );
7267 }
7268 }
7269 }
7270 }
7271
7272 #[test]
7273 fn sentence_per_line_keeps_block_markers_mid_line() {
7274 let options = ReflowOptions {
7275 line_length: 80,
7276 sentence_per_line: true,
7277 ..Default::default()
7278 };
7279 let lines = reflow_line(
7282 "Google Calendar (Can't we get rid of this dependency? - I don't really see the need)",
7283 &options,
7284 );
7285 assert_eq!(
7286 lines,
7287 vec!["Google Calendar (Can't we get rid of this dependency? - I don't really see the need)".to_string()]
7288 );
7289
7290 let lines = reflow_line("See section 4? # is the marker we use. Fine.", &options);
7292 assert_eq!(lines, vec!["See section 4? # is the marker we use.", "Fine."]);
7293
7294 let lines = reflow_line("Is this a problem? > I quote someone here.", &options);
7295 assert_eq!(lines, vec!["Is this a problem? > I quote someone here."]);
7296
7297 let lines = reflow_line("Another case! 1. Not a list. More text follows here.", &options);
7298 for line in &lines {
7299 assert!(
7300 !starts_block_construct(line),
7301 "sentence-per-line output opens a block construct: {line:?}"
7302 );
7303 }
7304 }
7305
7306 fn strict_sentence_lines(input: &str, require_sentence_capital: bool) -> Vec<String> {
7308 let options = ReflowOptions {
7309 line_length: 80,
7310 sentence_per_line: true,
7311 require_sentence_capital,
7312 ..Default::default()
7313 };
7314 reflow_line(input, &options)
7315 }
7316
7317 #[test]
7318 fn strict_mode_lets_a_sentence_open_with_a_number() {
7319 for (input, expected) in [
7323 (
7324 "The number of items was 5. 2 of them failed.",
7325 vec!["The number of items was 5.", "2 of them failed."],
7326 ),
7327 (
7328 "Sometimes we have 2. 3 might be here.",
7329 vec!["Sometimes we have 2.", "3 might be here."],
7330 ),
7331 (
7332 "The number of items was 5. 2nd sentence.",
7333 vec!["The number of items was 5.", "2nd sentence."],
7334 ),
7335 (
7336 "Released in 2020. 3 of them failed.",
7337 vec!["Released in 2020.", "3 of them failed."],
7338 ),
7339 (
7340 "First sentence. 2nd sentence.",
7341 vec!["First sentence.", "2nd sentence."],
7342 ),
7343 (
7344 "We met at 6:00 sharp. 6:00 is early.",
7345 vec!["We met at 6:00 sharp.", "6:00 is early."],
7346 ),
7347 ("Pi is 3.14 roughly. Next.", vec!["Pi is 3.14 roughly.", "Next."]),
7348 (
7351 "A \"Is this a test?\" 2020 was memorable.",
7352 vec!["A \"Is this a test?\"", "2020 was memorable."],
7353 ),
7354 ] {
7355 assert_eq!(strict_sentence_lines(input, true), expected, "input {input:?}");
7356 }
7357
7358 for input in [
7361 "The count was 5. and that was all.",
7362 "See fig. 3 for details.",
7363 "See no. 5 in the list.",
7364 "See ch. 12 and vol. 3 for more.",
7365 "A \"Is this a test?\" guide to it.",
7366 ] {
7367 assert_eq!(
7368 strict_sentence_lines(input, true),
7369 vec![input.to_string()],
7370 "input {input:?}"
7371 );
7372 }
7373 }
7374
7375 #[test]
7376 fn sentence_never_opens_with_an_ordered_list_marker() {
7377 for (input, require_capital, expected) in [
7384 (
7385 "Steps: 1. Do this. 2. Do that.",
7386 true,
7387 vec!["Steps: 1.", "Do this. 2.", "Do that."],
7388 ),
7389 (
7390 "First sentence. 1. Do that.",
7391 true,
7392 vec!["First sentence. 1.", "Do that."],
7393 ),
7394 ("Do this! 2. Do that.", true, vec!["Do this! 2.", "Do that."]),
7395 ("Do this. 12) Do that.", true, vec!["Do this. 12) Do that."]),
7396 ("Do this. 2. do that.", true, vec!["Do this. 2. do that."]),
7397 ("Do this. 2. do that.", false, vec!["Do this. 2.", "do that."]),
7398 (
7399 "Twelve. 1234567890. next one here.",
7400 true,
7401 vec!["Twelve. 1234567890. next one here."],
7402 ),
7403 ("Do this. 2 more times.", true, vec!["Do this.", "2 more times."]),
7406 ("How many? 2.", true, vec!["How many?", "2."]),
7407 ("第一句。 2. Do that.", true, vec!["第一句。 2.", "Do that."]),
7409 ("第一句。 2) 第二句。", true, vec!["第一句。 2) 第二句。"]),
7410 ("第一句。 2 more.", true, vec!["第一句。", "2 more."]),
7411 ("第一句。 第二句。", true, vec!["第一句。", "第二句。"]),
7412 ] {
7413 let lines = strict_sentence_lines(input, require_capital);
7414 assert_eq!(lines, expected, "input {input:?}, require capital {require_capital}");
7415 for line in &lines {
7416 let chars: Vec<char> = line.chars().collect();
7417 assert!(
7418 !opens_ordered_list_marker(&chars),
7419 "line opens with an ordered-list marker: {line:?} (input {input:?})"
7420 );
7421 }
7422 }
7423 }
7424
7425 #[test]
7426 fn opens_ordered_list_marker_matches_the_marker_shape() {
7427 let chars = |s: &str| s.chars().collect::<Vec<char>>();
7428 for text in [
7429 "2. x",
7430 "1) x",
7431 "12. x",
7432 "1.\tx",
7433 "2.\nx",
7434 "1)\nx",
7435 "1234567890. x",
7436 "0. x",
7437 ] {
7438 assert!(opens_ordered_list_marker(&chars(text)), "{text:?} is a marker");
7439 }
7440 for text in ["2.x", "2.", "2)", "2 x", "x. y", "", " 2. x", "2.5 x", "-2. x"] {
7441 assert!(!opens_ordered_list_marker(&chars(text)), "{text:?} is not a marker");
7442 }
7443 }
7444
7445 #[test]
7446 fn inline_math_directly_after_display_math_stays_atomic() {
7447 let options = ReflowOptions {
7455 line_length: 8,
7456 ..Default::default()
7457 };
7458 let lines = reflow_line("$$a$$$bb cc dd$ x", &options);
7459 assert_eq!(lines, vec!["$$a$$$bb cc dd$".to_string(), "x".to_string()]);
7460 }
7461
7462 #[test]
7463 fn test_code_span_parsing() {
7464 let elements = parse_markdown_elements_inner("`code`", false, false, None);
7466 assert_eq!(elements.len(), 1);
7467 assert!(matches!(&elements[0], Element::Code { content, marker } if content == "code" && marker == "`"));
7468
7469 let elements = parse_markdown_elements_inner("``code``", false, false, None);
7471 assert_eq!(elements.len(), 1);
7472 assert!(matches!(&elements[0], Element::Code { content, marker } if content == "code" && marker == "``"));
7473
7474 let elements = parse_markdown_elements_inner("``code`inside``", false, false, None);
7476 assert_eq!(elements.len(), 1);
7477 assert!(
7478 matches!(&elements[0], Element::Code { content, marker } if content == "code`inside" && marker == "``")
7479 );
7480
7481 let elements = parse_markdown_elements_inner("`` code ``", false, false, None);
7483 assert_eq!(elements.len(), 1);
7484 assert!(matches!(&elements[0], Element::Code { content, marker } if content == " code " && marker == "``"));
7485
7486 let elements = parse_markdown_elements_inner("`unclosed", false, false, None);
7488 assert_eq!(elements.len(), 1);
7489 assert!(matches!(&elements[0], Element::Text(s) if s == "`unclosed"));
7490
7491 let elements = parse_markdown_elements_inner("`unclosed [link](url)", false, false, None);
7493 assert_eq!(elements.len(), 2);
7495 assert!(matches!(&elements[0], Element::Text(s) if s == "`unclosed "));
7496 assert!(matches!(&elements[1], Element::Link(s) if s == "[link](url)"));
7497 }
7498
7499 #[test]
7500 fn test_reflow_performance_long_input() {
7501 let mut text = String::new();
7504 for i in 1..400 {
7505 let backticks = "`".repeat(i);
7506 text.push_str(&backticks);
7507 text.push(' ');
7508 }
7509
7510 let start = std::time::Instant::now();
7511 let elements = parse_markdown_elements_inner(&text, false, false, None);
7512 let duration = start.elapsed();
7513
7514 assert!(duration.as_millis() < 100, "Parsing took too long: {duration:?}");
7516 assert!(!elements.is_empty());
7517 }
7518
7519 #[test]
7520 fn test_reflow_performance_display_math_heavy() {
7521 let text = "$$a$$".repeat(4000);
7526
7527 let start = std::time::Instant::now();
7528 let elements = parse_markdown_elements_inner(&text, false, false, None);
7529 let duration = start.elapsed();
7530
7531 assert!(duration.as_millis() < 100, "Parsing took too long: {duration:?}");
7532 assert_eq!(elements.len(), 4000);
7533 }
7534
7535 #[test]
7536 fn inline_math_len_at_start_matches_regex_at_slice_start() {
7537 let alphabet = ['$', 'a', ' '];
7542 let mut inputs: Vec<String> = vec![String::new()];
7543 let mut frontier: Vec<String> = vec![String::new()];
7544 for _ in 0..6 {
7545 let mut longer = Vec::new();
7546 for prefix in &frontier {
7547 for ch in alphabet {
7548 let mut s = prefix.clone();
7549 s.push(ch);
7550 longer.push(s);
7551 }
7552 }
7553 inputs.extend(longer.iter().cloned());
7554 frontier = longer;
7555 }
7556 inputs.push("$αβ$x".to_string());
7558 inputs.push("$α$$".to_string());
7559
7560 for s in &inputs {
7561 let expected = INLINE_MATH_REGEX
7562 .find(s)
7563 .ok()
7564 .flatten()
7565 .filter(|m| m.start() == 0)
7566 .map(|m| m.end());
7567 assert_eq!(inline_math_len_at_start(s), expected, "input: {s:?}");
7568 }
7569 }
7570
7571 #[test]
7572 fn inline_math_probe_after_dollar_matches_uncached_parse() {
7573 let cases = [
7579 ("$$a$$$b c$ x", r#"[DisplayMath("a"), InlineMath("b c"), Text(" x")]"#),
7580 (
7581 "$$a$$$b$ $$a$$$b$",
7582 r#"[DisplayMath("a"), InlineMath("b"), Text(" "), DisplayMath("a"), InlineMath("b")]"#,
7583 ),
7584 (
7586 "$$a$$$ x $y z$",
7587 r#"[DisplayMath("a"), InlineMath(" x "), Text("y z$")]"#,
7588 ),
7589 ("$$a$$$$ x", r#"[DisplayMath("a"), Text("$$ x")]"#),
7591 ("$$a$$$$b$$ x", r#"[DisplayMath("a"), DisplayMath("b"), Text(" x")]"#),
7592 (
7594 "$a$$b$$c$$d$ tail",
7595 r#"[Text("$a"), DisplayMath("b"), Text("c$$d$ tail")]"#,
7596 ),
7597 ];
7598 for (input, expected) in cases {
7599 let elements = parse_markdown_elements_inner(input, false, false, None);
7600 assert_eq!(format!("{elements:?}"), expected, "input: {input:?}");
7601 }
7602 }
7603
7604 #[test]
7605 fn test_atomic_spans() {
7606 let text_emphasis = "hello **word1 word2**";
7608
7609 let options_disabled = ReflowOptions {
7610 line_length: 18,
7611 atomic_spans: true,
7612 ..Default::default()
7613 };
7614 let lines_disabled = reflow_line(text_emphasis, &options_disabled);
7615 assert_eq!(lines_disabled, vec!["hello", "**word1 word2**"]);
7616
7617 let options_enabled = ReflowOptions {
7618 line_length: 18,
7619 atomic_spans: false,
7620 ..Default::default()
7621 };
7622 let lines_enabled = reflow_line(text_emphasis, &options_enabled);
7623 assert_eq!(lines_enabled, vec!["hello **word1", "word2**"]);
7624
7625 let text_code = "hello `word1 word2`";
7627
7628 let lines_code_disabled = reflow_line(text_code, &options_disabled);
7629 assert_eq!(lines_code_disabled, vec!["hello", "`word1 word2`"]);
7630
7631 let lines_code_enabled = reflow_line(text_code, &options_enabled);
7632 assert_eq!(lines_code_enabled, vec!["hello `word1", "word2`"]);
7633
7634 let text_code_padding = "hello `` `word1` `word2` ``";
7636 let lines_padding_enabled = reflow_line(text_code_padding, &options_enabled);
7637 assert_eq!(lines_padding_enabled, vec![r#"hello `` `word1`"#, r#"`word2` ``"#]);
7638
7639 let text_attached = "**one two**,"; let options_11 = ReflowOptions {
7644 line_length: 11,
7645 atomic_spans: true,
7646 ..Default::default()
7647 };
7648 assert_eq!(reflow_line(text_attached, &options_11), vec!["**one two**,"]);
7649
7650 let options_10 = ReflowOptions {
7652 line_length: 10,
7653 atomic_spans: true,
7654 ..Default::default()
7655 };
7656 assert_eq!(reflow_line(text_attached, &options_10), vec!["**one", "two**,"]);
7657 }
7658
7659 #[test]
7660 fn test_emphasis_containing_markers_is_not_split() {
7661 let options = ReflowOptions {
7662 line_length: 5,
7663 atomic_spans: false,
7664 ..Default::default()
7665 };
7666 let lines = reflow_line(r#"*foo \*bar*"#, &options);
7668 assert_eq!(lines, vec![r#"*foo \*bar*"#.to_string()]);
7669 }
7670
7671 fn semantic_shape(markdown: &str) -> String {
7676 let mut options = Options::empty();
7677 options.insert(Options::ENABLE_STRIKETHROUGH);
7678 let mut out = String::new();
7679 let push_prose = |out: &mut String, text: &str| {
7680 for c in text.chars() {
7681 if c.is_whitespace() {
7682 if !out.ends_with(char::is_whitespace) {
7683 out.push(' ');
7684 }
7685 } else {
7686 out.push(c);
7687 }
7688 }
7689 };
7690 for event in Parser::new_ext(markdown, options) {
7691 match event {
7692 Event::Text(text) => push_prose(&mut out, &text),
7693 Event::SoftBreak | Event::HardBreak => push_prose(&mut out, " "),
7694 Event::Code(code) => out.push_str(&format!("<code>{code}</code>")),
7696 Event::Start(tag) => out.push_str(&format!("<{tag:?}>")),
7697 Event::End(tag) => out.push_str(&format!("</{tag:?}>")),
7698 other => out.push_str(&format!("{other:?}")),
7699 }
7700 }
7701 out.trim().to_string()
7702 }
7703
7704 #[test]
7705 fn test_wrapping_a_span_never_changes_what_it_parses_to() {
7706 let corpus = [
7710 "_This is a very, very, very, very, very long line with some `code` inside._",
7711 "_alpha beta gamma delta epsilon `a b` zeta eta theta iota kappa lambda_",
7712 "**strong text with `code` and more words than fit on one single line**",
7713 "~~struck text with `code` and more words than fit on one single line~~",
7714 "_emphasis with **nested strong that is quite long** and trailing words_",
7715 "***A doubly nested bold italic span with more words than fit on a line***",
7718 "___Another doubly nested span with more words than fit on a single line___",
7719 "**_mixed strong then emphasis with more words than fit on a single line_**",
7720 "*__mixed emphasis then strong with more words than fit on a single line__*",
7721 "**~~strong strikethrough with more words than fit on a single line here~~**",
7722 "**a * b with a stray marker and plenty more words to pass the budget**",
7725 "_foo `a` bar `b` baz qux quux corge grault garply waldo fred plugh xyzzy_",
7726 "text before _a long emphasis with `code` inside of it here_ and after",
7727 "(_a parenthesized long emphasis with `code` inside of it right here_)",
7728 r#"*foo \*bar baz qux quux corge grault garply waldo fred plugh xyzzy*"#,
7729 "_tab\tseparated `a\tb` words spread out over quite a long emphasis span_",
7730 "_This has [text](<a b c d e f g h i j k>) and trailing words to wrap._",
7733 "_This has [`code`](<a b c d e f g h i j k>) and trailing words to wrap._",
7734 r#"_See [x](https://example.com "a long link title here") and `code` too._"#,
7735 "_A [link with a long label](https://example.com/path) and `code` here._",
7736 "_An image  plus `code` and more text_",
7737 ];
7738 for text in corpus {
7739 let expected = semantic_shape(text);
7740 for line_length in [20, 30, 40, 80] {
7741 for atomic_spans in [true, false] {
7742 let options = ReflowOptions {
7743 line_length,
7744 atomic_spans,
7745 ..Default::default()
7746 };
7747 let wrapped = reflow_line(text, &options).join("\n");
7748 assert_eq!(
7749 semantic_shape(&wrapped),
7750 expected,
7751 "reflow changed the parse of {text:?} at line_length={line_length} \
7752 atomic_spans={atomic_spans}\n wrapped: {wrapped:?}"
7753 );
7754 }
7755 }
7756 }
7757 }
7758
7759 #[test]
7760 fn test_wrapping_a_span_keeps_whole_the_constructs_pulldown_cannot_see() {
7761 let cases = [
7765 (
7766 "_alpha beta gamma [[a wiki link]] delta epsilon zeta eta_",
7767 "[[a wiki link]]",
7768 ),
7769 (
7770 "_alpha beta gamma {{< foo bar >}} delta epsilon zeta eta_",
7771 "{{< foo bar >}}",
7772 ),
7773 ("_alpha beta gamma $a + b$ delta epsilon zeta eta theta_", "$a + b$"),
7774 ("_alpha beta gamma $$a + b$$ delta epsilon zeta eta theta_", "$$a + b$$"),
7775 ];
7776 for (text, construct) in cases {
7777 for line_length in [12, 20, 30] {
7778 for atomic_spans in [true, false] {
7779 let options = ReflowOptions {
7780 line_length,
7781 atomic_spans,
7782 ..Default::default()
7783 };
7784 let wrapped = reflow_line(text, &options).join("\n");
7785 assert!(
7786 wrapped.contains(construct),
7787 "{construct} was broken at line_length={line_length} \
7788 atomic_spans={atomic_spans}: {wrapped:?}"
7789 );
7790 }
7791 }
7792 }
7793 }
7794
7795 #[test]
7796 fn test_overlong_emphasis_with_nested_code_span_wraps() {
7797 let options = ReflowOptions {
7801 line_length: 80,
7802 atomic_spans: true,
7803 ..Default::default()
7804 };
7805 let text = "_This is a very, very, very, very, very, very, very long line that exceeds 80 characters with some `code` inside._";
7806 let lines = reflow_line(text, &options);
7807 assert_eq!(
7808 lines,
7809 vec![
7810 "_This is a very, very, very, very, very, very, very long line that exceeds 80",
7811 "characters with some `code` inside._",
7812 ]
7813 );
7814 }
7815
7816 #[test]
7817 fn test_overlong_emphasis_with_nested_strong_wraps() {
7818 let options = ReflowOptions {
7820 line_length: 80,
7821 atomic_spans: true,
7822 ..Default::default()
7823 };
7824 let text = "_This is a very, very, very, very, very, very, very long line that exceeds 80 characters with some **bold** inside._";
7825 let lines = reflow_line(text, &options);
7826 assert_eq!(
7827 lines,
7828 vec![
7829 "_This is a very, very, very, very, very, very, very long line that exceeds 80",
7830 "characters with some **bold** inside._",
7831 ]
7832 );
7833 }
7834
7835 #[test]
7836 fn test_overlong_doubly_nested_span_wraps() {
7837 let options = ReflowOptions {
7842 line_length: 80,
7843 atomic_spans: true,
7844 ..Default::default()
7845 };
7846 let body = "This is a very, very, very, very, very, very, very, very, very, very long line that is emphasised.";
7847 for (open, close) in [
7848 ("***", "***"),
7849 ("___", "___"),
7850 ("**_", "_**"),
7851 ("*__", "__*"),
7852 ("**~~", "~~**"),
7853 ] {
7854 let text = format!("{open}{body}{close}");
7855 assert!(text.len() > options.line_length, "case must start over budget");
7856 let lines = reflow_line(&text, &options);
7857 assert!(
7858 lines.len() > 1,
7859 "{open}...{close} should wrap but stayed on one line: {lines:?}"
7860 );
7861 assert!(
7862 lines.iter().all(|line| line.len() <= options.line_length),
7863 "{open}...{close} left a line over the budget: {lines:?}"
7864 );
7865 assert_eq!(
7866 lines.join(" "),
7867 text,
7868 "{open}...{close} wrapping must only replace a space with a newline"
7869 );
7870 }
7871 }
7872
7873 #[test]
7874 fn test_overlong_span_with_stray_marker_stays_whole() {
7875 let options = ReflowOptions {
7879 line_length: 40,
7880 atomic_spans: true,
7881 ..Default::default()
7882 };
7883 let text = "**alpha * beta gamma delta epsilon zeta eta theta iota kappa**";
7884 let lines = reflow_line(text, &options);
7885 assert_eq!(lines, vec![text], "stray marker must keep the span whole");
7886 }
7887
7888 #[test]
7889 fn test_overlong_span_never_breaks_inside_a_nested_reference_link() {
7890 let options = ReflowOptions {
7896 line_length: 30,
7897 atomic_spans: true,
7898 defined_references: Some(HashSet::from([
7899 "ref".to_string(),
7900 "one two three four five six seven".to_string(),
7902 ])),
7903 ..Default::default()
7904 };
7905 for (text, link) in [
7906 (
7907 "_**alpha [one two three four five six seven][ref] beta gamma delta**_",
7908 "[one two three four five six seven][ref]",
7909 ),
7910 (
7911 "**alpha [one two three four five six seven][ref] beta gamma delta**",
7912 "[one two three four five six seven][ref]",
7913 ),
7914 (
7915 "_**alpha ![one two three four five six seven][ref] beta gamma delta**_",
7916 "![one two three four five six seven][ref]",
7917 ),
7918 (
7919 "_**alpha [one two three four five six seven][] beta gamma delta**_",
7920 "[one two three four five six seven][]",
7921 ),
7922 (
7923 "_**alpha [one two three four five six seven] beta gamma delta**_",
7924 "[one two three four five six seven]",
7925 ),
7926 ] {
7927 let lines = reflow_line(text, &options);
7928 assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
7929 assert!(
7930 lines.iter().any(|line| line.contains(link)),
7931 "{link} must stay on one line: {lines:?}"
7932 );
7933 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
7934 }
7935 }
7936
7937 #[test]
7938 fn test_overlong_span_breaks_inside_an_undefined_shortcut_reference() {
7939 let options = ReflowOptions {
7943 line_length: 30,
7944 atomic_spans: true,
7945 defined_references: Some(HashSet::new()),
7946 ..Default::default()
7947 };
7948 let text = "_**alpha [one two three four five six seven] beta gamma delta**_";
7949 let lines = reflow_line(text, &options);
7950 assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
7951 assert!(
7952 !lines
7953 .iter()
7954 .any(|line| line.contains("[one two three four five six seven]")),
7955 "an undefined shortcut is prose and should break: {lines:?}"
7956 );
7957 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
7958 }
7959
7960 #[test]
7961 fn test_overlong_span_never_breaks_inside_a_nested_attr_list() {
7962 let attr = "{.highlight key=\"a b c\"}";
7966 let text = format!("_**alpha beta gamma delta epsilon zeta{attr} eta theta iota kappa**_");
7967 let options = ReflowOptions {
7968 line_length: 20,
7969 atomic_spans: true,
7970 attr_lists: true,
7971 ..Default::default()
7972 };
7973 let lines = reflow_line(&text, &options);
7974 assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
7975 assert!(
7976 lines.iter().any(|line| line.contains(attr)),
7977 "attr list must stay on one line: {lines:?}"
7978 );
7979 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
7980
7981 let plain = ReflowOptions {
7984 attr_lists: false,
7985 ..options
7986 };
7987 let lines = reflow_line(&text, &plain);
7988 assert!(
7989 !lines.iter().any(|line| line.contains(attr)),
7990 "without the flavor the braces are prose and should break: {lines:?}"
7991 );
7992 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
7993 }
7994
7995 #[test]
7996 fn test_overlong_emphasis_never_breaks_inside_nested_code_span() {
7997 let options = ReflowOptions {
8001 line_length: 30,
8002 atomic_spans: true,
8003 ..Default::default()
8004 };
8005 let text = "_alpha beta gamma delta epsilon `a b` zeta eta theta iota kappa_";
8006 let lines = reflow_line(text, &options);
8007 assert!(lines.len() > 1, "over-long emphasis should wrap: {lines:?}");
8008 assert!(
8009 lines.iter().any(|line| line.contains("`a b`")),
8010 "nested code span must stay whole with its interior spaces: {lines:?}"
8011 );
8012 for line in &lines {
8013 assert_eq!(
8014 line.matches('`').count() % 2,
8015 0,
8016 "no line may contain half a code span: {line:?}"
8017 );
8018 }
8019 }
8020
8021 #[test]
8022 fn test_definition_list_marker_does_not_start_line() {
8023 let options = ReflowOptions {
8024 line_length: 20,
8025 ..Default::default()
8026 };
8027 let lines = reflow_line("This is a term and : definition here.", &options);
8029 for line in &lines {
8030 assert!(
8031 !line.trim_start().starts_with(": "),
8032 "Wrapped line should not start with definition marker: {line}"
8033 );
8034 }
8035 }
8036
8037 #[test]
8038 fn test_div_marker_does_not_start_line() {
8039 let options = ReflowOptions {
8040 line_length: 20,
8041 ..Default::default()
8042 };
8043 let lines = reflow_line("This is some text with ::: class marker.", &options);
8045 for line in &lines {
8046 assert!(
8047 !line.trim_start().starts_with(":::"),
8048 "Wrapped line should not start with div marker: {line}"
8049 );
8050 }
8051 }
8052
8053 fn reflow_markdown_preserving_rendering(content: &str, options: &ReflowOptions) -> String {
8066 let render = |text: &str| {
8067 let mut parser_options = Options::empty();
8068 parser_options.insert(Options::ENABLE_STRIKETHROUGH);
8069 parser_options.insert(Options::ENABLE_DEFINITION_LIST);
8070 let mut html = String::new();
8071 pulldown_cmark::html::push_html(&mut html, Parser::new_ext(text, parser_options));
8072 html.retain(|c| !c.is_ascii_whitespace());
8073 html
8074 };
8075 let reflowed = reflow_markdown(content, options);
8076 assert_eq!(
8077 render(content),
8078 render(&reflowed),
8079 "rendering changed for input: {content:?}"
8080 );
8081 reflowed
8082 }
8083
8084 #[test]
8092 fn a_paragraph_part_never_ends_inside_an_atomic_construct() {
8093 let options = ReflowOptions {
8094 line_length: 0,
8095 sentence_per_line: true,
8096 ..Default::default()
8097 };
8098
8099 let cases = [
8100 ("[(完成。)\n继续。](url)", "[(完成。) 继续。](url)"),
8102 ("`(完成。)\n继续。`", "`(完成。) 继续。`"),
8104 ("[Done.\nNext](url)", "[Done. Next](url)"),
8107 ("(完成。)\n继续。", "(完成。)\n继续。"),
8109 ("Done. Next", "Done.\nNext"),
8110 ];
8111
8112 for (input, expected) in cases {
8113 assert_eq!(
8114 reflow_markdown_preserving_rendering(input, &options),
8115 expected,
8116 "input: {input:?}"
8117 );
8118 }
8119 }
8120
8121 #[test]
8124 fn a_bare_colon_line_ends_the_paragraph_above_it() {
8125 let options = ReflowOptions {
8126 line_length: 0,
8127 sentence_per_line: true,
8128 ..Default::default()
8129 };
8130
8131 for input in ["文章です。\n:", "Done!\n:", "完成。\n:", "Term\n:\nNext term\n: text"] {
8132 assert_eq!(
8133 reflow_markdown_preserving_rendering(input, &options),
8134 input,
8135 "input: {input:?}"
8136 );
8137 }
8138 }
8139
8140 #[test]
8145 fn a_colon_leading_the_first_line_of_a_paragraph_is_prose() {
8146 let options = ReflowOptions {
8147 line_length: 0,
8148 sentence_per_line: true,
8149 ..Default::default()
8150 };
8151
8152 for (input, expected) in [
8153 (
8154 ":warning: First sentence. Second sentence.",
8155 ":warning: First sentence.\nSecond sentence.",
8156 ),
8157 (
8158 "Term\n\n:warning: First sentence. Second sentence.",
8159 "Term\n\n:warning: First sentence.\nSecond sentence.",
8160 ),
8161 (
8162 "# Heading\n:warning: First sentence. Second sentence.",
8163 "# Heading\n:warning: First sentence.\nSecond sentence.",
8164 ),
8165 ] {
8166 assert_eq!(
8167 reflow_markdown_preserving_rendering(input, &options),
8168 expected,
8169 "input: {input:?}"
8170 );
8171 }
8172 for input in [
8173 "Term\n:warning: First sentence. Second sentence.",
8174 ":warning: First sentence.\n:note: Second sentence.",
8175 "- term\n :warning: First sentence. Second sentence.",
8176 ] {
8177 assert_eq!(
8178 reflow_markdown_preserving_rendering(input, &options),
8179 input,
8180 "input: {input:?}"
8181 );
8182 }
8183 }
8184}
8185
8186#[cfg(test)]
8187mod shortcode_delimiter_tests {
8188 use super::*;
8189
8190 #[test]
8191 fn reflow_preserves_shortcode_arguments_containing_closing_markers() {
8192 let options = ReflowOptions {
8193 line_length: 30,
8194 ..Default::default()
8195 };
8196 for (open, close) in [("{{<", ">}}"), ("{{%", "%}}")] {
8197 for quote in ["`", "\""] {
8198 let tag =
8199 format!("{open} note title={quote}literal {close} friend with some words and more{quote} {close}");
8200 let line = format!("Before {tag} after with more prose words.");
8201 let fixed = reflow_line(&line, &options).join("\n");
8202 assert!(fixed.contains(&tag), "{line:?} -> {fixed:?}");
8203 assert!(fixed.contains('\n'), "surrounding prose should still reflow");
8204 }
8205 }
8206 }
8207}