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();
3348 hashes <= 6
3349 }
3350 b'`' => bytes.iter().take_while(|&&b| b == b'`').count() >= 3,
3351 b'~' => bytes.iter().take_while(|&&b| b == b'~').count() >= 3,
3352 b'0'..=b'9' => {
3359 let digits = bytes.iter().take_while(|b| b.is_ascii_digit()).count();
3360 digits <= 9
3361 && text[..digits].trim_start_matches('0') == "1"
3362 && bytes.len() > digits + 1
3363 && (bytes[digits] == b'.' || bytes[digits] == b')')
3364 && (bytes[digits + 1] == b' ' || bytes[digits + 1] == b'\t')
3365 }
3366 b'[' => {
3374 let mut escaped = false;
3375 let mut label_close = None;
3376 for (i, &b) in bytes.iter().enumerate().skip(1) {
3377 if escaped {
3378 escaped = false;
3379 } else if b == b'\\' {
3380 escaped = true;
3381 } else if b == b']' {
3382 label_close = Some(i);
3383 break;
3384 }
3385 }
3386 label_close.is_some_and(|i| bytes.get(i + 1) == Some(&b':'))
3387 }
3388 b'<' => {
3392 crate::utils::html_block::parse_html_block_start(text).is_some()
3393 || crate::utils::html_block::opens_untagged_html_block(text)
3394 }
3395 _ => false,
3396 }
3397}
3398
3399fn ends_with_unescaped_backslash(line: &str) -> bool {
3402 line.bytes().rev().take_while(|&b| b == b'\\').count() % 2 == 1
3403}
3404
3405fn merge_block_construct_continuations(lines: Vec<String>) -> Vec<String> {
3418 let mut merged: Vec<String> = Vec::with_capacity(lines.len());
3419 for line in lines {
3420 merged.push(line);
3421 while merged.len() > 1
3425 && (starts_block_construct(merged.last().expect("non-empty"))
3426 || ends_with_unescaped_backslash(&merged[merged.len() - 2]))
3427 {
3428 let last = merged.pop().expect("non-empty");
3429 let prev = merged.last_mut().expect("len > 1");
3430 prev.push(' ');
3431 prev.push_str(last.trim_start());
3432 }
3433 }
3434 merged
3435}
3436
3437fn keep_first_line_out_of_html_block(mut lines: Vec<String>) -> Vec<String> {
3447 let Some(first) = lines.first() else {
3448 return lines;
3449 };
3450 if !crate::utils::html_block::opens_tag_line_html_block(first.trim_start()) {
3451 return lines;
3452 }
3453 if lines.len() > 1 {
3454 let next = lines.remove(1);
3455 lines[0].push(' ');
3456 lines[0].push_str(next.trim_start());
3457 return lines;
3458 }
3459 let tag = lines.remove(0);
3460 let indent = tag.len() - tag.trim_start().len();
3461 let tag_len = crate::utils::html_block::complete_tag_len(&tag[indent..]).unwrap_or(0);
3462 let mut quote = None;
3463 let mut split = None;
3464 let mut quoted_split = None;
3465 for (offset, byte) in tag.bytes().enumerate().skip(indent).take(tag_len) {
3466 let breakable = matches!(byte, b' ' | b'\t') && !starts_block_construct(&tag[offset..]);
3467 match (quote, byte) {
3468 (None, b'"' | b'\'') => quote = Some(byte),
3469 (Some(open), _) if open == byte => quote = None,
3470 (None, _) if breakable => split = Some(offset),
3471 (Some(_), _) if breakable => quoted_split = Some(offset),
3472 _ => {}
3473 }
3474 }
3475 match split.or(quoted_split) {
3476 Some(offset) => vec![
3477 tag[..offset].trim_end().to_string(),
3478 tag[offset..].trim_start().to_string(),
3479 ],
3480 None => vec![tag],
3481 }
3482}
3483
3484fn elements_source_text(elements: &[Element]) -> (String, Vec<(usize, usize)>) {
3492 let mut text = String::new();
3493 let mut ranges = Vec::with_capacity(elements.len());
3494 for (idx, element) in elements.iter().enumerate() {
3495 let gap = source_gap_before(elements, idx);
3496 let piece = match element {
3497 Element::Text(content) => content.clone(),
3499 Element::Italic { content, underscore } => {
3500 wrap_emphasis(content, if *underscore { "_" } else { "*" }, &mut text, gap)
3501 }
3502 Element::Bold { content, underscore } => {
3503 wrap_emphasis(content, if *underscore { "__" } else { "**" }, &mut text, gap)
3504 }
3505 Element::Strikethrough { content, double } => {
3506 wrap_emphasis(content, if *double { "~~" } else { "~" }, &mut text, gap)
3507 }
3508 _ => {
3509 push_source_gap(&mut text, gap);
3510 element.to_string()
3511 }
3512 };
3513 let start = text.len();
3514 text.push_str(&piece);
3515 ranges.push((start, text.len()));
3516 }
3517 (text, ranges)
3518}
3519
3520fn reflow_elements_sentence_per_line(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
3522 let abbreviations = get_abbreviations(&options.abbreviations);
3523 let require_sentence_capital = options.require_sentence_capital;
3524 let mut lines = Vec::new();
3525
3526 let (paragraph_text, piece_ranges) = elements_source_text(elements);
3534 let paragraph = sentence_structure(¶graph_text, options.defined_references.as_ref());
3535 let structure_from = |base: usize| ParagraphStructure {
3536 text: ¶graph_text,
3537 structure: ¶graph,
3538 base,
3539 };
3540 let mut line: Option<usize> = None;
3548 let absorb = |line_start: usize, (piece_start, piece_end): (usize, usize)| {
3556 let in_front = line_start <= piece_start;
3557 debug_assert!(
3558 in_front,
3559 "line under construction begins at {line_start}, past the element at {piece_start} of {paragraph_text:?}"
3560 );
3561 if in_front {
3562 (line_start, piece_end, Some(structure_from(line_start)))
3563 } else {
3564 (piece_start, piece_end, None)
3565 }
3566 };
3567
3568 for (idx, element) in elements.iter().enumerate() {
3569 let (line_start, line_end, structure) = absorb(line.unwrap_or(piece_ranges[idx].0), piece_ranges[idx]);
3570 let combined = ¶graph_text[line_start..line_end];
3571
3572 let splits = matches!(
3578 element,
3579 Element::Text(_) | Element::Italic { .. } | Element::Bold { .. } | Element::Strikethrough { .. }
3580 );
3581
3582 if splits {
3583 let sentences = match structure {
3586 Some(structure) => split_into_sentence_ranges(
3587 combined,
3588 &abbreviations,
3589 require_sentence_capital,
3590 options.cjk_soft_break,
3591 options.defined_references.as_ref(),
3592 Some(structure),
3593 ),
3594 None => trim_range(combined, 0, combined.len()).into_iter().collect(),
3595 };
3596
3597 let glued_to_next = piece_ranges
3608 .get(idx + 1)
3609 .is_some_and(|&(next_start, _)| !paragraph_text[..next_start].ends_with(is_breakable_whitespace));
3610 let next_bracketed = elements
3611 .get(idx + 1)
3612 .filter(|next| next.opens_with_bracket())
3613 .map(|next| {
3614 let trailing = &combined[combined.trim_end_matches(is_breakable_whitespace).len()..];
3615 let gap = if trailing.contains('\n') {
3616 "\n"
3617 } else {
3618 source_gap_before(elements, idx + 1)
3619 };
3620 (gap, next.to_string())
3621 });
3622 let closes_before_next = |sentence: &str| -> bool {
3623 if glued_to_next {
3624 return false;
3625 }
3626 let Some((gap, next_str)) = &next_bracketed else {
3627 return true;
3628 };
3629 let mut probe = sentence.to_string();
3630 push_source_gap(&mut probe, gap);
3631 probe.push_str(next_str);
3632 let probe_sentences = split_into_sentences_with_set(
3633 &probe,
3634 &abbreviations,
3635 require_sentence_capital,
3636 options.cjk_soft_break,
3637 options.defined_references.as_ref(),
3638 None,
3639 );
3640 probe_sentences.last().is_some_and(|last| last == next_str)
3641 };
3642
3643 if sentences.len() > 1 {
3644 let mut pending_start = None;
3650 let last = sentences.len() - 1;
3651 for (i, &(start, end)) in sentences.iter().enumerate() {
3652 let pending = &combined[*pending_start.get_or_insert(start)..end];
3653
3654 let closed = i < last || (ends_with_sentence_punct(pending) && closes_before_next(pending));
3659 if closed && !text_ends_with_abbreviation(pending, &abbreviations) {
3660 lines.push(pending.to_string());
3661 pending_start = None;
3662 }
3663 }
3664 line = pending_start.map(|held| line_start + held);
3665 } else {
3666 let trimmed = combined.trim();
3668
3669 if trimmed.is_empty() {
3673 continue;
3674 }
3675
3676 let ends_with_sentence_punct = ends_with_sentence_punct(trimmed);
3677
3678 if ends_with_sentence_punct
3679 && !text_ends_with_abbreviation(trimmed, &abbreviations)
3680 && closes_before_next(trimmed)
3681 {
3682 lines.push(combined.trim_matches(is_breakable_whitespace).to_string());
3685 line = None;
3686 } else {
3687 line = Some(line_start);
3689 }
3690 }
3691 } else {
3692 line = Some(line_start);
3695 }
3696 }
3697
3698 if let Some(line_start) = line {
3706 let tail = ¶graph_text[line_start..];
3710 lines.extend(split_into_sentences_with_set(
3711 tail,
3712 &abbreviations,
3713 require_sentence_capital,
3714 options.cjk_soft_break,
3715 options.defined_references.as_ref(),
3716 Some(structure_from(line_start)),
3717 ));
3718 }
3719 lines
3720}
3721
3722#[derive(Clone)]
3723struct SentencePackSeparator {
3724 text: String,
3725 preserve_inline: bool,
3726}
3727
3728struct SentencePackUnit {
3729 text: String,
3730 separator_before: SentencePackSeparator,
3731}
3732
3733fn sentence_pack_separators(
3741 elements: &[Element],
3742 sentences: &[String],
3743 options: &ReflowOptions,
3744) -> Vec<SentencePackSeparator> {
3745 let mut separators = vec![
3746 SentencePackSeparator {
3747 text: " ".to_string(),
3748 preserve_inline: false,
3749 };
3750 sentences.len().saturating_sub(1)
3751 ];
3752 if options.cjk_soft_break != CjkSoftBreak::Join {
3753 return separators;
3754 }
3755
3756 let (source, _) = elements_source_text(elements);
3757 let mut cursor = 0;
3758 for (idx, pair) in sentences.windows(2).enumerate() {
3759 let Some(current_offset) = source[cursor..].find(&pair[0]) else {
3760 continue;
3761 };
3762 let current_end = cursor + current_offset + pair[0].len();
3763 let Some(next_offset) = source[current_end..].find(&pair[1]) else {
3764 cursor = current_end;
3765 continue;
3766 };
3767 let next_start = current_end + next_offset;
3768 cursor = next_start;
3769
3770 if pair[0].chars().next_back().is_some_and(joins_cjk_soft_break)
3771 && pair[1].chars().next().is_some_and(joins_cjk_soft_break)
3772 {
3773 let text = source[current_end..next_start].to_string();
3774 separators[idx] = SentencePackSeparator {
3775 preserve_inline: !text.is_empty(),
3776 text,
3777 };
3778 }
3779 }
3780 separators
3781}
3782
3783fn sentence_pack_units(elements: &[Element], options: &ReflowOptions) -> Vec<SentencePackUnit> {
3785 let sentences = reflow_elements_sentence_per_line(elements, options);
3786 let separators = sentence_pack_separators(elements, &sentences, options);
3787 let mut units: Vec<SentencePackUnit> = Vec::with_capacity(sentences.len());
3788
3789 for (idx, text) in sentences.into_iter().enumerate() {
3790 let separator_before = idx.checked_sub(1).map_or(
3791 SentencePackSeparator {
3792 text: String::new(),
3793 preserve_inline: false,
3794 },
3795 |separator| separators[separator].clone(),
3796 );
3797 units.push(SentencePackUnit { text, separator_before });
3798
3799 while units.len() > 1 && starts_block_construct(&units.last().expect("non-empty").text) {
3800 let last = units.pop().expect("non-empty");
3801 let previous = &mut units.last_mut().expect("len > 1").text;
3802 previous.push(' ');
3803 previous.push_str(last.text.trim_start());
3804 }
3805 }
3806 units
3807}
3808
3809fn sentence_pack_groups(elements: &[Element], options: &ReflowOptions) -> Vec<SentencePackUnit> {
3811 let units = sentence_pack_units(elements, options);
3812 let mut groups: Vec<SentencePackUnit> = Vec::with_capacity(units.len());
3813
3814 for unit in units {
3815 if unit.separator_before.preserve_inline
3816 && let Some(previous) = groups.last_mut()
3817 {
3818 previous.text.push_str(&unit.separator_before.text);
3819 previous.text.push_str(&unit.text);
3820 continue;
3821 }
3822 groups.push(unit);
3823 }
3824 groups
3825}
3826
3827fn reflow_elements_sentence_pack(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
3829 let mut lines: Vec<String> = Vec::new();
3830 for unit in sentence_pack_groups(elements, options) {
3831 let budget = if lines.len() == 1 {
3832 options.first_line_length.unwrap_or(options.line_length)
3833 } else {
3834 options.line_length
3835 };
3836 if let Some(current) = lines.last_mut() {
3837 let previous_len = current.len();
3838 current.push_str(&unit.separator_before.text);
3839 current.push_str(&unit.text);
3840 if budget == 0 || budget == usize::MAX || line_width(current, options) <= budget {
3841 continue;
3842 }
3843 current.truncate(previous_len);
3844 }
3845 lines.push(unit.text);
3846 }
3847 lines
3848}
3849
3850fn wrap_emphasis(content: &str, marker: &str, current_line: &mut String, gap: &str) -> String {
3854 push_source_gap(current_line, gap);
3855 format!("{marker}{content}{marker}")
3856}
3857
3858const BREAK_WORDS: &[&str] = &[
3862 "and",
3863 "or",
3864 "but",
3865 "nor",
3866 "yet",
3867 "so",
3868 "for",
3869 "which",
3870 "that",
3871 "because",
3872 "when",
3873 "if",
3874 "while",
3875 "where",
3876 "although",
3877 "though",
3878 "unless",
3879 "since",
3880 "after",
3881 "before",
3882 "until",
3883 "as",
3884 "once",
3885 "whether",
3886 "however",
3887 "therefore",
3888 "moreover",
3889 "furthermore",
3890 "nevertheless",
3891 "whereas",
3892];
3893
3894fn is_clause_punctuation(c: char) -> bool {
3896 matches!(c, ',' | ';' | ':' | '\u{2014}') }
3898
3899fn clause_break_allowed_after(chars: &[char], i: usize) -> bool {
3910 match chars.get(i + 1) {
3911 None => true,
3912 Some(next) => is_breakable_whitespace(*next),
3913 }
3914}
3915
3916fn paren_group_end<'a>(slice: &'a str, element_spans: &[ElementSpan], offset: usize) -> Option<(usize, &'a str)> {
3930 debug_assert!(slice.starts_with('('));
3931 let mut depth: i32 = 0;
3932 for (local_byte, c) in slice.char_indices() {
3933 let global_byte = offset + local_byte;
3934 if depth > 0 && is_inside_element(global_byte, element_spans) {
3939 continue;
3940 }
3941 match c {
3942 '(' => depth += 1,
3943 ')' => {
3944 depth -= 1;
3945 if depth == 0 {
3946 let end = local_byte + 1;
3947 let inner = &slice[1..local_byte];
3948 return Some((end, inner));
3949 }
3950 }
3951 _ => {}
3952 }
3953 }
3954 None
3955}
3956
3957fn split_at_parenthetical(
3974 text: &str,
3975 line_length: usize,
3976 element_spans: &[ElementSpan],
3977 length_mode: ReflowLengthMode,
3978) -> Option<(String, String)> {
3979 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
3980
3981 if text.starts_with('(')
3983 && let Some((end_local, inner)) = paren_group_end(text, element_spans, 0)
3984 && inner.contains(' ')
3985 {
3986 let mut first_end = end_local;
3993 loop {
3994 first_end += text[first_end..]
3995 .char_indices()
3996 .take_while(|(_, c)| !is_breakable_whitespace(*c))
3997 .last()
3998 .map_or(0, |(idx, c)| idx + c.len_utf8());
3999 match element_containing(first_end, element_spans) {
4000 Some(span) => first_end = span.end,
4001 None => break,
4002 }
4003 }
4004 let rest_start = first_end;
4005 let first = &text[..first_end];
4006 if measure(first, 0, element_spans, length_mode).fits(line_length) {
4009 let rest = text[rest_start..].trim_start();
4010 if !rest.is_empty() {
4011 return Some((first.to_string(), rest.to_string()));
4012 }
4013 }
4014 }
4015
4016 let mut best_open_byte: Option<usize> = None;
4018 let mut pos = 0usize;
4019 while pos < text.len() {
4020 if text.as_bytes()[pos] != b'(' {
4022 let c = text[pos..].chars().next().unwrap();
4023 pos += c.len_utf8();
4024 continue;
4025 }
4026 if is_inside_element(pos, element_spans) {
4028 pos += 1;
4029 continue;
4030 }
4031 if let Some((end_local, inner)) = paren_group_end(&text[pos..], element_spans, pos) {
4032 let first = text[..pos].trim_end_matches(is_breakable_whitespace);
4033 let first_len = measure(first, 0, element_spans, length_mode).effective();
4034 if first.len() < pos
4037 && !first.is_empty()
4038 && first_len >= min_first_len
4039 && first_len <= line_length
4040 && inner.contains(' ')
4041 && best_open_byte.is_none_or(|prev| pos > prev)
4042 {
4043 best_open_byte = Some(pos);
4044 }
4045 pos += end_local;
4046 } else {
4047 pos += 1;
4048 }
4049 }
4050
4051 let open_byte = best_open_byte?;
4052 let first = text[..open_byte].trim_end_matches(is_breakable_whitespace).to_string();
4053 let rest = text[open_byte..].to_string();
4054 if first.is_empty() || rest.trim().is_empty() {
4055 return None;
4056 }
4057 Some((first, rest))
4058}
4059
4060#[derive(Clone, Copy, Debug, PartialEq, Eq)]
4068struct ElementSpan {
4069 start: usize,
4070 end: usize,
4071 full: usize,
4072 link_saving: usize,
4075 code_saving: usize,
4077 is_hard: bool,
4079}
4080
4081impl ElementSpan {
4082 fn new(start: usize, len: usize, full: usize, width: LineWidth, is_hard: bool) -> Self {
4085 Self {
4086 start,
4087 end: start + len,
4088 full,
4089 link_saving: full - width.link_exempt,
4090 code_saving: full - width.code_exempt,
4091 is_hard,
4092 }
4093 }
4094
4095 fn contains(&self, pos: usize) -> bool {
4096 pos > self.start && pos < self.end
4097 }
4098
4099 fn within(&self, start: usize, end: usize) -> bool {
4100 self.start >= start && self.end <= end
4101 }
4102
4103 fn exempt_width(&self) -> LineWidth {
4104 LineWidth {
4105 link_exempt: self.full - self.link_saving,
4106 code_exempt: self.full - self.code_saving,
4107 }
4108 }
4109}
4110
4111fn link_text_parts(element: &Element, break_link_text: bool) -> Option<(&str, &str, &str)> {
4117 if !break_link_text {
4118 return None;
4119 }
4120 let (raw, open) = match element {
4121 Element::Link(raw)
4122 | Element::ReferenceLink(raw)
4123 | Element::EmptyReferenceLink(raw)
4124 | Element::ShortcutReference(raw) => (raw.as_str(), 0),
4125 Element::InlineImage(raw) | Element::ReferenceImage(raw) | Element::EmptyReferenceImage(raw) => {
4126 (raw.as_str(), 1)
4127 }
4128 _ => return None,
4129 };
4130 let inner = bracketed_text(raw, open)?;
4131 Some((&raw[..=open], inner, &raw[open + 1 + inner.len()..]))
4132}
4133
4134fn element_is_hard(element: &Element, break_link_text: bool) -> bool {
4142 match element {
4143 Element::Bold { content, .. } | Element::Italic { content, .. } | Element::Strikethrough { content, .. } => {
4144 content.contains(['[', '`', '<', '$', '{'])
4145 }
4146 _ => match link_text_parts(element, break_link_text) {
4147 Some((_, inner, suffix)) => {
4148 inner.contains(['[', '`', '<', '$', '{']) || suffix.chars().any(char::is_whitespace)
4149 }
4150 None => true,
4151 },
4152 }
4153}
4154
4155fn compute_element_spans(
4161 elements: &[Element],
4162 mode: ReflowLengthMode,
4163 exemptions: LengthExemptions,
4164 break_link_text: bool,
4165) -> Vec<ElementSpan> {
4166 let mut spans = Vec::new();
4167 let mut offset = 0;
4168 for element in elements {
4169 let len = element.display_len(ReflowLengthMode::Bytes);
4170 if !matches!(element, Element::Text(_)) {
4171 let full = element.display_len(mode);
4172 let width = element.exempt_width(mode, exemptions);
4173 spans.push(ElementSpan::new(
4174 offset,
4175 len,
4176 full,
4177 width,
4178 element_is_hard(element, break_link_text),
4179 ));
4180 }
4181 offset += len;
4182 }
4183 spans
4184}
4185
4186fn measure(text: &str, offset: usize, spans: &[ElementSpan], mode: ReflowLengthMode) -> LineWidth {
4194 let full = display_len(text, mode);
4195 let end = offset + text.len();
4196 let mut width = LineWidth::plain(full);
4197 for span in spans.iter().filter(|span| span.within(offset, end)) {
4198 width.link_exempt -= span.link_saving;
4199 width.code_exempt -= span.code_saving;
4200 }
4201 width
4202}
4203
4204fn line_width_components(line: &str, options: &ReflowOptions) -> LineWidth {
4209 let raw = display_len(line, options.length_mode);
4210 if !options.length_exemptions.any() {
4211 return LineWidth::plain(raw);
4212 }
4213 let elements = parse_markdown_elements_inner(
4214 line,
4215 options.attr_lists,
4216 options.myst_roles,
4217 options.defined_references.as_ref(),
4218 );
4219 let spans = compute_element_spans(
4220 &elements,
4221 options.length_mode,
4222 options.length_exemptions,
4223 options.break_link_text,
4224 );
4225 measure(line, 0, &spans, options.length_mode)
4226}
4227
4228fn line_width(line: &str, options: &ReflowOptions) -> usize {
4230 line_width_components(line, options).effective()
4231}
4232
4233fn line_fits(line: &str, options: &ReflowOptions) -> bool {
4239 display_len(line, options.length_mode) <= options.line_length || line_width(line, options) <= options.line_length
4240}
4241
4242fn element_containing(pos: usize, spans: &[ElementSpan]) -> Option<ElementSpan> {
4244 spans.iter().copied().find(|span| span.contains(pos))
4245}
4246
4247fn is_inside_element(pos: usize, spans: &[ElementSpan]) -> bool {
4249 element_containing(pos, spans).is_some()
4250}
4251
4252const MIN_SPLIT_RATIO: f64 = 0.3;
4255
4256fn split_at_clause_punctuation(
4260 text: &str,
4261 line_length: usize,
4262 element_spans: &[ElementSpan],
4263 length_mode: ReflowLengthMode,
4264) -> Option<(String, String)> {
4265 let chars: Vec<char> = text.chars().collect();
4266 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
4267
4268 let mut width_acc = LineWidth::default();
4274 let mut search_end_char = 0;
4275 let mut byte = 0usize;
4276 let mut idx = 0usize;
4277 while idx < chars.len() {
4278 let (advance_chars, advance_bytes, width) = match element_spans.iter().find(|s| s.start == byte) {
4279 Some(span) => {
4280 let source = &text[span.start..span.end];
4281 (
4282 source.chars().count(),
4283 source.len(),
4284 measure(source, span.start, element_spans, length_mode),
4285 )
4286 }
4287 None => {
4288 let c = chars[idx];
4289 (
4290 1,
4291 c.len_utf8(),
4292 LineWidth::plain(display_len(&c.to_string(), length_mode)),
4293 )
4294 }
4295 };
4296 if !(width_acc + width).fits(line_length) {
4297 break;
4298 }
4299 width_acc += width;
4300 byte += advance_bytes;
4301 idx += advance_chars;
4302 search_end_char = idx;
4303 }
4304
4305 let mut paren_depth: i32 = 0;
4312 let mut best_pos = None;
4313 for i in (0..search_end_char).rev() {
4314 let byte_start: usize = chars[..i].iter().map(|c| c.len_utf8()).sum();
4316 let byte_after: usize = byte_start + chars[i].len_utf8();
4318
4319 if !is_inside_element(byte_start, element_spans) {
4320 match chars[i] {
4321 ')' => paren_depth += 1,
4322 '(' => paren_depth = paren_depth.saturating_sub(1),
4323 _ => {}
4324 }
4325 }
4326
4327 if paren_depth == 0
4328 && is_clause_punctuation(chars[i])
4329 && clause_break_allowed_after(&chars, i)
4330 && !is_inside_element(byte_after, element_spans)
4331 {
4332 best_pos = Some(i);
4333 break;
4334 }
4335 }
4336
4337 let pos = best_pos?;
4338
4339 let first: String = chars[..=pos].iter().collect();
4341 if measure(&first, 0, element_spans, length_mode).effective() < min_first_len {
4342 return None;
4343 }
4344
4345 let rest: String = chars[pos + 1..].iter().collect();
4347 let rest = rest.trim_start().to_string();
4348
4349 if rest.is_empty() {
4350 return None;
4351 }
4352
4353 Some((first, rest))
4354}
4355
4356fn paren_depth_map(text: &str, element_spans: &[ElementSpan]) -> Vec<i32> {
4363 let mut map = vec![0i32; text.len()];
4364 let mut depth = 0i32;
4365 for (byte, c) in text.char_indices() {
4366 if !is_inside_element(byte, element_spans) {
4367 match c {
4368 '(' => depth += 1,
4369 ')' => depth = depth.saturating_sub(1),
4370 _ => {}
4371 }
4372 }
4373 let end = (byte + c.len_utf8()).min(map.len());
4375 for slot in &mut map[byte..end] {
4376 *slot = depth;
4377 }
4378 }
4379 map
4380}
4381
4382fn is_standalone_parenthetical(line: &str) -> bool {
4391 let trimmed = line.trim();
4392 if !trimmed.starts_with('(') {
4393 return false;
4394 }
4395 let Some(close) = trimmed.rfind(')') else {
4398 return false;
4399 };
4400 if trimmed[close + 1..].contains(char::is_whitespace) {
4401 return false;
4402 }
4403 let core = &trimmed[..=close];
4404 let inner = &core[1..core.len() - 1];
4406 if !inner.contains(' ') {
4407 return false;
4408 }
4409 let mut depth = 0i32;
4411 for c in core.chars() {
4412 match c {
4413 '(' => depth += 1,
4414 ')' => depth -= 1,
4415 _ => {}
4416 }
4417 if depth < 0 {
4418 return false;
4419 }
4420 }
4421 depth == 0
4422}
4423
4424fn split_at_break_word(
4428 text: &str,
4429 line_length: usize,
4430 element_spans: &[ElementSpan],
4431 length_mode: ReflowLengthMode,
4432) -> Option<(String, String)> {
4433 let lower = text.to_lowercase();
4434 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
4435 let mut best_split: Option<(usize, usize)> = None; let depth_map = paren_depth_map(text, element_spans);
4440
4441 for &word in BREAK_WORDS {
4442 let mut search_start = 0;
4443 while let Some(pos) = lower[search_start..].find(word) {
4444 let abs_pos = search_start + pos;
4445
4446 let preceded_by_space = abs_pos == 0 || text.as_bytes().get(abs_pos - 1) == Some(&b' ');
4448 let followed_by_space = text.as_bytes().get(abs_pos + word.len()) == Some(&b' ');
4449
4450 if preceded_by_space && followed_by_space {
4451 let first_part = text[..abs_pos].trim_end();
4453 let first_part_len = measure(first_part, 0, element_spans, length_mode).effective();
4454
4455 let inside_paren = depth_map.get(abs_pos).is_some_and(|&d| d > 0);
4457
4458 if first_part_len >= min_first_len
4459 && first_part_len <= line_length
4460 && !is_inside_element(abs_pos, element_spans)
4461 && !inside_paren
4462 {
4463 if best_split.is_none_or(|(prev_pos, _)| abs_pos > prev_pos) {
4465 best_split = Some((abs_pos, word.len()));
4466 }
4467 }
4468 }
4469
4470 search_start = abs_pos + word.len();
4471 }
4472 }
4473
4474 let (byte_start, _word_len) = best_split?;
4475
4476 let first = text[..byte_start].trim_end().to_string();
4477 let rest = text[byte_start..].to_string();
4478
4479 if first.is_empty() || rest.trim().is_empty() {
4480 return None;
4481 }
4482
4483 Some((first, rest))
4484}
4485
4486fn replaces_whitespace(text: &str, first: &str, rest: &str, element_spans: &[ElementSpan]) -> bool {
4497 if !text.starts_with(first) || !text.ends_with(rest) {
4498 return false;
4499 }
4500 let gap_end = text.len() - rest.len();
4501 gap_end > first.len()
4502 && text[first.len()..gap_end].chars().all(is_breakable_whitespace)
4503 && !element_spans
4504 .iter()
4505 .any(|span| first.len() < span.end && span.start < gap_end)
4506}
4507
4508fn cascade_split_line(text: &str, options: &ReflowOptions) -> Vec<String> {
4519 let line_length = options.line_length;
4520 let length_mode = options.length_mode;
4521 let attr_lists = options.attr_lists;
4522 let myst_roles = options.myst_roles;
4523 let defined_references = options.defined_references.as_ref();
4524 if line_length == 0 || display_len(text, length_mode) <= line_length {
4525 return vec![text.to_string()];
4526 }
4527
4528 let elements = parse_markdown_elements_inner(text, attr_lists, myst_roles, defined_references);
4529 let element_spans = compute_element_spans(
4530 &elements,
4531 length_mode,
4532 options.length_exemptions,
4533 options.break_link_text,
4534 );
4535
4536 if measure(text, 0, &element_spans, length_mode).fits(line_length) {
4539 return vec![text.to_string()];
4540 }
4541
4542 let rebased_spans = |start: usize| -> Vec<ElementSpan> {
4546 if start == 0 {
4547 return element_spans.clone();
4548 }
4549 element_spans
4550 .iter()
4551 .filter(|span| span.end > start)
4552 .map(|span| ElementSpan {
4553 start: span.start.saturating_sub(start),
4554 end: span.end.saturating_sub(start),
4555 ..*span
4556 })
4557 .collect()
4558 };
4559
4560 let mut result = Vec::new();
4561 let mut start = 0usize;
4562
4563 loop {
4564 let remaining = &text[start..];
4565 let spans = rebased_spans(start);
4566 if measure(remaining, 0, &spans, length_mode).fits(line_length) {
4567 result.push(remaining.to_string());
4568 return result;
4569 }
4570
4571 let at_whitespace = |candidate: Option<(String, String)>| {
4580 candidate.filter(|(first, rest)| replaces_whitespace(remaining, first, rest, &spans))
4581 };
4582 let split = at_whitespace(split_at_parenthetical(remaining, line_length, &spans, length_mode))
4583 .or_else(|| at_whitespace(split_at_clause_punctuation(remaining, line_length, &spans, length_mode)))
4584 .or_else(|| at_whitespace(split_at_break_word(remaining, line_length, &spans, length_mode)));
4585
4586 if let Some((first, rest)) = split {
4587 let consumed = remaining.len().saturating_sub(rest.len());
4588 if consumed == 0 {
4591 break;
4592 }
4593 result.push(first);
4594 start += consumed;
4595 continue;
4596 }
4597
4598 break;
4600 }
4601
4602 let mut fallback_options = options.clone();
4604 fallback_options.break_on_sentences = false;
4605 fallback_options.preserve_breaks = false;
4606 fallback_options.sentence_per_line = false;
4607 fallback_options.semantic_line_breaks = false;
4608 fallback_options.require_sentence_capital = true;
4609 fallback_options.max_list_continuation_indent = None;
4610 fallback_options.defined_references = None;
4611 let remaining = &text[start..];
4612 let tail_elements = if start == 0 {
4613 elements
4614 } else {
4615 parse_markdown_elements_inner(remaining, attr_lists, myst_roles, defined_references)
4616 };
4617 result.extend(reflow_elements(&tail_elements, &fallback_options));
4618 result
4619}
4620
4621fn reflow_elements_semantic(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
4625 let sentence_lines = reflow_elements_sentence_per_line(elements, options);
4627
4628 if options.line_length == 0 {
4631 return sentence_lines;
4632 }
4633
4634 let mut result = Vec::new();
4635 for line in sentence_lines {
4636 if line_fits(&line, options) {
4637 result.push(line);
4638 } else {
4639 result.extend(cascade_split_line(&line, options));
4640 }
4641 }
4642
4643 let min_line_len = ((options.line_length as f64) * MIN_SPLIT_RATIO) as usize;
4646 let mut merged: Vec<String> = Vec::with_capacity(result.len());
4647 for line in result {
4648 if !merged.is_empty() && line_width(&line, options) < min_line_len && !line.trim().is_empty() {
4649 if is_standalone_parenthetical(&line) {
4652 merged.push(line);
4653 continue;
4654 }
4655
4656 let prev_ends_at_sentence = {
4658 let trimmed = merged.last().unwrap().trim_end();
4659 trimmed
4660 .chars()
4661 .rev()
4662 .find(|&c| !(is_closing_quote(c) || is_closing_bracket(c)))
4663 .is_some_and(|c| matches!(c, '.' | '!' | '?') || is_cjk_sentence_ending(c))
4664 };
4665
4666 if !prev_ends_at_sentence {
4667 let prev = merged.last_mut().unwrap();
4668 let combined = format!("{prev} {line}");
4669 if line_fits(&combined, options) {
4671 *prev = combined;
4672 continue;
4673 }
4674 }
4675 }
4676 merged.push(line);
4677 }
4678 merged
4679}
4680
4681fn rfind_safe_space(
4692 line: &str,
4693 element_spans: &[ElementSpan],
4694 options: &ReflowOptions,
4695 relax_soft_spans: bool,
4696) -> Option<usize> {
4697 line.char_indices().rev().map(|(pos, _)| pos).find(|&pos| {
4698 line.as_bytes()[pos] == b' '
4699 && !is_inside_element_filtered(pos, element_spans, options, relax_soft_spans)
4700 && !starts_block_construct(&line[pos + 1..])
4701 && !ends_with_unescaped_backslash(line[..pos].trim_end_matches(is_breakable_whitespace))
4702 })
4703}
4704
4705fn is_inside_element_filtered(
4706 pos: usize,
4707 spans: &[ElementSpan],
4708 options: &ReflowOptions,
4709 relax_soft_spans: bool,
4710) -> bool {
4711 spans.iter().any(|span| {
4712 span.contains(pos)
4713 && (!relax_soft_spans
4714 || span.is_hard
4715 || (options.atomic_spans && span.exempt_width().fits(options.line_length)))
4716 })
4717}
4718
4719#[derive(Clone, Copy)]
4724struct Attached<'a> {
4725 text: &'a str,
4726 width: LineWidth,
4727 separator: &'a str,
4728}
4729
4730fn break_before_attached(
4747 lines: &mut Vec<String>,
4748 current_line: &mut String,
4749 current_width: &mut LineWidth,
4750 element_spans: &mut Vec<ElementSpan>,
4751 attach: Attached<'_>,
4752 options: &ReflowOptions,
4753) -> Option<usize> {
4754 let length_mode = options.length_mode;
4755 let last_space = rfind_safe_space(current_line, element_spans, options, false)
4756 .or_else(|| rfind_safe_space(current_line, element_spans, options, true))?;
4757 let before = current_line[..last_space]
4758 .trim_end_matches(is_breakable_whitespace)
4759 .to_string();
4760 let after = current_line[last_space + 1..].to_string();
4761 let after_width = measure(&after, last_space + 1, element_spans, length_mode);
4762 lines.push(before);
4763 let carried = after.len();
4764 let Attached { text, width, separator } = attach;
4765 *current_line = format!("{after}{separator}{text}");
4766 *current_width = after_width + LineWidth::plain(display_len(separator, length_mode)) + width;
4767 rebase_spans_after_break(element_spans, last_space + 1);
4768 Some(carried)
4769}
4770
4771fn rebase_spans_after_break(element_spans: &mut Vec<ElementSpan>, carried_start: usize) {
4780 element_spans.retain(|span| span.end > carried_start);
4781 for span in element_spans.iter_mut() {
4782 span.start = span.start.saturating_sub(carried_start);
4783 span.end -= carried_start;
4784 }
4785}
4786
4787fn reflow_elements(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
4789 let mut lines = Vec::new();
4790 let mut current_line = String::new();
4791 let mut current_width = LineWidth::default();
4794 let mut current_line_element_spans: Vec<ElementSpan> = Vec::new();
4796 let length_mode = options.length_mode;
4797 let exemptions = options.length_exemptions;
4798
4799 for (idx, element) in elements.iter().enumerate() {
4800 let element_len = element.display_len(length_mode);
4801 let element_width = element.exempt_width(length_mode, exemptions);
4802 let is_hard = element_is_hard(element, options.break_link_text);
4803
4804 let is_adjacent_to_prev = if idx > 0 {
4813 match (&elements[idx - 1], element) {
4814 (Element::Text(t), _) => !t.is_empty() && !t.ends_with(is_breakable_whitespace),
4815 (_, Element::Text(t)) => !t.is_empty() && !t.starts_with(is_breakable_whitespace),
4816 _ => true,
4817 }
4818 } else {
4819 false
4820 };
4821
4822 if let Element::Text(text) = element {
4824 let has_leading_space = text.starts_with(is_breakable_whitespace);
4826 let words: Vec<&str> = split_breakable_words(text).collect();
4828
4829 for (i, word) in words.iter().enumerate() {
4830 let word_width = LineWidth::plain(display_len(word, length_mode));
4832 let is_trailing_punct = word.chars().all(|c| {
4838 matches!(c, ',' | '.' | ':' | ';' | '!' | '?' | ')' | ']' | '}') || is_significant_whitespace(c)
4839 });
4840
4841 let is_first_adjacent = i == 0 && is_adjacent_to_prev;
4844
4845 if is_first_adjacent {
4846 if !(current_width + word_width).fits(options.line_length)
4848 && !current_width.is_empty()
4849 && break_before_attached(
4850 &mut lines,
4851 &mut current_line,
4852 &mut current_width,
4853 &mut current_line_element_spans,
4854 Attached {
4855 text: word,
4856 width: word_width,
4857 separator: "",
4858 },
4859 options,
4860 )
4861 .is_some()
4862 {
4863 } else {
4868 current_line.push_str(word);
4869 current_width += word_width;
4870 }
4871 } else if !current_width.is_empty()
4872 && !(current_width + LineWidth::plain(1) + word_width).fits(options.line_length)
4873 {
4874 if is_trailing_punct {
4875 if break_before_attached(
4882 &mut lines,
4883 &mut current_line,
4884 &mut current_width,
4885 &mut current_line_element_spans,
4886 Attached {
4887 text: word,
4888 width: word_width,
4889 separator: " ",
4890 },
4891 options,
4892 )
4893 .is_none()
4894 {
4895 current_line.push(' ');
4896 current_line.push_str(word);
4897 current_width += LineWidth::plain(1) + word_width;
4898 }
4899 } else if !starts_block_construct(word)
4900 && !ends_with_unescaped_backslash(current_line.trim_end_matches(is_breakable_whitespace))
4901 {
4902 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
4904 current_line = word.to_string();
4905 current_width = word_width;
4906 current_line_element_spans.clear();
4907 } else if break_before_attached(
4908 &mut lines,
4909 &mut current_line,
4910 &mut current_width,
4911 &mut current_line_element_spans,
4912 Attached {
4913 text: word,
4914 width: word_width,
4915 separator: " ",
4916 },
4917 options,
4918 )
4919 .is_some()
4920 {
4921 } else {
4927 if i > 0 || has_leading_space {
4930 current_line.push(' ');
4931 current_width += LineWidth::plain(1);
4932 }
4933 current_line.push_str(word);
4934 current_width += word_width;
4935 }
4936 } else {
4937 let add_space = !current_width.is_empty() && (i > 0 || has_leading_space);
4949 if add_space {
4950 current_line.push(' ');
4951 current_width += LineWidth::plain(1);
4952 }
4953 current_line.push_str(word);
4954 current_width += word_width;
4955 }
4956 }
4957 } else {
4958 let link_parts = link_text_parts(element, options.break_link_text);
4959 let span_info = match element {
4960 Element::Italic { content, underscore } => {
4961 let marker = if *underscore { "_" } else { "*" };
4962 Some((content.as_str(), marker, marker, false))
4963 }
4964 Element::Bold { content, underscore } => {
4965 let marker = if *underscore { "__" } else { "**" };
4966 Some((content.as_str(), marker, marker, false))
4967 }
4968 Element::Strikethrough { content, double } => {
4969 let marker = if *double { "~~" } else { "~" };
4970 Some((content.as_str(), marker, marker, false))
4971 }
4972 Element::Code { content, marker } => Some((content.as_str(), marker.as_str(), marker.as_str(), true)),
4973 _ => link_parts.map(|(prefix, inner, suffix)| (inner, prefix, suffix, false)),
4974 };
4975 let is_link = link_parts.is_some();
4976
4977 let breakable: Option<Vec<&str>> = match span_info {
4984 Some((content, _, suffix, is_code)) => {
4985 if is_code {
4986 (!options.atomic_spans && options.wrap_code_spans && code_span_wraps_losslessly(content))
4987 .then(|| split_breakable_words(content).collect())
4988 } else if is_link {
4989 (!options.atomic_spans || !element_width.fits(options.line_length))
4990 .then(|| {
4991 link_text_break_units(
4992 content,
4993 suffix,
4994 options.line_length,
4995 length_mode,
4996 options.defined_references.as_ref(),
4997 options.attr_lists,
4998 )
4999 })
5000 .flatten()
5001 } else {
5002 (!options.atomic_spans || element_len > options.line_length)
5003 .then(|| breakable_units(content, options.defined_references.as_ref(), options.attr_lists))
5004 .flatten()
5005 }
5006 }
5007 None => None,
5008 };
5009
5010 if let Some(words) = breakable {
5011 let (_, prefix, suffix, is_code) = span_info.expect("breakable implies a span");
5012 let n = words.len();
5013 if n == 0 {
5014 let full = format!("{prefix}{suffix}");
5016 let full_width = LineWidth::plain(display_len(&full, length_mode));
5017 if !is_adjacent_to_prev && !current_width.is_empty() {
5018 current_line.push(' ');
5019 current_width += LineWidth::plain(1);
5020 }
5021 current_line.push_str(&full);
5022 current_width += full_width;
5023 } else {
5024 let suffix_span_width = LineWidth::plain(display_len(suffix, length_mode));
5030
5031 for (i, word) in words.iter().enumerate() {
5032 let is_first = i == 0;
5033 let is_last = i == n - 1;
5034
5035 let space_start = if is_first && is_code && word.starts_with('`') {
5036 " "
5037 } else {
5038 ""
5039 };
5040 let space_end = if is_last && is_code && word.ends_with('`') {
5041 " "
5042 } else {
5043 ""
5044 };
5045
5046 let word_str: String = match (is_first, is_last) {
5047 (true, true) => format!("{prefix}{space_start}{word}{space_end}{suffix}"),
5048 (true, false) => format!("{prefix}{space_start}{word}"),
5049 (false, true) => format!("{word}{space_end}{suffix}"),
5050 (false, false) => word.to_string(),
5051 };
5052 let word_elements = parse_elements(&word_str, options);
5053 let word_spans =
5054 compute_element_spans(&word_elements, length_mode, exemptions, options.break_link_text);
5055 let word_width = measure(&word_str, 0, &word_spans, length_mode);
5056
5057 let needs_space = if is_first {
5058 !is_adjacent_to_prev && !current_width.is_empty()
5059 } else {
5060 !current_width.is_empty()
5061 };
5062
5063 if needs_space
5064 && !(current_width + LineWidth::plain(1) + word_width).fits(options.line_length)
5065 && !starts_block_construct(&word_str)
5066 {
5067 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
5068 current_line = word_str;
5069 current_width = word_width;
5070 current_line_element_spans.clear();
5071 for span in word_spans {
5072 current_line_element_spans.push(span);
5073 }
5074 if is_link && is_last {
5075 current_line_element_spans.push(ElementSpan::new(
5076 word.len(),
5077 suffix.len(),
5078 display_len(suffix, length_mode),
5079 suffix_span_width,
5080 true,
5081 ));
5082 }
5083 } else {
5084 let mut start_pos = current_line.len();
5085 if needs_space {
5086 current_line.push(' ');
5087 current_width += LineWidth::plain(1);
5088 start_pos += 1;
5089 }
5090 current_line.push_str(&word_str);
5091 current_width += word_width;
5092 for mut span in word_spans {
5093 span.start += start_pos;
5094 span.end += start_pos;
5095 current_line_element_spans.push(span);
5096 }
5097 if is_link && is_last {
5098 current_line_element_spans.push(ElementSpan::new(
5099 start_pos + word.len(),
5100 suffix.len(),
5101 display_len(suffix, length_mode),
5102 suffix_span_width,
5103 true,
5104 ));
5105 }
5106 }
5107 }
5108 }
5109 } else {
5110 let element_str = format!("{element}");
5113
5114 if is_adjacent_to_prev {
5115 if !(current_width + element_width).fits(options.line_length)
5117 && let Some(carried) = break_before_attached(
5118 &mut lines,
5119 &mut current_line,
5120 &mut current_width,
5121 &mut current_line_element_spans,
5122 Attached {
5123 text: &element_str,
5124 width: element_width,
5125 separator: "",
5126 },
5127 options,
5128 )
5129 {
5130 current_line_element_spans.push(ElementSpan::new(
5134 carried,
5135 element_str.len(),
5136 element_len,
5137 element_width,
5138 is_hard,
5139 ));
5140 } else {
5141 let start = current_line.len();
5142 current_line.push_str(&element_str);
5143 current_width += element_width;
5144 current_line_element_spans.push(ElementSpan::new(
5145 start,
5146 element_str.len(),
5147 element_len,
5148 element_width,
5149 is_hard,
5150 ));
5151 }
5152 } else if !current_width.is_empty()
5153 && !(current_width + LineWidth::plain(1) + element_width).fits(options.line_length)
5154 {
5155 if !starts_block_construct(&element_str) {
5156 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
5158 current_line.clone_from(&element_str);
5159 current_width = element_width;
5160 current_line_element_spans.clear();
5161 current_line_element_spans.push(ElementSpan::new(
5162 0,
5163 element_str.len(),
5164 element_len,
5165 element_width,
5166 is_hard,
5167 ));
5168 } else if let Some(carried) = break_before_attached(
5169 &mut lines,
5170 &mut current_line,
5171 &mut current_width,
5172 &mut current_line_element_spans,
5173 Attached {
5174 text: &element_str,
5175 width: element_width,
5176 separator: " ",
5177 },
5178 options,
5179 ) {
5180 let start = carried + 1;
5184 current_line_element_spans.push(ElementSpan::new(
5185 start,
5186 element_str.len(),
5187 element_len,
5188 element_width,
5189 is_hard,
5190 ));
5191 } else {
5192 let ends_with_opener =
5195 current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
5196 if !ends_with_opener {
5197 current_line.push(' ');
5198 current_width += LineWidth::plain(1);
5199 }
5200 let start = current_line.len();
5201 current_line.push_str(&element_str);
5202 current_width += element_width;
5203 current_line_element_spans.push(ElementSpan::new(
5204 start,
5205 element_str.len(),
5206 element_len,
5207 element_width,
5208 is_hard,
5209 ));
5210 }
5211 } else {
5212 let ends_with_opener =
5214 current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
5215 if !current_width.is_empty() && !ends_with_opener {
5216 current_line.push(' ');
5217 current_width += LineWidth::plain(1);
5218 }
5219 let start = current_line.len();
5220 current_line.push_str(&element_str);
5221 current_width += element_width;
5222 current_line_element_spans.push(ElementSpan::new(
5223 start,
5224 element_str.len(),
5225 element_len,
5226 element_width,
5227 is_hard,
5228 ));
5229 }
5230 }
5231 }
5232 }
5233
5234 if !current_line.is_empty() {
5236 lines.push(current_line.trim_end_matches(is_breakable_whitespace).to_string());
5237 }
5238
5239 lines
5240}
5241
5242pub fn reflow_markdown(content: &str, options: &ReflowOptions) -> String {
5244 let lines: Vec<&str> = content.lines().collect();
5245 let inside_code_span = lines_touching_multiline_code_span(content);
5247 let mut result = Vec::new();
5248 let mut i = 0;
5249
5250 while i < lines.len() {
5251 let line = lines[i];
5252 let trimmed = line.trim();
5253
5254 if trimmed.is_empty() {
5256 result.push(String::new());
5257 i += 1;
5258 continue;
5259 }
5260
5261 if trimmed.starts_with('#') {
5263 result.push(line.to_string());
5264 i += 1;
5265 continue;
5266 }
5267
5268 if trimmed.starts_with(":::") {
5270 result.push(line.to_string());
5271 i += 1;
5272 continue;
5273 }
5274
5275 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
5277 result.push(line.to_string());
5278 i += 1;
5279 while i < lines.len() {
5281 result.push(lines[i].to_string());
5282 if lines[i].trim().starts_with("```") || lines[i].trim().starts_with("~~~") {
5283 i += 1;
5284 break;
5285 }
5286 i += 1;
5287 }
5288 continue;
5289 }
5290
5291 if calculate_indentation_width_default(line) >= 4 {
5293 result.push(line.to_string());
5295 i += 1;
5296 while i < lines.len() {
5297 let next_line = lines[i];
5298 if calculate_indentation_width_default(next_line) >= 4 || next_line.trim().is_empty() {
5300 result.push(next_line.to_string());
5301 i += 1;
5302 } else {
5303 break;
5304 }
5305 }
5306 continue;
5307 }
5308
5309 if trimmed.starts_with('>') {
5311 let gt_pos = line.find('>').expect("'>' must exist since trimmed.starts_with('>')");
5314 let quote_prefix = line[0..=gt_pos].to_string();
5315 let quote_content = &line[quote_prefix.len()..].trim_start();
5316
5317 let reflowed = reflow_line(quote_content, options);
5318 for reflowed_line in &reflowed {
5319 result.push(format!("{quote_prefix} {reflowed_line}"));
5320 }
5321 i += 1;
5322 continue;
5323 }
5324
5325 if is_horizontal_rule(trimmed) {
5327 result.push(line.to_string());
5328 i += 1;
5329 continue;
5330 }
5331
5332 if is_unordered_list_marker(trimmed) || is_numbered_list_item(trimmed) {
5334 let indent = line.len() - line.trim_start().len();
5336 let indent_str = " ".repeat(indent);
5337
5338 let mut marker_end = indent;
5341 let mut content_start = indent;
5342
5343 if trimmed.chars().next().is_some_and(char::is_numeric) {
5344 if let Some(period_pos) = line[indent..].find('.') {
5346 marker_end = indent + period_pos + 1; content_start = marker_end;
5348 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
5352 content_start += 1;
5353 }
5354 }
5355 } else {
5356 marker_end = indent + 1; content_start = marker_end;
5359 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
5363 content_start += 1;
5364 }
5365 }
5366
5367 let min_continuation_indent = content_start;
5369
5370 let rest = &line[content_start..];
5373 if rest.starts_with("[ ] ") || rest.starts_with("[x] ") || rest.starts_with("[X] ") {
5374 marker_end = content_start + 3; content_start += 4; }
5377
5378 let marker = &line[indent..marker_end];
5379
5380 let mut list_content = vec![trim_preserving_hard_break(&line[content_start..])];
5383 i += 1;
5384
5385 while i < lines.len() {
5389 let next_line = lines[i];
5390 let next_trimmed = next_line.trim();
5391
5392 if is_block_boundary(next_trimmed) {
5394 break;
5395 }
5396
5397 let next_indent = next_line.len() - next_line.trim_start().len();
5399 if next_indent >= min_continuation_indent {
5400 let trimmed_start = next_line.trim_start();
5403 list_content.push(trim_preserving_hard_break(trimmed_start));
5404 i += 1;
5405 } else {
5406 break;
5408 }
5409 }
5410
5411 let combined_content = if options.preserve_breaks {
5414 list_content[0].clone()
5415 } else {
5416 let has_hard_breaks = list_content.iter().any(|line| has_hard_break(line));
5418 if has_hard_breaks {
5419 list_content.join("\n")
5421 } else {
5422 join_reflow_lines(&list_content, options)
5423 }
5424 };
5425
5426 let trimmed_marker = marker;
5428 let continuation_spaces = if let Some(max_indent) = options.max_list_continuation_indent {
5429 indent + (content_start - indent).min(max_indent)
5432 } else {
5433 content_start
5434 };
5435
5436 let prefix_length = indent + trimmed_marker.len() + 1;
5438
5439 let adjusted_options = ReflowOptions {
5441 line_length: options.line_length.saturating_sub(prefix_length),
5442 ..options.clone()
5443 };
5444
5445 let reflowed = reflow_line(&combined_content, &adjusted_options);
5446 for (j, reflowed_line) in reflowed.iter().enumerate() {
5447 if j == 0 {
5448 result.push(format!("{indent_str}{trimmed_marker} {reflowed_line}"));
5449 } else {
5450 let continuation_indent = " ".repeat(continuation_spaces);
5452 result.push(format!("{continuation_indent}{reflowed_line}"));
5453 }
5454 }
5455 continue;
5456 }
5457
5458 if crate::utils::table_utils::TableUtils::is_potential_table_row(line) {
5460 result.push(line.to_string());
5461 i += 1;
5462 continue;
5463 }
5464
5465 if trimmed.starts_with('[') && line.contains("]:") {
5467 result.push(line.to_string());
5468 i += 1;
5469 continue;
5470 }
5471
5472 if is_definition_list_marker(trimmed) && has_block_line_above(&lines, i) {
5478 result.push(line.to_string());
5479 i += 1;
5480 continue;
5481 }
5482
5483 let mut is_single_line_paragraph = true;
5485 if i + 1 < lines.len() {
5486 let next_trimmed = lines[i + 1].trim();
5487 if !is_block_boundary(next_trimmed) {
5489 is_single_line_paragraph = false;
5490 }
5491 }
5492
5493 if is_single_line_paragraph && line_fits(line, options) {
5495 result.push(line.to_string());
5496 i += 1;
5497 continue;
5498 }
5499
5500 let mut paragraph_parts: Vec<(String, bool)> = Vec::new();
5504 let mut current_part = vec![line];
5505 i += 1;
5506
5507 if options.preserve_breaks {
5509 let hard_break_type = if line.strip_suffix('\r').unwrap_or(line).ends_with('\\') {
5511 Some("\\")
5512 } else if line.ends_with(" ") {
5513 Some(" ")
5514 } else {
5515 None
5516 };
5517 let reflowed = reflow_line(line, options);
5518
5519 if let Some(break_marker) = hard_break_type {
5521 if !reflowed.is_empty() {
5522 let mut reflowed_with_break = reflowed;
5523 let last_idx = reflowed_with_break.len() - 1;
5524 if !has_hard_break(&reflowed_with_break[last_idx]) {
5525 reflowed_with_break[last_idx].push_str(break_marker);
5526 }
5527 result.extend(reflowed_with_break);
5528 }
5529 } else {
5530 result.extend(reflowed);
5531 }
5532 } else {
5533 let paragraph_start = i - 1;
5547 let paragraph_scan = options.sentence_per_line.then(|| {
5548 let mut paragraph_end = i;
5549 while paragraph_end < lines.len() && !is_block_boundary(lines[paragraph_end].trim()) {
5550 paragraph_end += 1;
5551 }
5552 let joined_paragraph = lines[paragraph_start..paragraph_end].join(" ");
5553 let joined_atomic = sentence_structure(&joined_paragraph, options.defined_references.as_ref()).atomic;
5554 let mut line_starts = Vec::with_capacity(paragraph_end - paragraph_start);
5555 let mut line_offset = 0;
5556 for paragraph_line in &lines[paragraph_start..paragraph_end] {
5557 line_starts.push(line_offset);
5558 line_offset += paragraph_line.len() + 1;
5559 }
5560 (joined_atomic, line_starts)
5561 });
5562
5563 while i < lines.len() {
5564 let prev_line = if !current_part.is_empty() {
5565 current_part.last().unwrap()
5566 } else {
5567 ""
5568 };
5569 let next_line = lines[i];
5570 let next_trimmed = next_line.trim();
5571
5572 if is_block_boundary(next_trimmed) {
5574 break;
5575 }
5576
5577 let prev_trimmed = prev_line.trim();
5580 let abbreviations = get_abbreviations(&options.abbreviations);
5581 let ends_with_sentence = (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("?*")
5587 || prev_trimmed.ends_with("._")
5588 || prev_trimmed.ends_with("!_")
5589 || prev_trimmed.ends_with("?_")
5590 || prev_trimmed.ends_with(".\"")
5592 || prev_trimmed.ends_with("!\"")
5593 || prev_trimmed.ends_with("?\"")
5594 || prev_trimmed.ends_with(".'")
5595 || prev_trimmed.ends_with("!'")
5596 || prev_trimmed.ends_with("?'")
5597 || prev_trimmed.ends_with(".\u{201D}")
5598 || prev_trimmed.ends_with("!\u{201D}")
5599 || prev_trimmed.ends_with("?\u{201D}")
5600 || prev_trimmed.ends_with(".\u{2019}")
5601 || prev_trimmed.ends_with("!\u{2019}")
5602 || prev_trimmed.ends_with("?\u{2019}")
5603 || ends_cjk_sentence_with_closer(prev_trimmed))
5608 && !text_ends_with_abbreviation(
5609 prev_trimmed.trim_end_matches(['*', '_', '"', '\'', '\u{201D}', '\u{2019}']),
5610 &abbreviations,
5611 );
5612
5613 let inside_construct = paragraph_scan.as_ref().is_some_and(|(joined_atomic, line_starts)| {
5616 let join_offset = line_starts[i - paragraph_start] - 1;
5617 joined_atomic
5618 .iter()
5619 .any(|&(start, end)| start <= join_offset && join_offset < end)
5620 });
5621
5622 let ends_at_hard_break = has_hard_break(prev_line);
5629 if ends_at_hard_break
5630 || (is_self_contained_display_math_line(prev_line) && !inside_code_span[i - 1])
5631 || (is_self_contained_display_math_line(next_line) && !inside_code_span[i])
5632 || (options.sentence_per_line && ends_with_sentence && !inside_construct)
5633 {
5634 paragraph_parts.push((join_reflow_lines(¤t_part, options), ends_at_hard_break));
5636 current_part = vec![next_line];
5637 } else {
5638 current_part.push(next_line);
5639 }
5640 i += 1;
5641 }
5642
5643 if !current_part.is_empty() {
5645 if current_part.len() == 1 {
5646 paragraph_parts.push((current_part[0].to_string(), false));
5648 } else {
5649 paragraph_parts.push((join_reflow_lines(¤t_part, options), false));
5650 }
5651 }
5652
5653 for (j, (part, ends_at_hard_break)) in paragraph_parts.iter().enumerate() {
5655 let reflowed = reflow_line(part, options);
5656 result.extend(reflowed);
5657
5658 if *ends_at_hard_break
5664 && j < paragraph_parts.len() - 1
5665 && !result.is_empty()
5666 && !options.sentence_per_line
5667 {
5668 let last_idx = result.len() - 1;
5669 if !has_hard_break(&result[last_idx]) {
5670 result[last_idx].push_str(" ");
5671 }
5672 }
5673 }
5674 }
5675 }
5676
5677 let mut result_text = result.join("\n");
5680 if content.ends_with('\n') {
5681 result_text.push('\n');
5682 }
5683 result_text
5684}
5685
5686#[derive(Debug, Clone)]
5688pub struct ParagraphReflow {
5689 pub start_byte: usize,
5691 pub end_byte: usize,
5693 pub reflowed_text: String,
5695}
5696
5697#[derive(Debug, Clone)]
5703pub struct BlockquoteLineData {
5704 pub(crate) content: String,
5706 pub(crate) is_explicit: bool,
5708 pub(crate) prefix: Option<String>,
5710}
5711
5712impl BlockquoteLineData {
5713 pub fn explicit(content: String, prefix: String) -> Self {
5715 Self {
5716 content,
5717 is_explicit: true,
5718 prefix: Some(prefix),
5719 }
5720 }
5721
5722 pub fn lazy(content: String) -> Self {
5724 Self {
5725 content,
5726 is_explicit: false,
5727 prefix: None,
5728 }
5729 }
5730}
5731
5732#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5734pub enum BlockquoteContinuationStyle {
5735 Explicit,
5736 Lazy,
5737}
5738
5739pub fn blockquote_continuation_style(lines: &[BlockquoteLineData]) -> BlockquoteContinuationStyle {
5747 let mut explicit_count = 0usize;
5748 let mut lazy_count = 0usize;
5749
5750 for line in lines.iter().skip(1) {
5751 if line.is_explicit {
5752 explicit_count += 1;
5753 } else {
5754 lazy_count += 1;
5755 }
5756 }
5757
5758 if explicit_count > 0 && lazy_count == 0 {
5759 BlockquoteContinuationStyle::Explicit
5760 } else if lazy_count > 0 && explicit_count == 0 {
5761 BlockquoteContinuationStyle::Lazy
5762 } else if explicit_count >= lazy_count {
5763 BlockquoteContinuationStyle::Explicit
5764 } else {
5765 BlockquoteContinuationStyle::Lazy
5766 }
5767}
5768
5769pub fn dominant_blockquote_prefix(lines: &[BlockquoteLineData], fallback: &str) -> String {
5774 let mut counts: std::collections::HashMap<String, (usize, usize)> = std::collections::HashMap::new();
5775
5776 for (idx, line) in lines.iter().enumerate() {
5777 let Some(prefix) = line.prefix.as_ref() else {
5778 continue;
5779 };
5780 counts
5781 .entry(prefix.clone())
5782 .and_modify(|entry| entry.0 += 1)
5783 .or_insert((1, idx));
5784 }
5785
5786 counts
5787 .into_iter()
5788 .max_by(|(_, (count_a, first_idx_a)), (_, (count_b, first_idx_b))| {
5789 count_a.cmp(count_b).then_with(|| first_idx_b.cmp(first_idx_a))
5790 })
5791 .map_or_else(|| fallback.to_string(), |(prefix, _)| prefix)
5792}
5793
5794pub(crate) fn should_force_explicit_blockquote_line(content_line: &str) -> bool {
5799 let trimmed = content_line.trim_start();
5800 trimmed.starts_with('>')
5801 || trimmed.starts_with('#')
5802 || trimmed.starts_with("```")
5803 || trimmed.starts_with("~~~")
5804 || is_unordered_list_marker(trimmed)
5805 || is_numbered_list_item(trimmed)
5806 || is_horizontal_rule(trimmed)
5807 || is_definition_list_marker(trimmed)
5808 || (trimmed.starts_with('[') && trimmed.contains("]:"))
5809 || trimmed.starts_with(":::")
5810 || (trimmed.starts_with('<')
5811 && !trimmed.starts_with("<http")
5812 && !trimmed.starts_with("<https")
5813 && !trimmed.starts_with("<mailto:"))
5814}
5815
5816pub fn reflow_blockquote_content(
5825 lines: &[BlockquoteLineData],
5826 explicit_prefix: &str,
5827 continuation_style: BlockquoteContinuationStyle,
5828 options: &ReflowOptions,
5829) -> Vec<String> {
5830 let content_strs: Vec<&str> = lines.iter().map(|l| l.content.as_str()).collect();
5831 let ends_in_code_span = lines_ending_in_code_span(&content_strs);
5832 let segments = split_into_segments_strs(&content_strs, &ends_in_code_span);
5833 let mut reflowed_content_lines: Vec<String> = Vec::new();
5834
5835 for segment in segments {
5836 let hard_break_type = segment.last().and_then(|&(line, in_code_span)| {
5837 let line = line.strip_suffix('\r').unwrap_or(line);
5838 if in_code_span {
5839 None
5840 } else if line.ends_with('\\') {
5841 Some("\\")
5842 } else if line.ends_with(" ") {
5843 Some(" ")
5844 } else {
5845 None
5846 }
5847 });
5848
5849 let pieces: Vec<&str> = segment
5850 .iter()
5851 .map(|&(line, in_code_span)| {
5852 if in_code_span {
5853 line
5854 } else if let Some(l) = line.strip_suffix('\\') {
5855 l.trim_end_matches(is_breakable_whitespace)
5856 } else if let Some(l) = line.strip_suffix(" ") {
5857 l.trim_end_matches(is_breakable_whitespace)
5858 } else {
5859 line.trim_end_matches(is_breakable_whitespace)
5860 }
5861 })
5862 .collect();
5863
5864 let segment_text = join_reflow_lines(&pieces, options);
5865 let segment_text = trim_breakable_whitespace(&segment_text);
5866 if segment_text.is_empty() {
5867 continue;
5868 }
5869
5870 let mut segment_options = options.clone();
5871 if options.sentence_pack
5872 && !reflowed_content_lines.is_empty()
5873 && !should_force_explicit_blockquote_line(segment_text)
5874 {
5875 segment_options.first_line_length = None;
5876 }
5877 let mut reflowed = reflow_line(segment_text, &segment_options);
5878 if let Some(break_marker) = hard_break_type
5879 && !reflowed.is_empty()
5880 {
5881 let last_idx = reflowed.len() - 1;
5882 if !has_hard_break(&reflowed[last_idx]) {
5883 reflowed[last_idx].push_str(break_marker);
5884 }
5885 }
5886 reflowed_content_lines.extend(reflowed);
5887 }
5888
5889 let mut styled_lines: Vec<String> = Vec::new();
5890 for (idx, line) in reflowed_content_lines.iter().enumerate() {
5891 let force_explicit = idx == 0
5892 || continuation_style == BlockquoteContinuationStyle::Explicit
5893 || should_force_explicit_blockquote_line(line);
5894 if force_explicit {
5895 styled_lines.push(format!("{explicit_prefix}{line}"));
5896 } else {
5897 styled_lines.push(line.clone());
5898 }
5899 }
5900
5901 styled_lines
5902}
5903
5904fn is_blockquote_content_boundary(content: &str) -> bool {
5905 let trimmed = content.trim();
5906 trimmed.is_empty()
5907 || is_block_boundary(trimmed)
5908 || crate::utils::table_utils::TableUtils::is_potential_table_row(content)
5909 || trimmed.starts_with(":::")
5910 || crate::utils::is_template_directive_only(content)
5911 || is_standalone_attr_list(content)
5912 || is_snippet_block_delimiter(content)
5913}
5914
5915fn split_into_segments_strs<'a>(lines: &[&'a str], ends_in_code_span: &[bool]) -> Vec<Vec<(&'a str, bool)>> {
5919 let mut segments = Vec::new();
5920 let mut current = Vec::new();
5921 let inside_code_span = lines_touching_multiline_code_span(&lines.join("\n"));
5925
5926 for (idx, &line) in lines.iter().enumerate() {
5927 let is_display_math =
5932 is_self_contained_display_math_line(line) && !inside_code_span.get(idx).is_some_and(|&inside| inside);
5933 if is_display_math && !current.is_empty() {
5934 segments.push(std::mem::take(&mut current));
5935 }
5936 current.push((line, ends_in_code_span[idx]));
5937 if (has_hard_break(line) && !ends_in_code_span[idx]) || is_display_math {
5938 segments.push(std::mem::take(&mut current));
5939 }
5940 }
5941
5942 if !current.is_empty() {
5943 segments.push(current);
5944 }
5945
5946 segments
5947}
5948
5949fn reflow_blockquote_paragraph_at_line(
5950 content: &str,
5951 lines: &[&str],
5952 target_idx: usize,
5953 options: &ReflowOptions,
5954) -> Option<ParagraphReflow> {
5955 let mut anchor_idx = target_idx;
5956 let mut target_level = if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[target_idx]) {
5957 parsed.nesting_level
5958 } else {
5959 let mut found = None;
5960 let mut idx = target_idx;
5961 loop {
5962 if lines[idx].trim().is_empty() {
5963 break;
5964 }
5965 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[idx]) {
5966 found = Some((idx, parsed.nesting_level));
5967 break;
5968 }
5969 if idx == 0 {
5970 break;
5971 }
5972 idx -= 1;
5973 }
5974 let (idx, level) = found?;
5975 anchor_idx = idx;
5976 level
5977 };
5978
5979 let mut para_start = anchor_idx;
5981 while para_start > 0 {
5982 let prev_idx = para_start - 1;
5983 let prev_line = lines[prev_idx];
5984
5985 if prev_line.trim().is_empty() {
5986 break;
5987 }
5988
5989 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(prev_line) {
5990 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
5991 break;
5992 }
5993 para_start = prev_idx;
5994 continue;
5995 }
5996
5997 let prev_lazy = prev_line.trim_start();
5998 if is_blockquote_content_boundary(prev_lazy) {
5999 break;
6000 }
6001 para_start = prev_idx;
6002 }
6003
6004 while para_start < lines.len() {
6006 let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[para_start]) else {
6007 para_start += 1;
6008 continue;
6009 };
6010 target_level = parsed.nesting_level;
6011 break;
6012 }
6013
6014 if para_start >= lines.len() || para_start > target_idx {
6015 return None;
6016 }
6017
6018 let mut collected: Vec<(usize, BlockquoteLineData)> = Vec::new();
6021 let mut idx = para_start;
6022 while idx < lines.len() {
6023 if !collected.is_empty() && has_hard_break(&collected[collected.len() - 1].1.content) {
6024 break;
6025 }
6026
6027 let line = lines[idx];
6028 if line.trim().is_empty() {
6029 break;
6030 }
6031
6032 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(line) {
6033 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
6034 break;
6035 }
6036 collected.push((
6037 idx,
6038 BlockquoteLineData::explicit(trim_preserving_hard_break(parsed.content), parsed.prefix.to_string()),
6039 ));
6040 idx += 1;
6041 continue;
6042 }
6043
6044 let lazy_content = line.trim_start();
6045 if is_blockquote_content_boundary(lazy_content) {
6046 break;
6047 }
6048
6049 collected.push((idx, BlockquoteLineData::lazy(trim_preserving_hard_break(lazy_content))));
6050 idx += 1;
6051 }
6052
6053 if collected.is_empty() {
6054 return None;
6055 }
6056
6057 let para_end = collected[collected.len() - 1].0;
6058 if target_idx < para_start || target_idx > para_end {
6059 return None;
6060 }
6061
6062 let line_data: Vec<BlockquoteLineData> = collected.iter().map(|(_, d)| d.clone()).collect();
6063
6064 let fallback_prefix = line_data
6065 .iter()
6066 .find_map(|d| d.prefix.clone())
6067 .unwrap_or_else(|| "> ".to_string());
6068 let explicit_prefix = dominant_blockquote_prefix(&line_data, &fallback_prefix);
6069 let continuation_style = blockquote_continuation_style(&line_data);
6070
6071 let adjusted_line_length = options
6072 .line_length
6073 .saturating_sub(display_len(&explicit_prefix, options.length_mode))
6074 .max(1);
6075
6076 let adjusted_options = if options.sentence_pack && continuation_style == BlockquoteContinuationStyle::Lazy {
6077 ReflowOptions {
6078 first_line_length: if options.line_length == 0 || options.line_length == usize::MAX {
6079 None
6080 } else {
6081 Some(adjusted_line_length)
6082 },
6083 ..options.clone()
6084 }
6085 } else {
6086 ReflowOptions {
6087 line_length: adjusted_line_length,
6088 first_line_length: None,
6089 ..options.clone()
6090 }
6091 };
6092
6093 let styled_lines = reflow_blockquote_content(&line_data, &explicit_prefix, continuation_style, &adjusted_options);
6094
6095 if styled_lines.is_empty() {
6096 return None;
6097 }
6098
6099 let mut start_byte = 0;
6101 for line in lines.iter().take(para_start) {
6102 start_byte += line.len() + 1;
6103 }
6104
6105 let mut end_byte = start_byte;
6106 for line in lines.iter().take(para_end + 1).skip(para_start) {
6107 end_byte += line.len() + 1;
6108 }
6109
6110 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
6111 if !includes_trailing_newline {
6112 end_byte -= 1;
6113 }
6114
6115 let reflowed_joined = styled_lines.join("\n");
6116 let reflowed_text = if includes_trailing_newline {
6117 if reflowed_joined.ends_with('\n') {
6118 reflowed_joined
6119 } else {
6120 format!("{reflowed_joined}\n")
6121 }
6122 } else if reflowed_joined.ends_with('\n') {
6123 reflowed_joined.trim_end_matches('\n').to_string()
6124 } else {
6125 reflowed_joined
6126 };
6127
6128 Some(ParagraphReflow {
6129 start_byte,
6130 end_byte,
6131 reflowed_text,
6132 })
6133}
6134
6135pub fn reflow_paragraph_at_line(content: &str, line_number: usize, line_length: usize) -> Option<ParagraphReflow> {
6153 reflow_paragraph_at_line_with_mode(content, line_number, line_length, ReflowLengthMode::default())
6154}
6155
6156pub fn reflow_paragraph_at_line_with_mode(
6158 content: &str,
6159 line_number: usize,
6160 line_length: usize,
6161 length_mode: ReflowLengthMode,
6162) -> Option<ParagraphReflow> {
6163 let options = ReflowOptions {
6164 line_length,
6165 length_mode,
6166 ..Default::default()
6167 };
6168 reflow_paragraph_at_line_with_options(content, line_number, &options)
6169}
6170
6171pub fn reflow_paragraph_at_line_with_options(
6182 content: &str,
6183 line_number: usize,
6184 options: &ReflowOptions,
6185) -> Option<ParagraphReflow> {
6186 if line_number == 0 {
6187 return None;
6188 }
6189
6190 let lines: Vec<&str> = content.lines().collect();
6191
6192 if line_number > lines.len() {
6194 return None;
6195 }
6196
6197 let target_idx = line_number - 1; let target_line = lines[target_idx];
6199 let trimmed = target_line.trim();
6200
6201 if let Some(blockquote_reflow) = reflow_blockquote_paragraph_at_line(content, &lines, target_idx, options) {
6204 return Some(blockquote_reflow);
6205 }
6206
6207 if is_paragraph_boundary(trimmed, target_line) {
6209 return None;
6210 }
6211
6212 let mut para_start = target_idx;
6214 while para_start > 0 {
6215 let prev_idx = para_start - 1;
6216 let prev_line = lines[prev_idx];
6217 let prev_trimmed = prev_line.trim();
6218
6219 if is_paragraph_boundary(prev_trimmed, prev_line) {
6221 break;
6222 }
6223
6224 para_start = prev_idx;
6225 }
6226
6227 let mut para_end = target_idx;
6229 while para_end + 1 < lines.len() {
6230 let next_idx = para_end + 1;
6231 let next_line = lines[next_idx];
6232 let next_trimmed = next_line.trim();
6233
6234 if is_paragraph_boundary(next_trimmed, next_line) {
6236 break;
6237 }
6238
6239 para_end = next_idx;
6240 }
6241
6242 let paragraph_lines = &lines[para_start..=para_end];
6244
6245 let mut start_byte = 0;
6247 for line in lines.iter().take(para_start) {
6248 start_byte += line.len() + 1; }
6250
6251 let mut end_byte = start_byte;
6252 for line in paragraph_lines {
6253 end_byte += line.len() + 1; }
6255
6256 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
6259
6260 if !includes_trailing_newline {
6262 end_byte -= 1;
6263 }
6264
6265 let paragraph_text = paragraph_lines.join("\n");
6267
6268 let reflowed = reflow_markdown(¶graph_text, options);
6270
6271 let reflowed_text = if includes_trailing_newline {
6275 if reflowed.ends_with('\n') {
6277 reflowed
6278 } else {
6279 format!("{reflowed}\n")
6280 }
6281 } else {
6282 if reflowed.ends_with('\n') {
6284 reflowed.trim_end_matches('\n').to_string()
6285 } else {
6286 reflowed
6287 }
6288 };
6289
6290 Some(ParagraphReflow {
6291 start_byte,
6292 end_byte,
6293 reflowed_text,
6294 })
6295}
6296fn decompose_code_span(raw: &str) -> Option<(&str, &str)> {
6302 let marker_len = raw.bytes().take_while(|&b| b == b'`').count();
6303 if marker_len == 0 {
6304 return None;
6305 }
6306 let marker = &raw[..marker_len];
6307 if raw.len() < marker_len * 2 {
6308 return None;
6309 }
6310 let content = &raw[marker_len..raw.len() - marker_len];
6311 Some((content, marker))
6312}
6313
6314#[cfg(test)]
6315mod tests {
6316 use super::*;
6317
6318 #[test]
6319 fn source_sentence_breaks_respect_custom_abbreviations_and_atomic_constructs() {
6320 let custom = Some(vec!["Widget".to_string()]);
6321 assert_eq!(
6322 join_sentence_lines(&["Use Widget.", "then retry."], CjkSoftBreak::Space, &custom),
6323 "Use Widget. then retry."
6324 );
6325 for lines in [
6326 vec!["Read `literal.", "text` here."],
6327 vec here."],
6328 vec!["Read <span title=\"literal.", "text\"> here."],
6329 ] {
6330 assert_eq!(join_sentence_lines(&lines, CjkSoftBreak::Space, &None), lines.join(" "));
6331 }
6332 assert_eq!(
6333 join_sentence_lines(&["First sentence.", "lowercase follows."], CjkSoftBreak::Space, &None),
6334 "First sentence.\nlowercase follows."
6335 );
6336 }
6337
6338 #[test]
6342 fn preserves_content_accepts_whitespace_changes_and_rejects_the_rest() {
6343 let accepted: &[(&str, &[&str])] = &[
6344 ("one two three", &["one two three"]),
6345 ("one two three", &["one two", "three"]),
6346 ("one two three", &["one", "two", "three"]),
6347 ("one two ", &["one two"]),
6349 ("日本語のテキスト", &["日本語の", "テキスト"]),
6351 ("_First. Second._", &["_First.", "Second._"]),
6353 ];
6354 for (original, reflowed) in accepted {
6355 let reflowed: Vec<String> = reflowed.iter().map(ToString::to_string).collect();
6356 assert!(
6357 preserves_content(original, &reflowed),
6358 "{original:?} -> {reflowed:?} only moves whitespace"
6359 );
6360 }
6361
6362 let rejected: &[(&str, &[&str])] = &[
6363 ("one two three", &["one two"]),
6365 ("one two", &["one two three"]),
6367 ("one two", &["two one"]),
6369 ("_First. Second._", &["_First._", "_Second._"]),
6371 ("alpha and beta", &["alpha", "andbeta"]),
6373 ("mot suivant : autre", &["mot suivant: autre"]),
6375 ];
6376 for (original, reflowed) in rejected {
6377 let reflowed: Vec<String> = reflowed.iter().map(ToString::to_string).collect();
6378 assert!(
6379 !preserves_content(original, &reflowed),
6380 "{original:?} -> {reflowed:?} changes the text, not just its line breaks"
6381 );
6382 }
6383 }
6384
6385 #[test]
6388 fn nested_structure_reads_past_indentation_starting_the_text() {
6389 for indent in [" ", "\t", " "] {
6390 let text = format!("{indent}run `cargo test` now");
6391 let start = text.find('`').unwrap();
6392 let end = text.rfind('`').unwrap() + 1;
6393 assert_eq!(
6394 nested_structure(&text, None, false).code_spans,
6395 vec![(start, end)],
6396 "{text:?}"
6397 );
6398 }
6399 assert!(code_span_runs_into_indentation(&[" `cargo test", " --all`"]));
6400 }
6401
6402 #[test]
6405 fn lines_touching_multiline_code_span_reads_an_indented_code_block_as_code() {
6406 assert_eq!(
6407 lines_touching_multiline_code_span(" `a\n $$x`$$\n"),
6408 [false, false]
6409 );
6410 assert_eq!(lines_touching_multiline_code_span("Run `a\n$$x`$$\n"), [true, true]);
6411 }
6412
6413 #[test]
6415 fn reflow_line_falls_back_to_the_input_when_content_would_change() {
6416 let options = ReflowOptions {
6417 line_length: 40,
6418 ..Default::default()
6419 };
6420 let line = "one two three four five six seven eight nine ten";
6421
6422 assert!(preserves_content(line, &reflow_line(line, &options)));
6423 assert_eq!(reflow_line(line, &options), reflow_line_unchecked(line, &options));
6424 }
6425
6426 #[test]
6427 fn cascade_split_line_handles_a_very_long_line_without_overflowing() {
6428 let words: Vec<String> = (0..4000).map(|i| format!("word{i}")).collect();
6434 let line = words.join(" ");
6435
6436 let options = ReflowOptions {
6437 line_length: 80,
6438 length_mode: ReflowLengthMode::Chars,
6439 ..Default::default()
6440 };
6441 let out = cascade_split_line(&line, &options);
6442
6443 assert!(out.len() > 1, "a very long line should split into many lines");
6444 for segment in &out {
6445 assert!(
6446 display_len(segment, ReflowLengthMode::Chars) <= 80 || !segment.contains(' '),
6447 "each wrapped line should fit the width (or be a single unbreakable token)"
6448 );
6449 }
6450 let rejoined = out.join(" ");
6452 let original_words: Vec<&str> = line.split(' ').collect();
6453 let result_words: Vec<&str> = rejoined.split_whitespace().collect();
6454 assert_eq!(original_words, result_words, "reflow must preserve all words in order");
6455 }
6456
6457 #[test]
6462 fn test_helper_function_text_ends_with_abbreviation() {
6463 let abbreviations = get_abbreviations(&None);
6465
6466 assert!(text_ends_with_abbreviation("Dr.", &abbreviations));
6468 assert!(text_ends_with_abbreviation("word Dr.", &abbreviations));
6469 assert!(text_ends_with_abbreviation("e.g.", &abbreviations));
6470 assert!(text_ends_with_abbreviation("i.e.", &abbreviations));
6471 assert!(text_ends_with_abbreviation("Mr.", &abbreviations));
6472 assert!(text_ends_with_abbreviation("Mrs.", &abbreviations));
6473 assert!(text_ends_with_abbreviation("Ms.", &abbreviations));
6474 assert!(text_ends_with_abbreviation("Prof.", &abbreviations));
6475
6476 assert!(!text_ends_with_abbreviation("etc.", &abbreviations));
6478 assert!(!text_ends_with_abbreviation("paradigms.", &abbreviations));
6479 assert!(!text_ends_with_abbreviation("programs.", &abbreviations));
6480 assert!(!text_ends_with_abbreviation("items.", &abbreviations));
6481 assert!(!text_ends_with_abbreviation("systems.", &abbreviations));
6482 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)); }
6488
6489 #[test]
6490 fn test_footnote_after_period_splits_sentence() {
6491 let text = "First sentence.[^1] Second sentence.";
6495 let sentences = split_into_sentences(text, None, true, CjkSoftBreak::Space);
6496 assert_eq!(
6497 sentences,
6498 vec!["First sentence.[^1]".to_string(), "Second sentence.".to_string()],
6499 "footnote glued to the period should keep the boundary and stay attached to the first sentence"
6500 );
6501 }
6502
6503 #[test]
6504 fn test_multiple_consecutive_footnotes_after_period_splits_sentence() {
6505 let text = "Notes here.[^1][^2] Second sentence.";
6507 let sentences = split_into_sentences(text, None, true, CjkSoftBreak::Space);
6508 assert_eq!(
6509 sentences,
6510 vec!["Notes here.[^1][^2]".to_string(), "Second sentence.".to_string()]
6511 );
6512 }
6513
6514 #[test]
6515 fn test_footnote_before_period_still_splits_sentence() {
6516 let text = "Annotation here[^1]. Second sentence.";
6520 let sentences = split_into_sentences(text, None, true, CjkSoftBreak::Space);
6521 assert_eq!(
6522 sentences,
6523 vec!["Annotation here[^1].".to_string(), "Second sentence.".to_string()]
6524 );
6525 }
6526
6527 #[test]
6528 fn test_mid_sentence_footnote_does_not_split() {
6529 let text = "The system word[^1] more words. Next sentence.";
6532 let sentences = split_into_sentences(text, None, true, CjkSoftBreak::Space);
6533 assert_eq!(
6534 sentences,
6535 vec![
6536 "The system word[^1] more words.".to_string(),
6537 "Next sentence.".to_string()
6538 ]
6539 );
6540 }
6541
6542 #[test]
6543 fn test_bare_numeric_bracket_after_period_does_not_split() {
6544 let text = "Citation here.[1] Second sentence.";
6547 let sentences = split_into_sentences(text, None, true, CjkSoftBreak::Space);
6548 assert_eq!(
6549 sentences,
6550 vec![text.to_string()],
6551 "a bare numeric bracket must not be treated as a sentence boundary"
6552 );
6553 }
6554
6555 #[test]
6556 fn test_footnote_glued_to_following_word_does_not_split() {
6557 let text = "First sentence.[^1]Continued glued text.";
6560 let sentences = split_into_sentences(text, None, true, CjkSoftBreak::Space);
6561 assert_eq!(sentences, vec![text.to_string()]);
6562 }
6563
6564 #[test]
6565 fn test_footnote_at_end_of_text_is_preserved() {
6566 let text = "Sentence.[^1]";
6569 let sentences = split_into_sentences(text, None, true, CjkSoftBreak::Space);
6570 assert_eq!(sentences, vec![text.to_string()]);
6571 }
6572
6573 #[test]
6574 fn test_abbreviation_before_footnote_does_not_split() {
6575 let text = "See the notes, e.g.[^1] this one.";
6578 let sentences = split_into_sentences(text, None, true, CjkSoftBreak::Space);
6579 assert_eq!(
6580 sentences,
6581 vec![text.to_string()],
6582 "e.g. is an abbreviation, not a sentence boundary"
6583 );
6584 }
6585
6586 #[test]
6587 fn sentence_boundary_never_falls_inside_an_atomic_construct() {
6588 let cases = [
6594 "Prefix [link. Still link](https://example.com) tail. Next sentence.",
6595 "Prefix [target](<https://example.com/First. Second>) tail. Next sentence.",
6596 "Prefix [text](url \"Title. More\") tail. Next sentence.",
6597 "Prefix  tail. Next sentence.",
6598 "Prefix [ref text. More][ref] tail. Next sentence.",
6599 "Prefix [collapsed. More][] tail. Next sentence.",
6600 "Prefix [[Page name. Title]] tail. Next sentence.",
6601 "Prefix $x. Y$ tail. Next sentence.",
6602 "Prefix $$x. Y$$ tail. Next sentence.",
6603 "Prefix <span title=\"A. B\">x</span> tail. Next sentence.",
6604 "Prefix `code. Still code` tail. Next sentence.",
6605 ];
6606 for text in cases {
6607 let sentences = split_into_sentences(text, None, true, CjkSoftBreak::Space);
6608 let (head, tail) = text.rsplit_once(" tail. ").expect("case has a tail");
6609 assert_eq!(
6610 sentences,
6611 vec![format!("{head} tail."), tail.to_string()],
6612 "input {text:?}"
6613 );
6614 }
6615
6616 let text = "Prefix [shortcut. More] tail. Next sentence.";
6620 let whole = vec![
6621 "Prefix [shortcut. More] tail.".to_string(),
6622 "Next sentence.".to_string(),
6623 ];
6624 let defined = HashSet::from(["shortcut. more".to_string()]);
6625 assert_eq!(
6626 split_into_sentences(text, Some(&defined), true, CjkSoftBreak::Space),
6627 whole
6628 );
6629 assert_eq!(split_into_sentences(text, None, true, CjkSoftBreak::Space), whole);
6630 assert_eq!(
6631 split_into_sentences(text, Some(&HashSet::new()), true, CjkSoftBreak::Space),
6632 vec!["Prefix [shortcut.", "More] tail.", "Next sentence."]
6633 );
6634 }
6635
6636 #[test]
6637 fn a_sentence_may_open_with_a_link_or_image() {
6638 for text in [
6643 "Opening sentence. [First. Second](https://example.com)",
6644 "Opening sentence. ",
6645 "Opening sentence. [[First. Second]]",
6646 "Opening sentence. [[first-note|First. Second]]",
6647 "Opening sentence. [Ref link][ref]",
6648 "Opening sentence. [](url) continues.",
6651 "Opening sentence. [][ref] continues.",
6652 "Opening sentence. [![First image][img]](url) continues.",
6655 "Opening sentence. [![First image][]](url) continues.",
6656 "Opening sentence. [![First image][img]][ref] continues.",
6657 ] {
6658 let (head, tail) = text.split_once(". ").expect("case has a boundary");
6659 assert_eq!(
6660 split_into_sentences(text, None, true, CjkSoftBreak::Space),
6661 vec![format!("{head}."), tail.to_string()],
6662 "input {text:?}"
6663 );
6664 }
6665 let text = "Opening sentence. [![First image]](url) continues.";
6668 let defined = HashSet::from(["first image".to_string()]);
6669 assert_eq!(
6670 split_into_sentences(text, Some(&defined), true, CjkSoftBreak::Space),
6671 vec!["Opening sentence.", "[![First image]](url) continues."]
6672 );
6673 assert_eq!(
6674 split_into_sentences(text, Some(&HashSet::new()), true, CjkSoftBreak::Space),
6675 vec![text.to_string()],
6676 "an undefined shortcut is bracketed text, and `!` opens no sentence"
6677 );
6678 assert_eq!(
6681 split_into_sentences(
6682 "Opening sentence. [](url) continues.",
6683 None,
6684 true,
6685 CjkSoftBreak::Space
6686 ),
6687 vec](url) continues."]
6688 );
6689 let defined = HashSet::from(["smith 2020".to_string()]);
6692 assert_eq!(
6693 split_into_sentences(
6694 "Claim ends here. [Smith 2020] more text.",
6695 Some(&defined),
6696 true,
6697 CjkSoftBreak::Space
6698 ),
6699 vec!["Claim ends here.", "[Smith 2020] more text."]
6700 );
6701 let none_defined = HashSet::new();
6707 for text in [
6708 "Opening sentence. [first link](https://example.com) continues.",
6709 "Opening sentence. [[first note]] continues.",
6710 "Opening sentence. [[First Note|first note]] continues.",
6711 "Opening sentence. [[Page continues.",
6712 "Opening sentence. [[First] stray]] continues.",
6713 "Opening sentence.  continues.",
6714 "Opening sentence. [1] is the citation.",
6715 "Opening sentence. [First](unterminated",
6716 "Opening sentence. [First][unterminated",
6717 "Opening sentence. [First] (aside) continues.",
6718 "Claim ends here. [Smith 2020]",
6719 "Claim ends here. [Smith 2020] more text.",
6720 "See the RFC. [RFC] More text.",
6721 "Claim ends here. [^Note] more text.",
6722 ] {
6723 assert_eq!(
6724 split_into_sentences(text, Some(&none_defined), true, CjkSoftBreak::Space),
6725 vec![text.to_string()],
6726 "input {text:?}"
6727 );
6728 }
6729 }
6730
6731 #[test]
6732 fn link_opener_is_read_off_the_parse() {
6733 let len = |text: &str, defs: Option<&HashSet<String>>| {
6736 let chars: Vec<char> = text.chars().collect();
6737 let char_offsets = char_byte_offsets(&chars);
6738 let NestedStructure { links, .. } = sentence_structure(text, defs);
6739 let st = SentenceText {
6740 text,
6741 chars: &chars,
6742 char_offsets: &char_offsets,
6743 links: &links,
6744 code_spans: &[],
6745 markers: &[],
6746 marker_closers: &[],
6747 paragraph: None,
6748 cjk_soft_break: CjkSoftBreak::Space,
6749 };
6750 st.link_end_at(0).map_or(0, |end| link_opener_len(&chars, 0, end))
6751 };
6752 let none = HashSet::new();
6753 assert_eq!(len("[text](url)", Some(&none)), 1);
6754 assert_eq!(
6755 len("[text][ref]", Some(&none)),
6756 1,
6757 "a full reference is a link whether or not defined"
6758 );
6759 assert_eq!(len("[text][]", Some(&none)), 1);
6760 assert_eq!(len("", Some(&none)), 2);
6761 assert_eq!(len("[[wiki]]", Some(&none)), 2);
6762 assert_eq!(
6763 len("[[wiki|shown]]", Some(&none)),
6764 7,
6765 "the displayed text starts after the alias pipe"
6766 );
6767 assert_eq!(len("![[img.png|100]]", Some(&none)), 11);
6768 assert_eq!(len("[[wiki|a|b]]", Some(&none)), 7, "the first pipe starts the alias");
6769 assert_eq!(
6770 len("[[wiki|shown]] [[a|b]]", Some(&none)),
6771 7,
6772 "a pipe past the closing `]]` is not this alias"
6773 );
6774 assert_eq!(
6775 len("[a \\] b](url)", Some(&none)),
6776 1,
6777 "an escaped bracket does not close the text"
6778 );
6779 assert_eq!(
6780 len("[](url)", Some(&none)),
6781 1,
6782 "the outer opener is skipped first"
6783 );
6784 for text in [
6788 "[^1]",
6789 "[text](unterminated",
6790 "[text][unterminated",
6791 "[text] (url)",
6792 "[[wiki",
6793 "[[wiki]",
6794 "[[First] stray]]",
6795 "[Smith 2020]",
6796 "[Smith 2020] (see also)",
6797 "[unclosed",
6798 "!bang",
6799 "text",
6800 ] {
6801 assert_eq!(len(text, Some(&none)), 0, "input {text:?}");
6802 }
6803 let smith = HashSet::from(["smith 2020".to_string()]);
6806 assert_eq!(len("[Smith 2020]", Some(&smith)), 1);
6807 assert_eq!(len("[Smith 2020]", None), 1);
6808 }
6809
6810 #[test]
6811 fn sentence_per_line_reflow_breaks_before_a_bracket_only_where_the_check_counts() {
6812 let defined = HashSet::from(["spec".to_string()]);
6820 let options = ReflowOptions {
6821 line_length: 120,
6822 sentence_per_line: true,
6823 defined_references: Some(defined.clone()),
6824 ..Default::default()
6825 };
6826 for (text, expected) in [
6827 (
6828 "Claim ends here. [Smith](https://example.com) more text. Second sentence.",
6829 vec more text.",
6832 "Second sentence.",
6833 ],
6834 ),
6835 (
6836 "Wow! [smith](https://example.com) more text. Second sentence.",
6837 vec more text.", "Second sentence."],
6838 ),
6839 (
6840 "Claim ends here. [smith](https://example.com) more text. Second sentence.",
6841 vec more text.",
6843 "Second sentence.",
6844 ],
6845 ),
6846 (
6847 "Claim ends here. [smith][ref] more text. Second sentence.",
6848 vec!["Claim ends here. [smith][ref] more text.", "Second sentence."],
6849 ),
6850 (
6851 "Claim ends here.  more text. Second sentence.",
6852 vec more text.", "Second sentence."],
6853 ),
6854 (
6855 "Claim ends here.[Link](https://example.com) more text. Second sentence.",
6856 vec more text.",
6858 "Second sentence.",
6859 ],
6860 ),
6861 (
6862 "See the RFC. [RFC] More text. Second sentence.",
6863 vec!["See the RFC. [RFC] 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 "See the spec. [spec] more text. Second sentence.",
6871 vec!["See the spec. [spec] more text.", "Second sentence."],
6872 ),
6873 (
6874 "Claim ends here. [[page|Second sentence]] continues. Third sentence.",
6875 vec![
6876 "Claim ends here.",
6877 "[[page|Second sentence]] continues.",
6878 "Third sentence.",
6879 ],
6880 ),
6881 (
6882 "Claim ends here. [[Page|second sentence]] continues. Third sentence.",
6883 vec![
6884 "Claim ends here. [[Page|second sentence]] continues.",
6885 "Third sentence.",
6886 ],
6887 ),
6888 ] {
6889 let lines = reflow_line(text, &options);
6890 assert_eq!(lines, expected, "input {text:?}");
6891 assert_eq!(
6894 split_into_sentences(text, Some(&defined), true, CjkSoftBreak::Space).len(),
6895 expected.len(),
6896 "check count for {text:?}"
6897 );
6898 for line in &lines {
6899 assert_eq!(
6900 split_into_sentences(line, Some(&defined), true, CjkSoftBreak::Space).len(),
6901 1,
6902 "line {line:?} of {text:?}"
6903 );
6904 }
6905 }
6906 }
6907
6908 #[test]
6909 fn sentence_per_line_reflow_holds_atomic_constructs_whole() {
6910 let options = ReflowOptions {
6914 line_length: 80,
6915 sentence_per_line: true,
6916 ..Default::default()
6917 };
6918 let lines = reflow_line(
6919 "Prefix `code. Still code` and [link. Still link](https://example.com) tail. Next sentence.",
6920 &options,
6921 );
6922 assert_eq!(
6923 lines,
6924 vec tail.".to_string(),
6926 "Next sentence.".to_string(),
6927 ]
6928 );
6929
6930 let lines = reflow_line(
6931 "Prefix  and [target](<https://example.com/First. Second>) tail. Next sentence.",
6932 &options,
6933 );
6934 assert_eq!(
6935 lines,
6936 vec and [target](<https://example.com/First. Second>) tail.".to_string(),
6938 "Next sentence.".to_string(),
6939 ]
6940 );
6941
6942 let lines = reflow_line("First one. Then [link](url) second. Third one.", &options);
6945 assert_eq!(
6946 lines,
6947 vec second.".to_string(),
6950 "Third one.".to_string(),
6951 ]
6952 );
6953 }
6954
6955 #[test]
6956 fn test_is_unordered_list_marker() {
6957 assert!(is_unordered_list_marker("- item"));
6959 assert!(is_unordered_list_marker("* item"));
6960 assert!(is_unordered_list_marker("+ item"));
6961 assert!(is_unordered_list_marker("-")); assert!(is_unordered_list_marker("*"));
6963 assert!(is_unordered_list_marker("+"));
6964
6965 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")); }
6976
6977 #[test]
6978 fn test_is_block_boundary() {
6979 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"));
7001 assert!(!is_block_boundary("*emphasis*")); assert!(!is_block_boundary("[link](url)")); assert!(!is_block_boundary("some words here"));
7004 }
7005
7006 #[test]
7007 fn test_definition_list_boundary_in_single_line_paragraph() {
7008 let options = ReflowOptions {
7011 line_length: 80,
7012 ..Default::default()
7013 };
7014 let input = "Term\n: Definition of the term";
7015 let result = reflow_markdown(input, &options);
7016 assert!(
7018 result.contains(": Definition"),
7019 "Definition list item should not be merged into previous line. Got: {result:?}"
7020 );
7021 let lines: Vec<&str> = result.lines().collect();
7022 assert_eq!(lines.len(), 2, "Should remain two separate lines. Got: {lines:?}");
7023 assert_eq!(lines[0], "Term");
7024 assert_eq!(lines[1], ": Definition of the term");
7025 }
7026
7027 #[test]
7028 fn test_is_paragraph_boundary() {
7029 assert!(is_paragraph_boundary("# Heading", "# Heading"));
7031 assert!(is_paragraph_boundary("- item", "- item"));
7032 assert!(is_paragraph_boundary(":::", ":::"));
7033 assert!(is_paragraph_boundary(": definition", ": definition"));
7034
7035 assert!(is_paragraph_boundary("code", " code"));
7037 assert!(is_paragraph_boundary("code", "\tcode"));
7038
7039 assert!(is_paragraph_boundary("| a | b |", "| a | b |"));
7041 assert!(is_paragraph_boundary("a | b", "a | b")); assert!(!is_paragraph_boundary("regular text", "regular text"));
7045 assert!(!is_paragraph_boundary("text", " text")); }
7047
7048 #[test]
7049 fn test_div_marker_boundary_in_reflow_paragraph_at_line() {
7050 let content = "Some paragraph text here.\n\n::: {.callout-note}\nThis is a callout.\n:::\n";
7053 let result = reflow_paragraph_at_line(content, 3, 80);
7055 assert!(result.is_none(), "Div marker line should not be reflowed");
7056 }
7057
7058 #[test]
7059 fn starts_block_construct_detects_block_openers() {
7060 for case in ["- item", "-", "* item", "*", "+ item", "+", "-\titem"] {
7062 assert!(starts_block_construct(case), "bullet: {case:?}");
7063 }
7064 for case in ["1. item", "1) item", "01. x", "000000001. x", "1.\titem"] {
7067 assert!(starts_block_construct(case), "ordered: {case:?}");
7068 }
7069 for case in ["> quote", ">quote", ">"] {
7071 assert!(starts_block_construct(case), "blockquote: {case:?}");
7072 }
7073 for case in ["# heading", "###### h6", "#", "##"] {
7075 assert!(starts_block_construct(case), "heading: {case:?}");
7076 }
7077 for case in [
7080 "#hashtag",
7081 "#help-desk.",
7082 "#123",
7083 "##tag",
7084 "######x",
7085 "#\u{1F600}",
7086 "#*x",
7087 "#_x",
7088 ] {
7089 assert!(starts_block_construct(case), "heading missing space: {case:?}");
7090 }
7091 for case in ["```", "```rust", "````", "~~~", "~~~text"] {
7093 assert!(starts_block_construct(case), "fence: {case:?}");
7094 }
7095 for case in ["---", "--", "===", "=", "***", "___", "_ _ _", "- - -"] {
7097 assert!(starts_block_construct(case), "setext/thematic: {case:?}");
7098 }
7099 for case in [": definition", ":\tdefinition", ":", ": ", "::: note"] {
7102 assert!(starts_block_construct(case), "definition list: {case:?}");
7103 }
7104 for case in [
7107 "[^1]: text",
7108 "[^note]:",
7109 "[ref]: http://example.com",
7110 "[wat]: url follows",
7111 ] {
7112 assert!(starts_block_construct(case), "definition: {case:?}");
7113 }
7114 for case in ["<div>content", "</div>", "<p>text", "<table>", "<pre>code", "<h1>x"] {
7116 assert!(starts_block_construct(case), "html block: {case:?}");
7117 }
7118 for case in ["<!-- note -->", "<!-->", "<?php", "<!DOCTYPE html>", "<![CDATA[x]]>"] {
7120 assert!(starts_block_construct(case), "untagged html block: {case:?}");
7121 }
7122 for case in [
7125 "--- | ---",
7126 "------ | - |",
7127 "-|-",
7128 "--:|:--",
7129 "- | -",
7130 "---|",
7131 "-- |\t:-:",
7132 ] {
7133 assert!(starts_block_construct(case), "table delimiter row: {case:?}");
7134 }
7135 }
7136
7137 #[test]
7138 fn starts_block_construct_allows_ordinary_prose() {
7139 for case in [
7140 "",
7141 "word",
7142 "-5 degrees",
7143 "--flag",
7144 "-item",
7145 "####### seven hashes is not a heading",
7146 "#######x",
7147 "a #b",
7148 "x#y",
7149 "7#8",
7150 "1.5 million",
7151 "1234567890. ten digits is not a list marker",
7152 "0000000001. ten digits is not a list marker either",
7153 "2. item",
7156 "7. item",
7157 "0. item",
7158 "42) x",
7159 "123456. item",
7160 "1.",
7161 "1)",
7162 "123456.",
7163 "123456)",
7164 "1.item",
7165 "1:30 pm",
7166 "*emphasis*",
7167 "**bold** text",
7168 "__bold__ text",
7169 "_emphasis_ text",
7170 "`code` span",
7171 "`` double backtick span ``",
7172 "~~strikethrough~~",
7173 "=x",
7174 "== ==",
7175 "(parenthetical)",
7176 "[link](url)",
7177 "[text][ref] more",
7178 "[bracketed] aside",
7179 "[a](b) [ref]: first bracket is a link, not a label",
7180 "[esc\\]: not a close] text",
7181 "<span>inline</span>",
7182 "<b>bold</b>",
7183 "<https://example.com> autolink",
7184 "<mailto:a@b.com>",
7185 "<notarealtag>",
7186 "--flag | grep x",
7187 "-- | it depends",
7188 "--- | -x |",
7189 ] {
7190 assert!(!starts_block_construct(case), "prose: {case:?}");
7191 }
7192 }
7193
7194 #[test]
7195 fn merge_block_construct_continuations_merges_marker_led_lines() {
7196 let lines = vec![
7197 "First sentence?".to_string(),
7198 "- looks like a list item".to_string(),
7199 "Second sentence.".to_string(),
7200 ];
7201 assert_eq!(
7202 merge_block_construct_continuations(lines),
7203 vec![
7204 "First sentence? - looks like a list item".to_string(),
7205 "Second sentence.".to_string(),
7206 ]
7207 );
7208
7209 let lines = vec!["- real list content".to_string(), "continuation".to_string()];
7212 assert_eq!(
7213 merge_block_construct_continuations(lines.clone()),
7214 lines,
7215 "first line must never be merged"
7216 );
7217
7218 let lines = vec!["prose".to_string(), "1.".to_string(), "[ref]:".to_string()];
7221 assert_eq!(
7222 merge_block_construct_continuations(lines),
7223 vec!["prose 1. [ref]:".to_string()],
7224 "a merge that creates an opener must fold again"
7225 );
7226 }
7227
7228 #[test]
7229 fn merge_block_construct_continuations_folds_a_line_after_a_backslash() {
7230 let lines = vec!["Press the \\".to_string(), "key.".to_string()];
7232 assert_eq!(
7233 merge_block_construct_continuations(lines),
7234 vec!["Press the \\ key.".to_string()]
7235 );
7236
7237 let lines = vec!["A path C:\\\\".to_string(), "here.".to_string()];
7240 assert_eq!(merge_block_construct_continuations(lines.clone()), lines);
7241
7242 let lines = vec!["Line one".to_string(), "ends here \\".to_string()];
7245 assert_eq!(merge_block_construct_continuations(lines.clone()), lines);
7246 }
7247
7248 #[test]
7249 fn wrap_never_starts_a_line_with_a_block_marker() {
7250 let options = ReflowOptions {
7251 line_length: 25,
7252 ..Default::default()
7253 };
7254 let lines = reflow_line(
7257 "Some words here and then - a dash clause that wraps around the limit.",
7258 &options,
7259 );
7260 assert_eq!(
7261 lines,
7262 vec![
7263 "Some words here and",
7264 "then - a dash clause that",
7265 "wraps around the limit."
7266 ]
7267 );
7268
7269 for input in [
7271 "Alpha beta gamma delta epsilon - dash clause here to wrap",
7272 "Alpha beta gamma delta epsilon > quote lookalike here to wrap",
7273 "Alpha beta gamma delta epsilon # heading lookalike here to wrap",
7274 "Alpha beta gamma delta epsilon 1. ordered lookalike here to wrap",
7275 "Alpha beta gamma delta epsilon * star clause here to wrap",
7276 "Alpha beta gamma delta epsilon + plus clause here to wrap",
7277 ] {
7278 for width in 10..40 {
7279 let options = ReflowOptions {
7280 line_length: width,
7281 ..Default::default()
7282 };
7283 for line in reflow_line(input, &options) {
7284 assert!(
7285 !starts_block_construct(&line),
7286 "width {width}: wrapped line opens a block construct: {line:?} (input {input:?})"
7287 );
7288 }
7289 }
7290 }
7291 }
7292
7293 #[test]
7296 fn hash_word_never_starts_a_wrapped_line_in_any_mode() {
7297 let options_for = |mode: &str, width: usize| ReflowOptions {
7298 line_length: width,
7299 sentence_per_line: mode == "sentence-per-line",
7300 semantic_line_breaks: mode == "semantic",
7301 ..Default::default()
7302 };
7303 let inputs = [
7304 "Alpha beta gamma delta epsilon #help-desk and the rest here to wrap",
7305 "Alpha beta gamma delta epsilon, ##tag and the rest here to wrap",
7306 "Alpha beta gamma delta fixed in #123, thanks everyone for the report here",
7307 "Is it open? #help-desk is. Really! #123 too. Yes? #1 also.",
7308 "Ask in the channel, #help-desk or elsewhere, and #123 maybe, then more.",
7309 "Questions about this guide? Post them in the channel #help-desk.",
7310 ];
7311 for mode in ["wrap", "sentence-per-line", "semantic"] {
7312 for input in inputs {
7313 for width in 10..60 {
7314 let lines = reflow_line(input, &options_for(mode, width));
7315 for line in &lines {
7316 let hashes = line.bytes().take_while(|&b| b == b'#').count();
7317 assert!(
7318 !starts_block_construct(line) && !(1..=6).contains(&hashes),
7319 "{mode} width {width}: line opens a block construct: {line:?} (input {input:?})"
7320 );
7321 }
7322 let words = |s: &str| s.split_whitespace().map(String::from).collect::<Vec<_>>();
7323 assert_eq!(
7324 words(&lines.join(" ")),
7325 words(input),
7326 "{mode} width {width}: content changed (input {input:?})"
7327 );
7328 }
7329 }
7330 }
7331 }
7332
7333 #[test]
7335 fn hash_controls_stay_where_they_are() {
7336 let options = ReflowOptions {
7337 line_length: 80,
7338 ..Default::default()
7339 };
7340 for input in [
7341 "Issue numbers like # alone and 7#8 and a#b stay mid-line",
7342 "Seven hashes #######y are prose and fixes #12 are fine here",
7343 ] {
7344 assert_eq!(reflow_line(input, &options), vec![input.to_string()]);
7345 }
7346 let options = ReflowOptions {
7348 line_length: 20,
7349 ..Default::default()
7350 };
7351 let lines = reflow_line("aaaa bbbb cccc dddd #######y zzzz", &options);
7352 assert!(
7353 lines.iter().any(|l| l.starts_with("#######y")),
7354 "7+ hashes may start a line: {lines:?}"
7355 );
7356 }
7357
7358 #[test]
7361 fn block_guard_covers_every_line_start_md018_acts_on() {
7362 use crate::config::MarkdownFlavor;
7363 use crate::lint_context::LintContext;
7364 use crate::rule::Rule;
7365 use crate::rules::MD018NoMissingSpaceAtx;
7366
7367 let rule = MD018NoMissingSpaceAtx::new();
7368 let mut acted_on = 0;
7369 for line in [
7370 "#a",
7371 "##a",
7372 "###a",
7373 "####a",
7374 "#####a",
7375 "######a",
7376 "#######a",
7377 "# a",
7378 "## a",
7379 "#",
7380 "##",
7381 "#1",
7382 "#123",
7383 "#help-desk.",
7384 "#*a",
7385 "#_a",
7386 "#\u{FE0F}",
7387 "#\u{1F600}",
7388 "#a#b",
7389 "##\u{00e9}",
7390 "#-x",
7391 "#.x",
7392 "#(x)",
7393 ] {
7394 let content = format!("para\n{line}\n");
7395 let ctx = LintContext::new(&content, MarkdownFlavor::Standard, None);
7396 let acts = ctx.lines[1].heading.is_some()
7397 || ctx.lines[1].atx_missing_space.is_some()
7398 || !rule.check(&ctx).unwrap().is_empty();
7399 if acts {
7400 acted_on += 1;
7401 assert!(
7402 starts_block_construct(line),
7403 "MD018/heading detection acts on {line:?} but the reflow guard allows it"
7404 );
7405 }
7406 }
7407 assert!(acted_on >= 10, "the corpus must exercise the detector, got {acted_on}");
7408 }
7409
7410 #[test]
7411 fn sentence_per_line_keeps_block_markers_mid_line() {
7412 let options = ReflowOptions {
7413 line_length: 80,
7414 sentence_per_line: true,
7415 ..Default::default()
7416 };
7417 let lines = reflow_line(
7420 "Google Calendar (Can't we get rid of this dependency? - I don't really see the need)",
7421 &options,
7422 );
7423 assert_eq!(
7424 lines,
7425 vec!["Google Calendar (Can't we get rid of this dependency? - I don't really see the need)".to_string()]
7426 );
7427
7428 let lines = reflow_line("See section 4? # is the marker we use. Fine.", &options);
7430 assert_eq!(lines, vec!["See section 4? # is the marker we use.", "Fine."]);
7431
7432 let lines = reflow_line("Is this a problem? > I quote someone here.", &options);
7433 assert_eq!(lines, vec!["Is this a problem? > I quote someone here."]);
7434
7435 let lines = reflow_line("Another case! 1. Not a list. More text follows here.", &options);
7436 for line in &lines {
7437 assert!(
7438 !starts_block_construct(line),
7439 "sentence-per-line output opens a block construct: {line:?}"
7440 );
7441 }
7442 }
7443
7444 fn strict_sentence_lines(input: &str, require_sentence_capital: bool) -> Vec<String> {
7446 let options = ReflowOptions {
7447 line_length: 80,
7448 sentence_per_line: true,
7449 require_sentence_capital,
7450 ..Default::default()
7451 };
7452 reflow_line(input, &options)
7453 }
7454
7455 #[test]
7456 fn strict_mode_lets_a_sentence_open_with_a_number() {
7457 for (input, expected) in [
7461 (
7462 "The number of items was 5. 2 of them failed.",
7463 vec!["The number of items was 5.", "2 of them failed."],
7464 ),
7465 (
7466 "Sometimes we have 2. 3 might be here.",
7467 vec!["Sometimes we have 2.", "3 might be here."],
7468 ),
7469 (
7470 "The number of items was 5. 2nd sentence.",
7471 vec!["The number of items was 5.", "2nd sentence."],
7472 ),
7473 (
7474 "Released in 2020. 3 of them failed.",
7475 vec!["Released in 2020.", "3 of them failed."],
7476 ),
7477 (
7478 "First sentence. 2nd sentence.",
7479 vec!["First sentence.", "2nd sentence."],
7480 ),
7481 (
7482 "We met at 6:00 sharp. 6:00 is early.",
7483 vec!["We met at 6:00 sharp.", "6:00 is early."],
7484 ),
7485 ("Pi is 3.14 roughly. Next.", vec!["Pi is 3.14 roughly.", "Next."]),
7486 (
7489 "A \"Is this a test?\" 2020 was memorable.",
7490 vec!["A \"Is this a test?\"", "2020 was memorable."],
7491 ),
7492 ] {
7493 assert_eq!(strict_sentence_lines(input, true), expected, "input {input:?}");
7494 }
7495
7496 for input in [
7499 "The count was 5. and that was all.",
7500 "See fig. 3 for details.",
7501 "See no. 5 in the list.",
7502 "See ch. 12 and vol. 3 for more.",
7503 "A \"Is this a test?\" guide to it.",
7504 ] {
7505 assert_eq!(
7506 strict_sentence_lines(input, true),
7507 vec![input.to_string()],
7508 "input {input:?}"
7509 );
7510 }
7511 }
7512
7513 #[test]
7514 fn sentence_never_opens_with_an_ordered_list_marker() {
7515 for (input, require_capital, expected) in [
7522 (
7523 "Steps: 1. Do this. 2. Do that.",
7524 true,
7525 vec!["Steps: 1.", "Do this. 2.", "Do that."],
7526 ),
7527 (
7528 "First sentence. 1. Do that.",
7529 true,
7530 vec!["First sentence. 1.", "Do that."],
7531 ),
7532 ("Do this! 2. Do that.", true, vec!["Do this! 2.", "Do that."]),
7533 ("Do this. 12) Do that.", true, vec!["Do this. 12) Do that."]),
7534 ("Do this. 2. do that.", true, vec!["Do this. 2. do that."]),
7535 ("Do this. 2. do that.", false, vec!["Do this. 2.", "do that."]),
7536 (
7537 "Twelve. 1234567890. next one here.",
7538 true,
7539 vec!["Twelve. 1234567890. next one here."],
7540 ),
7541 ("Do this. 2 more times.", true, vec!["Do this.", "2 more times."]),
7544 ("How many? 2.", true, vec!["How many?", "2."]),
7545 ("第一句。 2. Do that.", true, vec!["第一句。 2.", "Do that."]),
7547 ("第一句。 2) 第二句。", true, vec!["第一句。 2) 第二句。"]),
7548 ("第一句。 2 more.", true, vec!["第一句。", "2 more."]),
7549 ("第一句。 第二句。", true, vec!["第一句。", "第二句。"]),
7550 ] {
7551 let lines = strict_sentence_lines(input, require_capital);
7552 assert_eq!(lines, expected, "input {input:?}, require capital {require_capital}");
7553 for line in &lines {
7554 let chars: Vec<char> = line.chars().collect();
7555 assert!(
7556 !opens_ordered_list_marker(&chars),
7557 "line opens with an ordered-list marker: {line:?} (input {input:?})"
7558 );
7559 }
7560 }
7561 }
7562
7563 #[test]
7564 fn opens_ordered_list_marker_matches_the_marker_shape() {
7565 let chars = |s: &str| s.chars().collect::<Vec<char>>();
7566 for text in [
7567 "2. x",
7568 "1) x",
7569 "12. x",
7570 "1.\tx",
7571 "2.\nx",
7572 "1)\nx",
7573 "1234567890. x",
7574 "0. x",
7575 ] {
7576 assert!(opens_ordered_list_marker(&chars(text)), "{text:?} is a marker");
7577 }
7578 for text in ["2.x", "2.", "2)", "2 x", "x. y", "", " 2. x", "2.5 x", "-2. x"] {
7579 assert!(!opens_ordered_list_marker(&chars(text)), "{text:?} is not a marker");
7580 }
7581 }
7582
7583 #[test]
7584 fn inline_math_directly_after_display_math_stays_atomic() {
7585 let options = ReflowOptions {
7593 line_length: 8,
7594 ..Default::default()
7595 };
7596 let lines = reflow_line("$$a$$$bb cc dd$ x", &options);
7597 assert_eq!(lines, vec!["$$a$$$bb cc dd$".to_string(), "x".to_string()]);
7598 }
7599
7600 #[test]
7601 fn test_code_span_parsing() {
7602 let elements = parse_markdown_elements_inner("`code`", false, false, None);
7604 assert_eq!(elements.len(), 1);
7605 assert!(matches!(&elements[0], Element::Code { content, marker } if content == "code" && marker == "`"));
7606
7607 let elements = parse_markdown_elements_inner("``code``", false, false, None);
7609 assert_eq!(elements.len(), 1);
7610 assert!(matches!(&elements[0], Element::Code { content, marker } if content == "code" && marker == "``"));
7611
7612 let elements = parse_markdown_elements_inner("``code`inside``", false, false, None);
7614 assert_eq!(elements.len(), 1);
7615 assert!(
7616 matches!(&elements[0], Element::Code { content, marker } if content == "code`inside" && marker == "``")
7617 );
7618
7619 let elements = parse_markdown_elements_inner("`` code ``", false, false, None);
7621 assert_eq!(elements.len(), 1);
7622 assert!(matches!(&elements[0], Element::Code { content, marker } if content == " code " && marker == "``"));
7623
7624 let elements = parse_markdown_elements_inner("`unclosed", false, false, None);
7626 assert_eq!(elements.len(), 1);
7627 assert!(matches!(&elements[0], Element::Text(s) if s == "`unclosed"));
7628
7629 let elements = parse_markdown_elements_inner("`unclosed [link](url)", false, false, None);
7631 assert_eq!(elements.len(), 2);
7633 assert!(matches!(&elements[0], Element::Text(s) if s == "`unclosed "));
7634 assert!(matches!(&elements[1], Element::Link(s) if s == "[link](url)"));
7635 }
7636
7637 #[test]
7638 fn test_reflow_performance_long_input() {
7639 let mut text = String::new();
7642 for i in 1..400 {
7643 let backticks = "`".repeat(i);
7644 text.push_str(&backticks);
7645 text.push(' ');
7646 }
7647
7648 let start = std::time::Instant::now();
7649 let elements = parse_markdown_elements_inner(&text, false, false, None);
7650 let duration = start.elapsed();
7651
7652 assert!(duration.as_millis() < 100, "Parsing took too long: {duration:?}");
7654 assert!(!elements.is_empty());
7655 }
7656
7657 #[test]
7658 fn test_reflow_performance_display_math_heavy() {
7659 let text = "$$a$$".repeat(4000);
7664
7665 let start = std::time::Instant::now();
7666 let elements = parse_markdown_elements_inner(&text, false, false, None);
7667 let duration = start.elapsed();
7668
7669 assert!(duration.as_millis() < 100, "Parsing took too long: {duration:?}");
7670 assert_eq!(elements.len(), 4000);
7671 }
7672
7673 #[test]
7674 fn inline_math_len_at_start_matches_regex_at_slice_start() {
7675 let alphabet = ['$', 'a', ' '];
7680 let mut inputs: Vec<String> = vec![String::new()];
7681 let mut frontier: Vec<String> = vec![String::new()];
7682 for _ in 0..6 {
7683 let mut longer = Vec::new();
7684 for prefix in &frontier {
7685 for ch in alphabet {
7686 let mut s = prefix.clone();
7687 s.push(ch);
7688 longer.push(s);
7689 }
7690 }
7691 inputs.extend(longer.iter().cloned());
7692 frontier = longer;
7693 }
7694 inputs.push("$αβ$x".to_string());
7696 inputs.push("$α$$".to_string());
7697
7698 for s in &inputs {
7699 let expected = INLINE_MATH_REGEX
7700 .find(s)
7701 .ok()
7702 .flatten()
7703 .filter(|m| m.start() == 0)
7704 .map(|m| m.end());
7705 assert_eq!(inline_math_len_at_start(s), expected, "input: {s:?}");
7706 }
7707 }
7708
7709 #[test]
7710 fn inline_math_probe_after_dollar_matches_uncached_parse() {
7711 let cases = [
7717 ("$$a$$$b c$ x", r#"[DisplayMath("a"), InlineMath("b c"), Text(" x")]"#),
7718 (
7719 "$$a$$$b$ $$a$$$b$",
7720 r#"[DisplayMath("a"), InlineMath("b"), Text(" "), DisplayMath("a"), InlineMath("b")]"#,
7721 ),
7722 (
7724 "$$a$$$ x $y z$",
7725 r#"[DisplayMath("a"), InlineMath(" x "), Text("y z$")]"#,
7726 ),
7727 ("$$a$$$$ x", r#"[DisplayMath("a"), Text("$$ x")]"#),
7729 ("$$a$$$$b$$ x", r#"[DisplayMath("a"), DisplayMath("b"), Text(" x")]"#),
7730 (
7732 "$a$$b$$c$$d$ tail",
7733 r#"[Text("$a"), DisplayMath("b"), Text("c$$d$ tail")]"#,
7734 ),
7735 ];
7736 for (input, expected) in cases {
7737 let elements = parse_markdown_elements_inner(input, false, false, None);
7738 assert_eq!(format!("{elements:?}"), expected, "input: {input:?}");
7739 }
7740 }
7741
7742 #[test]
7743 fn test_atomic_spans() {
7744 let text_emphasis = "hello **word1 word2**";
7746
7747 let options_disabled = ReflowOptions {
7748 line_length: 18,
7749 atomic_spans: true,
7750 ..Default::default()
7751 };
7752 let lines_disabled = reflow_line(text_emphasis, &options_disabled);
7753 assert_eq!(lines_disabled, vec!["hello", "**word1 word2**"]);
7754
7755 let options_enabled = ReflowOptions {
7756 line_length: 18,
7757 atomic_spans: false,
7758 ..Default::default()
7759 };
7760 let lines_enabled = reflow_line(text_emphasis, &options_enabled);
7761 assert_eq!(lines_enabled, vec!["hello **word1", "word2**"]);
7762
7763 let text_code = "hello `word1 word2`";
7765
7766 let lines_code_disabled = reflow_line(text_code, &options_disabled);
7767 assert_eq!(lines_code_disabled, vec!["hello", "`word1 word2`"]);
7768
7769 let lines_code_enabled = reflow_line(text_code, &options_enabled);
7770 assert_eq!(lines_code_enabled, vec!["hello `word1", "word2`"]);
7771
7772 let text_code_padding = "hello `` `word1` `word2` ``";
7774 let lines_padding_enabled = reflow_line(text_code_padding, &options_enabled);
7775 assert_eq!(lines_padding_enabled, vec![r#"hello `` `word1`"#, r#"`word2` ``"#]);
7776
7777 let text_attached = "**one two**,"; let options_11 = ReflowOptions {
7782 line_length: 11,
7783 atomic_spans: true,
7784 ..Default::default()
7785 };
7786 assert_eq!(reflow_line(text_attached, &options_11), vec!["**one two**,"]);
7787
7788 let options_10 = ReflowOptions {
7790 line_length: 10,
7791 atomic_spans: true,
7792 ..Default::default()
7793 };
7794 assert_eq!(reflow_line(text_attached, &options_10), vec!["**one", "two**,"]);
7795 }
7796
7797 #[test]
7798 fn test_emphasis_containing_markers_is_not_split() {
7799 let options = ReflowOptions {
7800 line_length: 5,
7801 atomic_spans: false,
7802 ..Default::default()
7803 };
7804 let lines = reflow_line(r#"*foo \*bar*"#, &options);
7806 assert_eq!(lines, vec![r#"*foo \*bar*"#.to_string()]);
7807 }
7808
7809 fn semantic_shape(markdown: &str) -> String {
7814 let mut options = Options::empty();
7815 options.insert(Options::ENABLE_STRIKETHROUGH);
7816 let mut out = String::new();
7817 let push_prose = |out: &mut String, text: &str| {
7818 for c in text.chars() {
7819 if c.is_whitespace() {
7820 if !out.ends_with(char::is_whitespace) {
7821 out.push(' ');
7822 }
7823 } else {
7824 out.push(c);
7825 }
7826 }
7827 };
7828 for event in Parser::new_ext(markdown, options) {
7829 match event {
7830 Event::Text(text) => push_prose(&mut out, &text),
7831 Event::SoftBreak | Event::HardBreak => push_prose(&mut out, " "),
7832 Event::Code(code) => out.push_str(&format!("<code>{code}</code>")),
7834 Event::Start(tag) => out.push_str(&format!("<{tag:?}>")),
7835 Event::End(tag) => out.push_str(&format!("</{tag:?}>")),
7836 other => out.push_str(&format!("{other:?}")),
7837 }
7838 }
7839 out.trim().to_string()
7840 }
7841
7842 #[test]
7843 fn test_wrapping_a_span_never_changes_what_it_parses_to() {
7844 let corpus = [
7848 "_This is a very, very, very, very, very long line with some `code` inside._",
7849 "_alpha beta gamma delta epsilon `a b` zeta eta theta iota kappa lambda_",
7850 "**strong text with `code` and more words than fit on one single line**",
7851 "~~struck text with `code` and more words than fit on one single line~~",
7852 "_emphasis with **nested strong that is quite long** and trailing words_",
7853 "***A doubly nested bold italic span with more words than fit on a line***",
7856 "___Another doubly nested span with more words than fit on a single line___",
7857 "**_mixed strong then emphasis with more words than fit on a single line_**",
7858 "*__mixed emphasis then strong with more words than fit on a single line__*",
7859 "**~~strong strikethrough with more words than fit on a single line here~~**",
7860 "**a * b with a stray marker and plenty more words to pass the budget**",
7863 "_foo `a` bar `b` baz qux quux corge grault garply waldo fred plugh xyzzy_",
7864 "text before _a long emphasis with `code` inside of it here_ and after",
7865 "(_a parenthesized long emphasis with `code` inside of it right here_)",
7866 r#"*foo \*bar baz qux quux corge grault garply waldo fred plugh xyzzy*"#,
7867 "_tab\tseparated `a\tb` words spread out over quite a long emphasis span_",
7868 "_This has [text](<a b c d e f g h i j k>) and trailing words to wrap._",
7871 "_This has [`code`](<a b c d e f g h i j k>) and trailing words to wrap._",
7872 r#"_See [x](https://example.com "a long link title here") and `code` too._"#,
7873 "_A [link with a long label](https://example.com/path) and `code` here._",
7874 "_An image  plus `code` and more text_",
7875 ];
7876 for text in corpus {
7877 let expected = semantic_shape(text);
7878 for line_length in [20, 30, 40, 80] {
7879 for atomic_spans in [true, false] {
7880 let options = ReflowOptions {
7881 line_length,
7882 atomic_spans,
7883 ..Default::default()
7884 };
7885 let wrapped = reflow_line(text, &options).join("\n");
7886 assert_eq!(
7887 semantic_shape(&wrapped),
7888 expected,
7889 "reflow changed the parse of {text:?} at line_length={line_length} \
7890 atomic_spans={atomic_spans}\n wrapped: {wrapped:?}"
7891 );
7892 }
7893 }
7894 }
7895 }
7896
7897 #[test]
7898 fn test_wrapping_a_span_keeps_whole_the_constructs_pulldown_cannot_see() {
7899 let cases = [
7903 (
7904 "_alpha beta gamma [[a wiki link]] delta epsilon zeta eta_",
7905 "[[a wiki link]]",
7906 ),
7907 (
7908 "_alpha beta gamma {{< foo bar >}} delta epsilon zeta eta_",
7909 "{{< foo bar >}}",
7910 ),
7911 ("_alpha beta gamma $a + b$ delta epsilon zeta eta theta_", "$a + b$"),
7912 ("_alpha beta gamma $$a + b$$ delta epsilon zeta eta theta_", "$$a + b$$"),
7913 ];
7914 for (text, construct) in cases {
7915 for line_length in [12, 20, 30] {
7916 for atomic_spans in [true, false] {
7917 let options = ReflowOptions {
7918 line_length,
7919 atomic_spans,
7920 ..Default::default()
7921 };
7922 let wrapped = reflow_line(text, &options).join("\n");
7923 assert!(
7924 wrapped.contains(construct),
7925 "{construct} was broken at line_length={line_length} \
7926 atomic_spans={atomic_spans}: {wrapped:?}"
7927 );
7928 }
7929 }
7930 }
7931 }
7932
7933 #[test]
7934 fn test_overlong_emphasis_with_nested_code_span_wraps() {
7935 let options = ReflowOptions {
7939 line_length: 80,
7940 atomic_spans: true,
7941 ..Default::default()
7942 };
7943 let text = "_This is a very, very, very, very, very, very, very long line that exceeds 80 characters with some `code` inside._";
7944 let lines = reflow_line(text, &options);
7945 assert_eq!(
7946 lines,
7947 vec![
7948 "_This is a very, very, very, very, very, very, very long line that exceeds 80",
7949 "characters with some `code` inside._",
7950 ]
7951 );
7952 }
7953
7954 #[test]
7955 fn test_overlong_emphasis_with_nested_strong_wraps() {
7956 let options = ReflowOptions {
7958 line_length: 80,
7959 atomic_spans: true,
7960 ..Default::default()
7961 };
7962 let text = "_This is a very, very, very, very, very, very, very long line that exceeds 80 characters with some **bold** inside._";
7963 let lines = reflow_line(text, &options);
7964 assert_eq!(
7965 lines,
7966 vec![
7967 "_This is a very, very, very, very, very, very, very long line that exceeds 80",
7968 "characters with some **bold** inside._",
7969 ]
7970 );
7971 }
7972
7973 #[test]
7974 fn test_overlong_doubly_nested_span_wraps() {
7975 let options = ReflowOptions {
7980 line_length: 80,
7981 atomic_spans: true,
7982 ..Default::default()
7983 };
7984 let body = "This is a very, very, very, very, very, very, very, very, very, very long line that is emphasised.";
7985 for (open, close) in [
7986 ("***", "***"),
7987 ("___", "___"),
7988 ("**_", "_**"),
7989 ("*__", "__*"),
7990 ("**~~", "~~**"),
7991 ] {
7992 let text = format!("{open}{body}{close}");
7993 assert!(text.len() > options.line_length, "case must start over budget");
7994 let lines = reflow_line(&text, &options);
7995 assert!(
7996 lines.len() > 1,
7997 "{open}...{close} should wrap but stayed on one line: {lines:?}"
7998 );
7999 assert!(
8000 lines.iter().all(|line| line.len() <= options.line_length),
8001 "{open}...{close} left a line over the budget: {lines:?}"
8002 );
8003 assert_eq!(
8004 lines.join(" "),
8005 text,
8006 "{open}...{close} wrapping must only replace a space with a newline"
8007 );
8008 }
8009 }
8010
8011 #[test]
8012 fn test_overlong_span_with_stray_marker_stays_whole() {
8013 let options = ReflowOptions {
8017 line_length: 40,
8018 atomic_spans: true,
8019 ..Default::default()
8020 };
8021 let text = "**alpha * beta gamma delta epsilon zeta eta theta iota kappa**";
8022 let lines = reflow_line(text, &options);
8023 assert_eq!(lines, vec![text], "stray marker must keep the span whole");
8024 }
8025
8026 #[test]
8027 fn test_overlong_span_never_breaks_inside_a_nested_reference_link() {
8028 let options = ReflowOptions {
8034 line_length: 30,
8035 atomic_spans: true,
8036 defined_references: Some(HashSet::from([
8037 "ref".to_string(),
8038 "one two three four five six seven".to_string(),
8040 ])),
8041 ..Default::default()
8042 };
8043 for (text, link) in [
8044 (
8045 "_**alpha [one two three four five six seven][ref] beta gamma delta**_",
8046 "[one two three four five six seven][ref]",
8047 ),
8048 (
8049 "**alpha [one two three four five six seven][ref] beta gamma delta**",
8050 "[one two three four five six seven][ref]",
8051 ),
8052 (
8053 "_**alpha ![one two three four five six seven][ref] beta gamma delta**_",
8054 "![one two three four five six seven][ref]",
8055 ),
8056 (
8057 "_**alpha [one two three four five six seven][] beta gamma delta**_",
8058 "[one two three four five six seven][]",
8059 ),
8060 (
8061 "_**alpha [one two three four five six seven] beta gamma delta**_",
8062 "[one two three four five six seven]",
8063 ),
8064 ] {
8065 let lines = reflow_line(text, &options);
8066 assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
8067 assert!(
8068 lines.iter().any(|line| line.contains(link)),
8069 "{link} must stay on one line: {lines:?}"
8070 );
8071 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
8072 }
8073 }
8074
8075 #[test]
8076 fn test_overlong_span_breaks_inside_an_undefined_shortcut_reference() {
8077 let options = ReflowOptions {
8081 line_length: 30,
8082 atomic_spans: true,
8083 defined_references: Some(HashSet::new()),
8084 ..Default::default()
8085 };
8086 let text = "_**alpha [one two three four five six seven] beta gamma delta**_";
8087 let lines = reflow_line(text, &options);
8088 assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
8089 assert!(
8090 !lines
8091 .iter()
8092 .any(|line| line.contains("[one two three four five six seven]")),
8093 "an undefined shortcut is prose and should break: {lines:?}"
8094 );
8095 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
8096 }
8097
8098 #[test]
8099 fn test_overlong_span_never_breaks_inside_a_nested_attr_list() {
8100 let attr = "{.highlight key=\"a b c\"}";
8104 let text = format!("_**alpha beta gamma delta epsilon zeta{attr} eta theta iota kappa**_");
8105 let options = ReflowOptions {
8106 line_length: 20,
8107 atomic_spans: true,
8108 attr_lists: true,
8109 ..Default::default()
8110 };
8111 let lines = reflow_line(&text, &options);
8112 assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
8113 assert!(
8114 lines.iter().any(|line| line.contains(attr)),
8115 "attr list must stay on one line: {lines:?}"
8116 );
8117 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
8118
8119 let plain = ReflowOptions {
8122 attr_lists: false,
8123 ..options
8124 };
8125 let lines = reflow_line(&text, &plain);
8126 assert!(
8127 !lines.iter().any(|line| line.contains(attr)),
8128 "without the flavor the braces are prose and should break: {lines:?}"
8129 );
8130 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
8131 }
8132
8133 #[test]
8134 fn test_overlong_emphasis_never_breaks_inside_nested_code_span() {
8135 let options = ReflowOptions {
8139 line_length: 30,
8140 atomic_spans: true,
8141 ..Default::default()
8142 };
8143 let text = "_alpha beta gamma delta epsilon `a b` zeta eta theta iota kappa_";
8144 let lines = reflow_line(text, &options);
8145 assert!(lines.len() > 1, "over-long emphasis should wrap: {lines:?}");
8146 assert!(
8147 lines.iter().any(|line| line.contains("`a b`")),
8148 "nested code span must stay whole with its interior spaces: {lines:?}"
8149 );
8150 for line in &lines {
8151 assert_eq!(
8152 line.matches('`').count() % 2,
8153 0,
8154 "no line may contain half a code span: {line:?}"
8155 );
8156 }
8157 }
8158
8159 #[test]
8160 fn test_definition_list_marker_does_not_start_line() {
8161 let options = ReflowOptions {
8162 line_length: 20,
8163 ..Default::default()
8164 };
8165 let lines = reflow_line("This is a term and : definition here.", &options);
8167 for line in &lines {
8168 assert!(
8169 !line.trim_start().starts_with(": "),
8170 "Wrapped line should not start with definition marker: {line}"
8171 );
8172 }
8173 }
8174
8175 #[test]
8176 fn test_div_marker_does_not_start_line() {
8177 let options = ReflowOptions {
8178 line_length: 20,
8179 ..Default::default()
8180 };
8181 let lines = reflow_line("This is some text with ::: class marker.", &options);
8183 for line in &lines {
8184 assert!(
8185 !line.trim_start().starts_with(":::"),
8186 "Wrapped line should not start with div marker: {line}"
8187 );
8188 }
8189 }
8190
8191 fn reflow_markdown_preserving_rendering(content: &str, options: &ReflowOptions) -> String {
8204 let render = |text: &str| {
8205 let mut parser_options = Options::empty();
8206 parser_options.insert(Options::ENABLE_STRIKETHROUGH);
8207 parser_options.insert(Options::ENABLE_DEFINITION_LIST);
8208 let mut html = String::new();
8209 pulldown_cmark::html::push_html(&mut html, Parser::new_ext(text, parser_options));
8210 html.retain(|c| !c.is_ascii_whitespace());
8211 html
8212 };
8213 let reflowed = reflow_markdown(content, options);
8214 assert_eq!(
8215 render(content),
8216 render(&reflowed),
8217 "rendering changed for input: {content:?}"
8218 );
8219 reflowed
8220 }
8221
8222 #[test]
8230 fn a_paragraph_part_never_ends_inside_an_atomic_construct() {
8231 let options = ReflowOptions {
8232 line_length: 0,
8233 sentence_per_line: true,
8234 ..Default::default()
8235 };
8236
8237 let cases = [
8238 ("[(完成。)\n继续。](url)", "[(完成。) 继续。](url)"),
8240 ("`(完成。)\n继续。`", "`(完成。) 继续。`"),
8242 ("[Done.\nNext](url)", "[Done. Next](url)"),
8245 ("(完成。)\n继续。", "(完成。)\n继续。"),
8247 ("Done. Next", "Done.\nNext"),
8248 ];
8249
8250 for (input, expected) in cases {
8251 assert_eq!(
8252 reflow_markdown_preserving_rendering(input, &options),
8253 expected,
8254 "input: {input:?}"
8255 );
8256 }
8257 }
8258
8259 #[test]
8262 fn a_bare_colon_line_ends_the_paragraph_above_it() {
8263 let options = ReflowOptions {
8264 line_length: 0,
8265 sentence_per_line: true,
8266 ..Default::default()
8267 };
8268
8269 for input in ["文章です。\n:", "Done!\n:", "完成。\n:", "Term\n:\nNext term\n: text"] {
8270 assert_eq!(
8271 reflow_markdown_preserving_rendering(input, &options),
8272 input,
8273 "input: {input:?}"
8274 );
8275 }
8276 }
8277
8278 #[test]
8283 fn a_colon_leading_the_first_line_of_a_paragraph_is_prose() {
8284 let options = ReflowOptions {
8285 line_length: 0,
8286 sentence_per_line: true,
8287 ..Default::default()
8288 };
8289
8290 for (input, expected) in [
8291 (
8292 ":warning: First sentence. Second sentence.",
8293 ":warning: First sentence.\nSecond sentence.",
8294 ),
8295 (
8296 "Term\n\n:warning: First sentence. Second sentence.",
8297 "Term\n\n:warning: First sentence.\nSecond sentence.",
8298 ),
8299 (
8300 "# Heading\n:warning: First sentence. Second sentence.",
8301 "# Heading\n:warning: First sentence.\nSecond sentence.",
8302 ),
8303 ] {
8304 assert_eq!(
8305 reflow_markdown_preserving_rendering(input, &options),
8306 expected,
8307 "input: {input:?}"
8308 );
8309 }
8310 for input in [
8311 "Term\n:warning: First sentence. Second sentence.",
8312 ":warning: First sentence.\n:note: Second sentence.",
8313 "- term\n :warning: First sentence. Second sentence.",
8314 ] {
8315 assert_eq!(
8316 reflow_markdown_preserving_rendering(input, &options),
8317 input,
8318 "input: {input:?}"
8319 );
8320 }
8321 }
8322}
8323
8324#[cfg(test)]
8325mod shortcode_delimiter_tests {
8326 use super::*;
8327
8328 #[test]
8329 fn reflow_preserves_shortcode_arguments_containing_closing_markers() {
8330 let options = ReflowOptions {
8331 line_length: 30,
8332 ..Default::default()
8333 };
8334 for (open, close) in [("{{<", ">}}"), ("{{%", "%}}")] {
8335 for quote in ["`", "\""] {
8336 let tag =
8337 format!("{open} note title={quote}literal {close} friend with some words and more{quote} {close}");
8338 let line = format!("Before {tag} after with more prose words.");
8339 let fixed = reflow_line(&line, &options).join("\n");
8340 assert!(fixed.contains(&tag), "{line:?} -> {fixed:?}");
8341 assert!(fixed.contains('\n'), "surrounding prose should still reflow");
8342 }
8343 }
8344 }
8345}