1use crate::utils::element_cache::ElementCache;
7use crate::utils::is_definition_list_item;
8use crate::utils::regex_cache::{
9 DISPLAY_MATH_REGEX, EMOJI_SHORTCODE_REGEX, FOOTNOTE_REF_REGEX, HTML_ENTITY_REGEX, HTML_TAG_PATTERN,
10 INLINE_IMAGE_FANCY_REGEX, INLINE_LINK_FANCY_REGEX, INLINE_MATH_REGEX, LINKED_IMAGE_INLINE_INLINE,
11 LINKED_IMAGE_INLINE_REF, LINKED_IMAGE_REF_INLINE, LINKED_IMAGE_REF_REF, REF_IMAGE_REGEX, REF_LINK_REGEX,
12 SHORTCUT_REF_REGEX, STRIKETHROUGH_FANCY_REGEX, WIKI_LINK_REGEX,
13};
14use std::collections::HashSet;
15
16#[derive(Clone)]
18pub struct ReflowOptions {
19 pub line_length: usize,
21 pub break_on_sentences: bool,
23 pub preserve_breaks: bool,
25 pub sentence_per_line: bool,
27 pub abbreviations: Option<Vec<String>>,
31}
32
33impl Default for ReflowOptions {
34 fn default() -> Self {
35 Self {
36 line_length: 80,
37 break_on_sentences: true,
38 preserve_breaks: false,
39 sentence_per_line: false,
40 abbreviations: None,
41 }
42 }
43}
44
45fn get_abbreviations(custom: &Option<Vec<String>>) -> HashSet<String> {
49 let mut abbreviations: HashSet<String> = [
57 "Mr", "Mrs", "Ms", "Dr", "Prof", "Sr", "Jr",
59 "i.e", "e.g",
61 ]
62 .iter()
63 .map(|s| s.to_lowercase())
64 .collect();
65
66 if let Some(custom_list) = custom {
69 for abbr in custom_list {
70 let normalized = abbr.trim_end_matches('.').to_lowercase();
71 if !normalized.is_empty() {
72 abbreviations.insert(normalized);
73 }
74 }
75 }
76
77 abbreviations
78}
79
80fn text_ends_with_abbreviation(text: &str, abbreviations: &HashSet<String>) -> bool {
95 if !text.ends_with('.') {
97 return false;
98 }
99
100 let without_period = text.trim_end_matches('.');
102
103 let last_word = without_period.split_whitespace().last().unwrap_or("");
105
106 if last_word.is_empty() {
107 return false;
108 }
109
110 abbreviations.contains(&last_word.to_lowercase())
112}
113
114fn is_sentence_boundary(text: &str, pos: usize, abbreviations: &HashSet<String>) -> bool {
117 let chars: Vec<char> = text.chars().collect();
118
119 if pos + 1 >= chars.len() {
120 return false;
121 }
122
123 let c = chars[pos];
125 if c != '.' && c != '!' && c != '?' {
126 return false;
127 }
128
129 if chars[pos + 1] != ' ' {
131 return false;
132 }
133
134 let mut next_char_pos = pos + 2;
136 while next_char_pos < chars.len() && chars[next_char_pos].is_whitespace() {
137 next_char_pos += 1;
138 }
139
140 if next_char_pos >= chars.len() {
142 return false;
143 }
144
145 if !chars[next_char_pos].is_uppercase() {
147 return false;
148 }
149
150 if pos > 0 && c == '.' {
152 if text_ends_with_abbreviation(&text[..=pos], abbreviations) {
155 return false;
156 }
157
158 if chars[pos - 1].is_numeric() && next_char_pos < chars.len() && chars[next_char_pos].is_numeric() {
161 return false;
162 }
163 }
164 true
165}
166
167pub fn split_into_sentences(text: &str) -> Vec<String> {
169 split_into_sentences_custom(text, &None)
170}
171
172pub fn split_into_sentences_custom(text: &str, custom_abbreviations: &Option<Vec<String>>) -> Vec<String> {
174 let abbreviations = get_abbreviations(custom_abbreviations);
175 split_into_sentences_with_set(text, &abbreviations)
176}
177
178fn split_into_sentences_with_set(text: &str, abbreviations: &HashSet<String>) -> Vec<String> {
181 let mut sentences = Vec::new();
182 let mut current_sentence = String::new();
183 let mut chars = text.chars().peekable();
184 let mut pos = 0;
185
186 while let Some(c) = chars.next() {
187 current_sentence.push(c);
188
189 if is_sentence_boundary(text, pos, abbreviations) {
190 if chars.peek() == Some(&' ') {
192 chars.next();
193 pos += 1;
194 }
195 sentences.push(current_sentence.trim().to_string());
196 current_sentence.clear();
197 }
198
199 pos += 1;
200 }
201
202 if !current_sentence.trim().is_empty() {
204 sentences.push(current_sentence.trim().to_string());
205 }
206 sentences
207}
208
209fn is_horizontal_rule(line: &str) -> bool {
211 if line.len() < 3 {
212 return false;
213 }
214
215 let chars: Vec<char> = line.chars().collect();
217 if chars.is_empty() {
218 return false;
219 }
220
221 let first_char = chars[0];
222 if first_char != '-' && first_char != '_' && first_char != '*' {
223 return false;
224 }
225
226 for c in &chars {
228 if *c != first_char && *c != ' ' {
229 return false;
230 }
231 }
232
233 let non_space_count = chars.iter().filter(|c| **c != ' ').count();
235 non_space_count >= 3
236}
237
238fn is_numbered_list_item(line: &str) -> bool {
240 let mut chars = line.chars();
241
242 if !chars.next().is_some_and(|c| c.is_numeric()) {
244 return false;
245 }
246
247 while let Some(c) = chars.next() {
249 if c == '.' {
250 return chars.next().is_none_or(|c| c == ' ');
252 }
253 if !c.is_numeric() {
254 return false;
255 }
256 }
257
258 false
259}
260
261fn has_hard_break(line: &str) -> bool {
267 let line = line.strip_suffix('\r').unwrap_or(line);
268 line.ends_with(" ") || line.ends_with('\\')
269}
270
271fn trim_preserving_hard_break(s: &str) -> String {
277 let s = s.strip_suffix('\r').unwrap_or(s);
279
280 if s.ends_with('\\') {
282 return s.to_string();
284 }
285
286 if s.ends_with(" ") {
288 let content_end = s.trim_end().len();
290 if content_end == 0 {
291 return String::new();
293 }
294 format!("{} ", &s[..content_end])
296 } else {
297 s.trim_end().to_string()
299 }
300}
301
302pub fn reflow_line(line: &str, options: &ReflowOptions) -> Vec<String> {
303 if options.sentence_per_line {
305 let elements = parse_markdown_elements(line);
306 return reflow_elements_sentence_per_line(&elements, &options.abbreviations);
307 }
308
309 if line.chars().count() <= options.line_length {
311 return vec![line.to_string()];
312 }
313
314 let elements = parse_markdown_elements(line);
316
317 reflow_elements(&elements, options)
319}
320
321#[derive(Debug, Clone)]
323enum LinkedImageSource {
324 Inline(String),
326 Reference(String),
328}
329
330#[derive(Debug, Clone)]
332enum LinkedImageTarget {
333 Inline(String),
335 Reference(String),
337}
338
339#[derive(Debug, Clone)]
341enum Element {
342 Text(String),
344 Link { text: String, url: String },
346 ReferenceLink { text: String, reference: String },
348 EmptyReferenceLink { text: String },
350 ShortcutReference { reference: String },
352 InlineImage { alt: String, url: String },
354 ReferenceImage { alt: String, reference: String },
356 EmptyReferenceImage { alt: String },
358 LinkedImage {
364 alt: String,
365 img_source: LinkedImageSource,
366 link_target: LinkedImageTarget,
367 },
368 FootnoteReference { note: String },
370 Strikethrough(String),
372 WikiLink(String),
374 InlineMath(String),
376 DisplayMath(String),
378 EmojiShortcode(String),
380 HtmlTag(String),
382 HtmlEntity(String),
384 Code(String),
386 Bold(String),
388 Italic(String),
390}
391
392impl std::fmt::Display for Element {
393 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
394 match self {
395 Element::Text(s) => write!(f, "{s}"),
396 Element::Link { text, url } => write!(f, "[{text}]({url})"),
397 Element::ReferenceLink { text, reference } => write!(f, "[{text}][{reference}]"),
398 Element::EmptyReferenceLink { text } => write!(f, "[{text}][]"),
399 Element::ShortcutReference { reference } => write!(f, "[{reference}]"),
400 Element::InlineImage { alt, url } => write!(f, ""),
401 Element::ReferenceImage { alt, reference } => write!(f, "![{alt}][{reference}]"),
402 Element::EmptyReferenceImage { alt } => write!(f, "![{alt}][]"),
403 Element::LinkedImage {
404 alt,
405 img_source,
406 link_target,
407 } => {
408 let img_part = match img_source {
410 LinkedImageSource::Inline(url) => format!(""),
411 LinkedImageSource::Reference(r) => format!("![{alt}][{r}]"),
412 };
413 match link_target {
415 LinkedImageTarget::Inline(url) => write!(f, "[{img_part}]({url})"),
416 LinkedImageTarget::Reference(r) => write!(f, "[{img_part}][{r}]"),
417 }
418 }
419 Element::FootnoteReference { note } => write!(f, "[^{note}]"),
420 Element::Strikethrough(s) => write!(f, "~~{s}~~"),
421 Element::WikiLink(s) => write!(f, "[[{s}]]"),
422 Element::InlineMath(s) => write!(f, "${s}$"),
423 Element::DisplayMath(s) => write!(f, "$${s}$$"),
424 Element::EmojiShortcode(s) => write!(f, ":{s}:"),
425 Element::HtmlTag(s) => write!(f, "{s}"),
426 Element::HtmlEntity(s) => write!(f, "{s}"),
427 Element::Code(s) => write!(f, "`{s}`"),
428 Element::Bold(s) => write!(f, "**{s}**"),
429 Element::Italic(s) => write!(f, "*{s}*"),
430 }
431 }
432}
433
434impl Element {
435 fn len(&self) -> usize {
436 match self {
437 Element::Text(s) => s.chars().count(),
438 Element::Link { text, url } => text.chars().count() + url.chars().count() + 4, Element::ReferenceLink { text, reference } => text.chars().count() + reference.chars().count() + 4, Element::EmptyReferenceLink { text } => text.chars().count() + 4, Element::ShortcutReference { reference } => reference.chars().count() + 2, Element::InlineImage { alt, url } => alt.chars().count() + url.chars().count() + 5, Element::ReferenceImage { alt, reference } => alt.chars().count() + reference.chars().count() + 5, Element::EmptyReferenceImage { alt } => alt.chars().count() + 5, Element::LinkedImage {
446 alt,
447 img_source,
448 link_target,
449 } => {
450 let alt_len = alt.chars().count();
453 let img_len = match img_source {
454 LinkedImageSource::Inline(url) => url.chars().count() + 2, LinkedImageSource::Reference(r) => r.chars().count() + 2, };
457 let link_len = match link_target {
458 LinkedImageTarget::Inline(url) => url.chars().count() + 2, LinkedImageTarget::Reference(r) => r.chars().count() + 2, };
461 5 + alt_len + img_len + link_len
464 }
465 Element::FootnoteReference { note } => note.chars().count() + 3, Element::Strikethrough(s) => s.chars().count() + 4, Element::WikiLink(s) => s.chars().count() + 4, Element::InlineMath(s) => s.chars().count() + 2, Element::DisplayMath(s) => s.chars().count() + 4, Element::EmojiShortcode(s) => s.chars().count() + 2, Element::HtmlTag(s) => s.chars().count(), Element::HtmlEntity(s) => s.chars().count(), Element::Code(s) => s.chars().count() + 2, Element::Bold(s) => s.chars().count() + 4, Element::Italic(s) => s.chars().count() + 2, }
477 }
478}
479
480fn parse_markdown_elements(text: &str) -> Vec<Element> {
491 let mut elements = Vec::new();
492 let mut remaining = text;
493
494 while !remaining.is_empty() {
495 let mut earliest_match: Option<(usize, &str, fancy_regex::Match)> = None;
497
498 if remaining.contains("[!") {
502 if let Ok(Some(m)) = LINKED_IMAGE_INLINE_INLINE.find(remaining)
504 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
505 {
506 earliest_match = Some((m.start(), "linked_image_ii", m));
507 }
508
509 if let Ok(Some(m)) = LINKED_IMAGE_REF_INLINE.find(remaining)
511 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
512 {
513 earliest_match = Some((m.start(), "linked_image_ri", m));
514 }
515
516 if let Ok(Some(m)) = LINKED_IMAGE_INLINE_REF.find(remaining)
518 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
519 {
520 earliest_match = Some((m.start(), "linked_image_ir", m));
521 }
522
523 if let Ok(Some(m)) = LINKED_IMAGE_REF_REF.find(remaining)
525 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
526 {
527 earliest_match = Some((m.start(), "linked_image_rr", m));
528 }
529 }
530
531 if let Ok(Some(m)) = INLINE_IMAGE_FANCY_REGEX.find(remaining)
534 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
535 {
536 earliest_match = Some((m.start(), "inline_image", m));
537 }
538
539 if let Ok(Some(m)) = REF_IMAGE_REGEX.find(remaining)
541 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
542 {
543 earliest_match = Some((m.start(), "ref_image", m));
544 }
545
546 if let Ok(Some(m)) = FOOTNOTE_REF_REGEX.find(remaining)
548 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
549 {
550 earliest_match = Some((m.start(), "footnote_ref", m));
551 }
552
553 if let Ok(Some(m)) = INLINE_LINK_FANCY_REGEX.find(remaining)
555 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
556 {
557 earliest_match = Some((m.start(), "inline_link", m));
558 }
559
560 if let Ok(Some(m)) = REF_LINK_REGEX.find(remaining)
562 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
563 {
564 earliest_match = Some((m.start(), "ref_link", m));
565 }
566
567 if let Ok(Some(m)) = SHORTCUT_REF_REGEX.find(remaining)
570 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
571 {
572 earliest_match = Some((m.start(), "shortcut_ref", m));
573 }
574
575 if let Ok(Some(m)) = WIKI_LINK_REGEX.find(remaining)
577 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
578 {
579 earliest_match = Some((m.start(), "wiki_link", m));
580 }
581
582 if let Ok(Some(m)) = DISPLAY_MATH_REGEX.find(remaining)
584 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
585 {
586 earliest_match = Some((m.start(), "display_math", m));
587 }
588
589 if let Ok(Some(m)) = INLINE_MATH_REGEX.find(remaining)
591 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
592 {
593 earliest_match = Some((m.start(), "inline_math", m));
594 }
595
596 if let Ok(Some(m)) = STRIKETHROUGH_FANCY_REGEX.find(remaining)
598 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
599 {
600 earliest_match = Some((m.start(), "strikethrough", m));
601 }
602
603 if let Ok(Some(m)) = EMOJI_SHORTCODE_REGEX.find(remaining)
605 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
606 {
607 earliest_match = Some((m.start(), "emoji", m));
608 }
609
610 if let Ok(Some(m)) = HTML_ENTITY_REGEX.find(remaining)
612 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
613 {
614 earliest_match = Some((m.start(), "html_entity", m));
615 }
616
617 if let Ok(Some(m)) = HTML_TAG_PATTERN.find(remaining)
620 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
621 {
622 let matched_text = &remaining[m.start()..m.end()];
624 let is_autolink = matched_text.starts_with("<http://")
625 || matched_text.starts_with("<https://")
626 || matched_text.starts_with("<mailto:")
627 || matched_text.starts_with("<ftp://")
628 || matched_text.starts_with("<ftps://");
629
630 if !is_autolink {
631 earliest_match = Some((m.start(), "html_tag", m));
632 }
633 }
634
635 let mut next_special = remaining.len();
637 let mut special_type = "";
638
639 if let Some(pos) = remaining.find('`')
640 && pos < next_special
641 {
642 next_special = pos;
643 special_type = "code";
644 }
645 if let Some(pos) = remaining.find("**")
646 && pos < next_special
647 {
648 next_special = pos;
649 special_type = "bold";
650 }
651 if let Some(pos) = remaining.find('*')
652 && pos < next_special
653 && !remaining[pos..].starts_with("**")
654 {
655 next_special = pos;
656 special_type = "italic";
657 }
658
659 let should_process_markdown_link = if let Some((pos, _, _)) = earliest_match {
661 pos < next_special
662 } else {
663 false
664 };
665
666 if should_process_markdown_link {
667 let (pos, pattern_type, match_obj) = earliest_match.unwrap();
668
669 if pos > 0 {
671 elements.push(Element::Text(remaining[..pos].to_string()));
672 }
673
674 match pattern_type {
676 "linked_image_ii" => {
678 if let Ok(Some(caps)) = LINKED_IMAGE_INLINE_INLINE.captures(remaining) {
679 let alt = caps.get(1).map(|m| m.as_str()).unwrap_or("");
680 let img_url = caps.get(2).map(|m| m.as_str()).unwrap_or("");
681 let link_url = caps.get(3).map(|m| m.as_str()).unwrap_or("");
682 elements.push(Element::LinkedImage {
683 alt: alt.to_string(),
684 img_source: LinkedImageSource::Inline(img_url.to_string()),
685 link_target: LinkedImageTarget::Inline(link_url.to_string()),
686 });
687 remaining = &remaining[match_obj.end()..];
688 } else {
689 elements.push(Element::Text("[".to_string()));
690 remaining = &remaining[1..];
691 }
692 }
693 "linked_image_ri" => {
695 if let Ok(Some(caps)) = LINKED_IMAGE_REF_INLINE.captures(remaining) {
696 let alt = caps.get(1).map(|m| m.as_str()).unwrap_or("");
697 let img_ref = caps.get(2).map(|m| m.as_str()).unwrap_or("");
698 let link_url = caps.get(3).map(|m| m.as_str()).unwrap_or("");
699 elements.push(Element::LinkedImage {
700 alt: alt.to_string(),
701 img_source: LinkedImageSource::Reference(img_ref.to_string()),
702 link_target: LinkedImageTarget::Inline(link_url.to_string()),
703 });
704 remaining = &remaining[match_obj.end()..];
705 } else {
706 elements.push(Element::Text("[".to_string()));
707 remaining = &remaining[1..];
708 }
709 }
710 "linked_image_ir" => {
712 if let Ok(Some(caps)) = LINKED_IMAGE_INLINE_REF.captures(remaining) {
713 let alt = caps.get(1).map(|m| m.as_str()).unwrap_or("");
714 let img_url = caps.get(2).map(|m| m.as_str()).unwrap_or("");
715 let link_ref = caps.get(3).map(|m| m.as_str()).unwrap_or("");
716 elements.push(Element::LinkedImage {
717 alt: alt.to_string(),
718 img_source: LinkedImageSource::Inline(img_url.to_string()),
719 link_target: LinkedImageTarget::Reference(link_ref.to_string()),
720 });
721 remaining = &remaining[match_obj.end()..];
722 } else {
723 elements.push(Element::Text("[".to_string()));
724 remaining = &remaining[1..];
725 }
726 }
727 "linked_image_rr" => {
729 if let Ok(Some(caps)) = LINKED_IMAGE_REF_REF.captures(remaining) {
730 let alt = caps.get(1).map(|m| m.as_str()).unwrap_or("");
731 let img_ref = caps.get(2).map(|m| m.as_str()).unwrap_or("");
732 let link_ref = caps.get(3).map(|m| m.as_str()).unwrap_or("");
733 elements.push(Element::LinkedImage {
734 alt: alt.to_string(),
735 img_source: LinkedImageSource::Reference(img_ref.to_string()),
736 link_target: LinkedImageTarget::Reference(link_ref.to_string()),
737 });
738 remaining = &remaining[match_obj.end()..];
739 } else {
740 elements.push(Element::Text("[".to_string()));
741 remaining = &remaining[1..];
742 }
743 }
744 "inline_image" => {
745 if let Ok(Some(caps)) = INLINE_IMAGE_FANCY_REGEX.captures(remaining) {
746 let alt = caps.get(1).map(|m| m.as_str()).unwrap_or("");
747 let url = caps.get(2).map(|m| m.as_str()).unwrap_or("");
748 elements.push(Element::InlineImage {
749 alt: alt.to_string(),
750 url: url.to_string(),
751 });
752 remaining = &remaining[match_obj.end()..];
753 } else {
754 elements.push(Element::Text("!".to_string()));
755 remaining = &remaining[1..];
756 }
757 }
758 "ref_image" => {
759 if let Ok(Some(caps)) = REF_IMAGE_REGEX.captures(remaining) {
760 let alt = caps.get(1).map(|m| m.as_str()).unwrap_or("");
761 let reference = caps.get(2).map(|m| m.as_str()).unwrap_or("");
762
763 if reference.is_empty() {
764 elements.push(Element::EmptyReferenceImage { alt: alt.to_string() });
765 } else {
766 elements.push(Element::ReferenceImage {
767 alt: alt.to_string(),
768 reference: reference.to_string(),
769 });
770 }
771 remaining = &remaining[match_obj.end()..];
772 } else {
773 elements.push(Element::Text("!".to_string()));
774 remaining = &remaining[1..];
775 }
776 }
777 "footnote_ref" => {
778 if let Ok(Some(caps)) = FOOTNOTE_REF_REGEX.captures(remaining) {
779 let note = caps.get(1).map(|m| m.as_str()).unwrap_or("");
780 elements.push(Element::FootnoteReference { note: note.to_string() });
781 remaining = &remaining[match_obj.end()..];
782 } else {
783 elements.push(Element::Text("[".to_string()));
784 remaining = &remaining[1..];
785 }
786 }
787 "inline_link" => {
788 if let Ok(Some(caps)) = INLINE_LINK_FANCY_REGEX.captures(remaining) {
789 let text = caps.get(1).map(|m| m.as_str()).unwrap_or("");
790 let url = caps.get(2).map(|m| m.as_str()).unwrap_or("");
791 elements.push(Element::Link {
792 text: text.to_string(),
793 url: url.to_string(),
794 });
795 remaining = &remaining[match_obj.end()..];
796 } else {
797 elements.push(Element::Text("[".to_string()));
799 remaining = &remaining[1..];
800 }
801 }
802 "ref_link" => {
803 if let Ok(Some(caps)) = REF_LINK_REGEX.captures(remaining) {
804 let text = caps.get(1).map(|m| m.as_str()).unwrap_or("");
805 let reference = caps.get(2).map(|m| m.as_str()).unwrap_or("");
806
807 if reference.is_empty() {
808 elements.push(Element::EmptyReferenceLink { text: text.to_string() });
810 } else {
811 elements.push(Element::ReferenceLink {
813 text: text.to_string(),
814 reference: reference.to_string(),
815 });
816 }
817 remaining = &remaining[match_obj.end()..];
818 } else {
819 elements.push(Element::Text("[".to_string()));
821 remaining = &remaining[1..];
822 }
823 }
824 "shortcut_ref" => {
825 if let Ok(Some(caps)) = SHORTCUT_REF_REGEX.captures(remaining) {
826 let reference = caps.get(1).map(|m| m.as_str()).unwrap_or("");
827 elements.push(Element::ShortcutReference {
828 reference: reference.to_string(),
829 });
830 remaining = &remaining[match_obj.end()..];
831 } else {
832 elements.push(Element::Text("[".to_string()));
834 remaining = &remaining[1..];
835 }
836 }
837 "wiki_link" => {
838 if let Ok(Some(caps)) = WIKI_LINK_REGEX.captures(remaining) {
839 let content = caps.get(1).map(|m| m.as_str()).unwrap_or("");
840 elements.push(Element::WikiLink(content.to_string()));
841 remaining = &remaining[match_obj.end()..];
842 } else {
843 elements.push(Element::Text("[[".to_string()));
844 remaining = &remaining[2..];
845 }
846 }
847 "display_math" => {
848 if let Ok(Some(caps)) = DISPLAY_MATH_REGEX.captures(remaining) {
849 let math = caps.get(1).map(|m| m.as_str()).unwrap_or("");
850 elements.push(Element::DisplayMath(math.to_string()));
851 remaining = &remaining[match_obj.end()..];
852 } else {
853 elements.push(Element::Text("$$".to_string()));
854 remaining = &remaining[2..];
855 }
856 }
857 "inline_math" => {
858 if let Ok(Some(caps)) = INLINE_MATH_REGEX.captures(remaining) {
859 let math = caps.get(1).map(|m| m.as_str()).unwrap_or("");
860 elements.push(Element::InlineMath(math.to_string()));
861 remaining = &remaining[match_obj.end()..];
862 } else {
863 elements.push(Element::Text("$".to_string()));
864 remaining = &remaining[1..];
865 }
866 }
867 "strikethrough" => {
868 if let Ok(Some(caps)) = STRIKETHROUGH_FANCY_REGEX.captures(remaining) {
869 let text = caps.get(1).map(|m| m.as_str()).unwrap_or("");
870 elements.push(Element::Strikethrough(text.to_string()));
871 remaining = &remaining[match_obj.end()..];
872 } else {
873 elements.push(Element::Text("~~".to_string()));
874 remaining = &remaining[2..];
875 }
876 }
877 "emoji" => {
878 if let Ok(Some(caps)) = EMOJI_SHORTCODE_REGEX.captures(remaining) {
879 let emoji = caps.get(1).map(|m| m.as_str()).unwrap_or("");
880 elements.push(Element::EmojiShortcode(emoji.to_string()));
881 remaining = &remaining[match_obj.end()..];
882 } else {
883 elements.push(Element::Text(":".to_string()));
884 remaining = &remaining[1..];
885 }
886 }
887 "html_entity" => {
888 elements.push(Element::HtmlEntity(remaining[..match_obj.end()].to_string()));
890 remaining = &remaining[match_obj.end()..];
891 }
892 "html_tag" => {
893 elements.push(Element::HtmlTag(remaining[..match_obj.end()].to_string()));
895 remaining = &remaining[match_obj.end()..];
896 }
897 _ => {
898 elements.push(Element::Text("[".to_string()));
900 remaining = &remaining[1..];
901 }
902 }
903 } else {
904 if next_special > 0 && next_special < remaining.len() {
908 elements.push(Element::Text(remaining[..next_special].to_string()));
909 remaining = &remaining[next_special..];
910 }
911
912 match special_type {
914 "code" => {
915 if let Some(code_end) = remaining[1..].find('`') {
917 let code = &remaining[1..1 + code_end];
918 elements.push(Element::Code(code.to_string()));
919 remaining = &remaining[1 + code_end + 1..];
920 } else {
921 elements.push(Element::Text(remaining.to_string()));
923 break;
924 }
925 }
926 "bold" => {
927 if let Some(bold_end) = remaining[2..].find("**") {
929 let bold_text = &remaining[2..2 + bold_end];
930 elements.push(Element::Bold(bold_text.to_string()));
931 remaining = &remaining[2 + bold_end + 2..];
932 } else {
933 elements.push(Element::Text("**".to_string()));
935 remaining = &remaining[2..];
936 }
937 }
938 "italic" => {
939 if let Some(italic_end) = remaining[1..].find('*') {
941 let italic_text = &remaining[1..1 + italic_end];
942 elements.push(Element::Italic(italic_text.to_string()));
943 remaining = &remaining[1 + italic_end + 1..];
944 } else {
945 elements.push(Element::Text("*".to_string()));
947 remaining = &remaining[1..];
948 }
949 }
950 _ => {
951 elements.push(Element::Text(remaining.to_string()));
953 break;
954 }
955 }
956 }
957 }
958
959 elements
960}
961
962fn reflow_elements_sentence_per_line(elements: &[Element], custom_abbreviations: &Option<Vec<String>>) -> Vec<String> {
964 let abbreviations = get_abbreviations(custom_abbreviations);
965 let mut lines = Vec::new();
966 let mut current_line = String::new();
967
968 for element in elements.iter() {
969 let element_str = format!("{element}");
970
971 if let Element::Text(text) = element {
973 let combined = format!("{current_line}{text}");
975 let sentences = split_into_sentences_with_set(&combined, &abbreviations);
977
978 if sentences.len() > 1 {
979 for (i, sentence) in sentences.iter().enumerate() {
981 if i == 0 {
982 let trimmed = sentence.trim();
985
986 if text_ends_with_abbreviation(trimmed, &abbreviations) {
987 current_line = sentence.to_string();
989 } else {
990 lines.push(sentence.to_string());
992 current_line.clear();
993 }
994 } else if i == sentences.len() - 1 {
995 let trimmed = sentence.trim();
997 let ends_with_sentence_punct =
998 trimmed.ends_with('.') || trimmed.ends_with('!') || trimmed.ends_with('?');
999
1000 if ends_with_sentence_punct && !text_ends_with_abbreviation(trimmed, &abbreviations) {
1001 lines.push(sentence.to_string());
1003 current_line.clear();
1004 } else {
1005 current_line = sentence.to_string();
1007 }
1008 } else {
1009 lines.push(sentence.to_string());
1011 }
1012 }
1013 } else {
1014 current_line = combined;
1016 }
1017 } else {
1018 if !current_line.is_empty()
1021 && !current_line.ends_with(' ')
1022 && !current_line.ends_with('(')
1023 && !current_line.ends_with('[')
1024 {
1025 current_line.push(' ');
1026 }
1027 current_line.push_str(&element_str);
1028 }
1029 }
1030
1031 if !current_line.is_empty() {
1033 lines.push(current_line.trim().to_string());
1034 }
1035 lines
1036}
1037
1038fn reflow_elements(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
1040 let mut lines = Vec::new();
1041 let mut current_line = String::new();
1042 let mut current_length = 0;
1043
1044 for element in elements {
1045 let element_str = format!("{element}");
1046 let element_len = element.len();
1047
1048 if let Element::Text(text) = element {
1050 let has_leading_space = text.starts_with(char::is_whitespace);
1052 let words: Vec<&str> = text.split_whitespace().collect();
1054
1055 for (i, word) in words.iter().enumerate() {
1056 let word_len = word.chars().count();
1057 let is_trailing_punct = word
1059 .chars()
1060 .all(|c| matches!(c, ',' | '.' | ':' | ';' | '!' | '?' | ')' | ']' | '}'));
1061
1062 if current_length > 0 && current_length + 1 + word_len > options.line_length && !is_trailing_punct {
1063 lines.push(current_line.trim().to_string());
1065 current_line = word.to_string();
1066 current_length = word_len;
1067 } else {
1068 if current_length > 0 && (i > 0 || has_leading_space) && !is_trailing_punct {
1072 current_line.push(' ');
1073 current_length += 1;
1074 }
1075 current_line.push_str(word);
1076 current_length += word_len;
1077 }
1078 }
1079 } else {
1080 if current_length > 0 && current_length + 1 + element_len > options.line_length {
1083 lines.push(current_line.trim().to_string());
1085 current_line = element_str;
1086 current_length = element_len;
1087 } else {
1088 let ends_with_opener =
1091 current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
1092 if current_length > 0 && !ends_with_opener {
1093 current_line.push(' ');
1094 current_length += 1;
1095 }
1096 current_line.push_str(&element_str);
1097 current_length += element_len;
1098 }
1099 }
1100 }
1101
1102 if !current_line.is_empty() {
1104 lines.push(current_line.trim_end().to_string());
1105 }
1106
1107 lines
1108}
1109
1110pub fn reflow_markdown(content: &str, options: &ReflowOptions) -> String {
1112 let lines: Vec<&str> = content.lines().collect();
1113 let mut result = Vec::new();
1114 let mut i = 0;
1115
1116 while i < lines.len() {
1117 let line = lines[i];
1118 let trimmed = line.trim();
1119
1120 if trimmed.is_empty() {
1122 result.push(String::new());
1123 i += 1;
1124 continue;
1125 }
1126
1127 if trimmed.starts_with('#') {
1129 result.push(line.to_string());
1130 i += 1;
1131 continue;
1132 }
1133
1134 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
1136 result.push(line.to_string());
1137 i += 1;
1138 while i < lines.len() {
1140 result.push(lines[i].to_string());
1141 if lines[i].trim().starts_with("```") || lines[i].trim().starts_with("~~~") {
1142 i += 1;
1143 break;
1144 }
1145 i += 1;
1146 }
1147 continue;
1148 }
1149
1150 if ElementCache::calculate_indentation_width_default(line) >= 4 {
1152 result.push(line.to_string());
1154 i += 1;
1155 while i < lines.len() {
1156 let next_line = lines[i];
1157 if ElementCache::calculate_indentation_width_default(next_line) >= 4 || next_line.trim().is_empty() {
1159 result.push(next_line.to_string());
1160 i += 1;
1161 } else {
1162 break;
1163 }
1164 }
1165 continue;
1166 }
1167
1168 if trimmed.starts_with('>') {
1170 let quote_prefix = line[0..line.find('>').unwrap() + 1].to_string();
1171 let quote_content = &line[quote_prefix.len()..].trim_start();
1172
1173 let reflowed = reflow_line(quote_content, options);
1174 for reflowed_line in reflowed.iter() {
1175 result.push(format!("{quote_prefix} {reflowed_line}"));
1176 }
1177 i += 1;
1178 continue;
1179 }
1180
1181 if is_horizontal_rule(trimmed) {
1183 result.push(line.to_string());
1184 i += 1;
1185 continue;
1186 }
1187
1188 if (trimmed.starts_with('-') && !is_horizontal_rule(trimmed))
1190 || (trimmed.starts_with('*') && !is_horizontal_rule(trimmed))
1191 || trimmed.starts_with('+')
1192 || is_numbered_list_item(trimmed)
1193 {
1194 let indent = line.len() - line.trim_start().len();
1196 let indent_str = " ".repeat(indent);
1197
1198 let mut marker_end = indent;
1201 let mut content_start = indent;
1202
1203 if trimmed.chars().next().is_some_and(|c| c.is_numeric()) {
1204 if let Some(period_pos) = line[indent..].find('.') {
1206 marker_end = indent + period_pos + 1; content_start = marker_end;
1208 while content_start < line.len() && line.chars().nth(content_start) == Some(' ') {
1210 content_start += 1;
1211 }
1212 }
1213 } else {
1214 marker_end = indent + 1; content_start = marker_end;
1217 while content_start < line.len() && line.chars().nth(content_start) == Some(' ') {
1219 content_start += 1;
1220 }
1221 }
1222
1223 let marker = &line[indent..marker_end];
1224
1225 let mut list_content = vec![trim_preserving_hard_break(&line[content_start..])];
1228 i += 1;
1229
1230 while i < lines.len() {
1232 let next_line = lines[i];
1233 let next_trimmed = next_line.trim();
1234
1235 if next_trimmed.is_empty()
1237 || next_trimmed.starts_with('#')
1238 || next_trimmed.starts_with("```")
1239 || next_trimmed.starts_with("~~~")
1240 || next_trimmed.starts_with('>')
1241 || next_trimmed.starts_with('|')
1242 || (next_trimmed.starts_with('[') && next_line.contains("]:"))
1243 || is_horizontal_rule(next_trimmed)
1244 || (next_trimmed.starts_with('-')
1245 && (next_trimmed.len() == 1 || next_trimmed.chars().nth(1) == Some(' ')))
1246 || (next_trimmed.starts_with('*')
1247 && (next_trimmed.len() == 1 || next_trimmed.chars().nth(1) == Some(' ')))
1248 || (next_trimmed.starts_with('+')
1249 && (next_trimmed.len() == 1 || next_trimmed.chars().nth(1) == Some(' ')))
1250 || is_numbered_list_item(next_trimmed)
1251 || is_definition_list_item(next_trimmed)
1252 {
1253 break;
1254 }
1255
1256 let next_indent = next_line.len() - next_line.trim_start().len();
1258 if next_indent >= content_start {
1259 let trimmed_start = next_line.trim_start();
1262 list_content.push(trim_preserving_hard_break(trimmed_start));
1263 i += 1;
1264 } else {
1265 break;
1267 }
1268 }
1269
1270 let combined_content = if options.preserve_breaks {
1273 list_content[0].clone()
1274 } else {
1275 let has_hard_breaks = list_content.iter().any(|line| has_hard_break(line));
1277 if has_hard_breaks {
1278 list_content.join("\n")
1280 } else {
1281 list_content.join(" ")
1283 }
1284 };
1285
1286 let trimmed_marker = marker;
1288 let continuation_spaces = content_start;
1289
1290 let prefix_length = indent + trimmed_marker.len() + 1;
1292
1293 let adjusted_options = ReflowOptions {
1295 line_length: options.line_length.saturating_sub(prefix_length),
1296 ..options.clone()
1297 };
1298
1299 let reflowed = reflow_line(&combined_content, &adjusted_options);
1300 for (j, reflowed_line) in reflowed.iter().enumerate() {
1301 if j == 0 {
1302 result.push(format!("{indent_str}{trimmed_marker} {reflowed_line}"));
1303 } else {
1304 let continuation_indent = " ".repeat(continuation_spaces);
1306 result.push(format!("{continuation_indent}{reflowed_line}"));
1307 }
1308 }
1309 continue;
1310 }
1311
1312 if crate::utils::table_utils::TableUtils::is_potential_table_row(line) {
1314 result.push(line.to_string());
1315 i += 1;
1316 continue;
1317 }
1318
1319 if trimmed.starts_with('[') && line.contains("]:") {
1321 result.push(line.to_string());
1322 i += 1;
1323 continue;
1324 }
1325
1326 if is_definition_list_item(trimmed) {
1328 result.push(line.to_string());
1329 i += 1;
1330 continue;
1331 }
1332
1333 let mut is_single_line_paragraph = true;
1335 if i + 1 < lines.len() {
1336 let next_line = lines[i + 1];
1337 let next_trimmed = next_line.trim();
1338 if !next_trimmed.is_empty()
1340 && !next_trimmed.starts_with('#')
1341 && !next_trimmed.starts_with("```")
1342 && !next_trimmed.starts_with("~~~")
1343 && !next_trimmed.starts_with('>')
1344 && !next_trimmed.starts_with('|')
1345 && !(next_trimmed.starts_with('[') && next_line.contains("]:"))
1346 && !is_horizontal_rule(next_trimmed)
1347 && !(next_trimmed.starts_with('-')
1348 && !is_horizontal_rule(next_trimmed)
1349 && (next_trimmed.len() == 1 || next_trimmed.chars().nth(1) == Some(' ')))
1350 && !(next_trimmed.starts_with('*')
1351 && !is_horizontal_rule(next_trimmed)
1352 && (next_trimmed.len() == 1 || next_trimmed.chars().nth(1) == Some(' ')))
1353 && !(next_trimmed.starts_with('+')
1354 && (next_trimmed.len() == 1 || next_trimmed.chars().nth(1) == Some(' ')))
1355 && !is_numbered_list_item(next_trimmed)
1356 {
1357 is_single_line_paragraph = false;
1358 }
1359 }
1360
1361 if is_single_line_paragraph && line.chars().count() <= options.line_length {
1363 result.push(line.to_string());
1364 i += 1;
1365 continue;
1366 }
1367
1368 let mut paragraph_parts = Vec::new();
1370 let mut current_part = vec![line];
1371 i += 1;
1372
1373 if options.preserve_breaks {
1375 let hard_break_type = if line.strip_suffix('\r').unwrap_or(line).ends_with('\\') {
1377 Some("\\")
1378 } else if line.ends_with(" ") {
1379 Some(" ")
1380 } else {
1381 None
1382 };
1383 let reflowed = reflow_line(line, options);
1384
1385 if let Some(break_marker) = hard_break_type {
1387 if !reflowed.is_empty() {
1388 let mut reflowed_with_break = reflowed;
1389 let last_idx = reflowed_with_break.len() - 1;
1390 if !has_hard_break(&reflowed_with_break[last_idx]) {
1391 reflowed_with_break[last_idx].push_str(break_marker);
1392 }
1393 result.extend(reflowed_with_break);
1394 }
1395 } else {
1396 result.extend(reflowed);
1397 }
1398 } else {
1399 while i < lines.len() {
1401 let prev_line = if !current_part.is_empty() {
1402 current_part.last().unwrap()
1403 } else {
1404 ""
1405 };
1406 let next_line = lines[i];
1407 let next_trimmed = next_line.trim();
1408
1409 if next_trimmed.is_empty()
1411 || next_trimmed.starts_with('#')
1412 || next_trimmed.starts_with("```")
1413 || next_trimmed.starts_with("~~~")
1414 || next_trimmed.starts_with('>')
1415 || next_trimmed.starts_with('|')
1416 || (next_trimmed.starts_with('[') && next_line.contains("]:"))
1417 || is_horizontal_rule(next_trimmed)
1418 || (next_trimmed.starts_with('-')
1419 && !is_horizontal_rule(next_trimmed)
1420 && (next_trimmed.len() == 1 || next_trimmed.chars().nth(1) == Some(' ')))
1421 || (next_trimmed.starts_with('*')
1422 && !is_horizontal_rule(next_trimmed)
1423 && (next_trimmed.len() == 1 || next_trimmed.chars().nth(1) == Some(' ')))
1424 || (next_trimmed.starts_with('+')
1425 && (next_trimmed.len() == 1 || next_trimmed.chars().nth(1) == Some(' ')))
1426 || is_numbered_list_item(next_trimmed)
1427 || is_definition_list_item(next_trimmed)
1428 {
1429 break;
1430 }
1431
1432 if has_hard_break(prev_line) {
1434 paragraph_parts.push(current_part.join(" "));
1436 current_part = vec![next_line];
1437 } else {
1438 current_part.push(next_line);
1439 }
1440 i += 1;
1441 }
1442
1443 if !current_part.is_empty() {
1445 if current_part.len() == 1 {
1446 paragraph_parts.push(current_part[0].to_string());
1448 } else {
1449 paragraph_parts.push(current_part.join(" "));
1450 }
1451 }
1452
1453 for (j, part) in paragraph_parts.iter().enumerate() {
1455 let reflowed = reflow_line(part, options);
1456 result.extend(reflowed);
1457
1458 if j < paragraph_parts.len() - 1 && !result.is_empty() {
1461 let last_idx = result.len() - 1;
1462 if !has_hard_break(&result[last_idx]) {
1463 result[last_idx].push_str(" ");
1464 }
1465 }
1466 }
1467 }
1468 }
1469
1470 let result_text = result.join("\n");
1472 if content.ends_with('\n') && !result_text.ends_with('\n') {
1473 format!("{result_text}\n")
1474 } else {
1475 result_text
1476 }
1477}
1478
1479#[derive(Debug, Clone)]
1481pub struct ParagraphReflow {
1482 pub start_byte: usize,
1484 pub end_byte: usize,
1486 pub reflowed_text: String,
1488}
1489
1490pub fn reflow_paragraph_at_line(content: &str, line_number: usize, line_length: usize) -> Option<ParagraphReflow> {
1508 if line_number == 0 {
1509 return None;
1510 }
1511
1512 let lines: Vec<&str> = content.lines().collect();
1513
1514 if line_number > lines.len() {
1516 return None;
1517 }
1518
1519 let target_idx = line_number - 1; let target_line = lines[target_idx];
1521 let trimmed = target_line.trim();
1522
1523 if trimmed.is_empty()
1525 || trimmed.starts_with('#')
1526 || trimmed.starts_with("```")
1527 || trimmed.starts_with("~~~")
1528 || ElementCache::calculate_indentation_width_default(target_line) >= 4
1529 || trimmed.starts_with('>')
1530 || crate::utils::table_utils::TableUtils::is_potential_table_row(target_line) || (trimmed.starts_with('[') && target_line.contains("]:")) || is_horizontal_rule(trimmed)
1533 || ((trimmed.starts_with('-') || trimmed.starts_with('*') || trimmed.starts_with('+'))
1534 && !is_horizontal_rule(trimmed)
1535 && (trimmed.len() == 1 || trimmed.chars().nth(1) == Some(' ')))
1536 || is_numbered_list_item(trimmed)
1537 || is_definition_list_item(trimmed)
1538 {
1539 return None;
1540 }
1541
1542 let mut para_start = target_idx;
1544 while para_start > 0 {
1545 let prev_idx = para_start - 1;
1546 let prev_line = lines[prev_idx];
1547 let prev_trimmed = prev_line.trim();
1548
1549 if prev_trimmed.is_empty()
1551 || prev_trimmed.starts_with('#')
1552 || prev_trimmed.starts_with("```")
1553 || prev_trimmed.starts_with("~~~")
1554 || ElementCache::calculate_indentation_width_default(prev_line) >= 4
1555 || prev_trimmed.starts_with('>')
1556 || crate::utils::table_utils::TableUtils::is_potential_table_row(prev_line)
1557 || (prev_trimmed.starts_with('[') && prev_line.contains("]:"))
1558 || is_horizontal_rule(prev_trimmed)
1559 || ((prev_trimmed.starts_with('-') || prev_trimmed.starts_with('*') || prev_trimmed.starts_with('+'))
1560 && !is_horizontal_rule(prev_trimmed)
1561 && (prev_trimmed.len() == 1 || prev_trimmed.chars().nth(1) == Some(' ')))
1562 || is_numbered_list_item(prev_trimmed)
1563 || is_definition_list_item(prev_trimmed)
1564 {
1565 break;
1566 }
1567
1568 para_start = prev_idx;
1569 }
1570
1571 let mut para_end = target_idx;
1573 while para_end + 1 < lines.len() {
1574 let next_idx = para_end + 1;
1575 let next_line = lines[next_idx];
1576 let next_trimmed = next_line.trim();
1577
1578 if next_trimmed.is_empty()
1580 || next_trimmed.starts_with('#')
1581 || next_trimmed.starts_with("```")
1582 || next_trimmed.starts_with("~~~")
1583 || ElementCache::calculate_indentation_width_default(next_line) >= 4
1584 || next_trimmed.starts_with('>')
1585 || crate::utils::table_utils::TableUtils::is_potential_table_row(next_line)
1586 || (next_trimmed.starts_with('[') && next_line.contains("]:"))
1587 || is_horizontal_rule(next_trimmed)
1588 || ((next_trimmed.starts_with('-') || next_trimmed.starts_with('*') || next_trimmed.starts_with('+'))
1589 && !is_horizontal_rule(next_trimmed)
1590 && (next_trimmed.len() == 1 || next_trimmed.chars().nth(1) == Some(' ')))
1591 || is_numbered_list_item(next_trimmed)
1592 || is_definition_list_item(next_trimmed)
1593 {
1594 break;
1595 }
1596
1597 para_end = next_idx;
1598 }
1599
1600 let paragraph_lines = &lines[para_start..=para_end];
1602
1603 let mut start_byte = 0;
1605 for line in lines.iter().take(para_start) {
1606 start_byte += line.len() + 1; }
1608
1609 let mut end_byte = start_byte;
1610 for line in paragraph_lines.iter() {
1611 end_byte += line.len() + 1; }
1613
1614 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
1617
1618 if !includes_trailing_newline {
1620 end_byte -= 1;
1621 }
1622
1623 let paragraph_text = paragraph_lines.join("\n");
1625
1626 let options = ReflowOptions {
1628 line_length,
1629 break_on_sentences: true,
1630 preserve_breaks: false,
1631 sentence_per_line: false,
1632 abbreviations: None,
1633 };
1634
1635 let reflowed = reflow_markdown(¶graph_text, &options);
1637
1638 let reflowed_text = if includes_trailing_newline {
1642 if reflowed.ends_with('\n') {
1644 reflowed
1645 } else {
1646 format!("{reflowed}\n")
1647 }
1648 } else {
1649 if reflowed.ends_with('\n') {
1651 reflowed.trim_end_matches('\n').to_string()
1652 } else {
1653 reflowed
1654 }
1655 };
1656
1657 Some(ParagraphReflow {
1658 start_byte,
1659 end_byte,
1660 reflowed_text,
1661 })
1662}
1663
1664#[cfg(test)]
1665mod tests {
1666 use super::*;
1667
1668 #[test]
1673 fn test_helper_function_text_ends_with_abbreviation() {
1674 let abbreviations = get_abbreviations(&None);
1676
1677 assert!(text_ends_with_abbreviation("Dr.", &abbreviations));
1679 assert!(text_ends_with_abbreviation("word Dr.", &abbreviations));
1680 assert!(text_ends_with_abbreviation("e.g.", &abbreviations));
1681 assert!(text_ends_with_abbreviation("i.e.", &abbreviations));
1682 assert!(text_ends_with_abbreviation("Mr.", &abbreviations));
1683 assert!(text_ends_with_abbreviation("Mrs.", &abbreviations));
1684 assert!(text_ends_with_abbreviation("Ms.", &abbreviations));
1685 assert!(text_ends_with_abbreviation("Prof.", &abbreviations));
1686
1687 assert!(!text_ends_with_abbreviation("etc.", &abbreviations));
1689 assert!(!text_ends_with_abbreviation("paradigms.", &abbreviations));
1690 assert!(!text_ends_with_abbreviation("programs.", &abbreviations));
1691 assert!(!text_ends_with_abbreviation("items.", &abbreviations));
1692 assert!(!text_ends_with_abbreviation("systems.", &abbreviations));
1693 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)); }
1699}