1use crate::utils::regex_cache::{
7 DISPLAY_MATH_REGEX, EMOJI_SHORTCODE_REGEX, FOOTNOTE_REF_REGEX, HTML_ENTITY_REGEX, HTML_TAG_PATTERN,
8 INLINE_IMAGE_FANCY_REGEX, INLINE_LINK_FANCY_REGEX, INLINE_MATH_REGEX, REF_IMAGE_REGEX, REF_LINK_REGEX,
9 SHORTCUT_REF_REGEX, STRIKETHROUGH_FANCY_REGEX, WIKI_LINK_REGEX,
10};
11#[derive(Clone)]
13pub struct ReflowOptions {
14 pub line_length: usize,
16 pub break_on_sentences: bool,
18 pub preserve_breaks: bool,
20}
21
22impl Default for ReflowOptions {
23 fn default() -> Self {
24 Self {
25 line_length: 80,
26 break_on_sentences: true,
27 preserve_breaks: false,
28 }
29 }
30}
31
32pub fn reflow_line(line: &str, options: &ReflowOptions) -> Vec<String> {
34 if line.chars().count() <= options.line_length {
36 return vec![line.to_string()];
37 }
38
39 let elements = parse_markdown_elements(line);
41
42 reflow_elements(&elements, options)
44}
45
46#[derive(Debug, Clone)]
48enum Element {
49 Text(String),
51 Link { text: String, url: String },
53 ReferenceLink { text: String, reference: String },
55 EmptyReferenceLink { text: String },
57 ShortcutReference { reference: String },
59 InlineImage { alt: String, url: String },
61 ReferenceImage { alt: String, reference: String },
63 EmptyReferenceImage { alt: String },
65 FootnoteReference { note: String },
67 Strikethrough(String),
69 WikiLink(String),
71 InlineMath(String),
73 DisplayMath(String),
75 EmojiShortcode(String),
77 HtmlTag(String),
79 HtmlEntity(String),
81 Code(String),
83 Bold(String),
85 Italic(String),
87}
88
89impl std::fmt::Display for Element {
90 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91 match self {
92 Element::Text(s) => write!(f, "{s}"),
93 Element::Link { text, url } => write!(f, "[{text}]({url})"),
94 Element::ReferenceLink { text, reference } => write!(f, "[{text}][{reference}]"),
95 Element::EmptyReferenceLink { text } => write!(f, "[{text}][]"),
96 Element::ShortcutReference { reference } => write!(f, "[{reference}]"),
97 Element::InlineImage { alt, url } => write!(f, ""),
98 Element::ReferenceImage { alt, reference } => write!(f, "![{alt}][{reference}]"),
99 Element::EmptyReferenceImage { alt } => write!(f, "![{alt}][]"),
100 Element::FootnoteReference { note } => write!(f, "[^{note}]"),
101 Element::Strikethrough(s) => write!(f, "~~{s}~~"),
102 Element::WikiLink(s) => write!(f, "[[{s}]]"),
103 Element::InlineMath(s) => write!(f, "${s}$"),
104 Element::DisplayMath(s) => write!(f, "$${s}$$"),
105 Element::EmojiShortcode(s) => write!(f, ":{s}:"),
106 Element::HtmlTag(s) => write!(f, "{s}"),
107 Element::HtmlEntity(s) => write!(f, "{s}"),
108 Element::Code(s) => write!(f, "`{s}`"),
109 Element::Bold(s) => write!(f, "**{s}**"),
110 Element::Italic(s) => write!(f, "*{s}*"),
111 }
112 }
113}
114
115impl Element {
116 fn len(&self) -> usize {
117 match self {
118 Element::Text(s) => s.chars().count(),
119 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::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, }
138 }
139}
140
141fn parse_markdown_elements(text: &str) -> Vec<Element> {
150 let mut elements = Vec::new();
151 let mut remaining = text;
152
153 while !remaining.is_empty() {
154 let mut earliest_match: Option<(usize, &str, fancy_regex::Match)> = None;
156
157 if let Ok(Some(m)) = INLINE_IMAGE_FANCY_REGEX.find(remaining)
160 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
161 {
162 earliest_match = Some((m.start(), "inline_image", m));
163 }
164
165 if let Ok(Some(m)) = REF_IMAGE_REGEX.find(remaining)
167 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
168 {
169 earliest_match = Some((m.start(), "ref_image", m));
170 }
171
172 if let Ok(Some(m)) = FOOTNOTE_REF_REGEX.find(remaining)
174 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
175 {
176 earliest_match = Some((m.start(), "footnote_ref", m));
177 }
178
179 if let Ok(Some(m)) = INLINE_LINK_FANCY_REGEX.find(remaining)
181 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
182 {
183 earliest_match = Some((m.start(), "inline_link", m));
184 }
185
186 if let Ok(Some(m)) = REF_LINK_REGEX.find(remaining)
188 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
189 {
190 earliest_match = Some((m.start(), "ref_link", m));
191 }
192
193 if let Ok(Some(m)) = SHORTCUT_REF_REGEX.find(remaining)
196 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
197 {
198 earliest_match = Some((m.start(), "shortcut_ref", m));
199 }
200
201 if let Ok(Some(m)) = WIKI_LINK_REGEX.find(remaining)
203 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
204 {
205 earliest_match = Some((m.start(), "wiki_link", m));
206 }
207
208 if let Ok(Some(m)) = DISPLAY_MATH_REGEX.find(remaining)
210 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
211 {
212 earliest_match = Some((m.start(), "display_math", m));
213 }
214
215 if let Ok(Some(m)) = INLINE_MATH_REGEX.find(remaining)
217 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
218 {
219 earliest_match = Some((m.start(), "inline_math", m));
220 }
221
222 if let Ok(Some(m)) = STRIKETHROUGH_FANCY_REGEX.find(remaining)
224 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
225 {
226 earliest_match = Some((m.start(), "strikethrough", m));
227 }
228
229 if let Ok(Some(m)) = EMOJI_SHORTCODE_REGEX.find(remaining)
231 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
232 {
233 earliest_match = Some((m.start(), "emoji", m));
234 }
235
236 if let Ok(Some(m)) = HTML_ENTITY_REGEX.find(remaining)
238 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
239 {
240 earliest_match = Some((m.start(), "html_entity", m));
241 }
242
243 if let Ok(Some(m)) = HTML_TAG_PATTERN.find(remaining)
245 && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
246 {
247 earliest_match = Some((m.start(), "html_tag", m));
248 }
249
250 let mut next_special = remaining.len();
252 let mut special_type = "";
253
254 if let Some(pos) = remaining.find('`')
255 && pos < next_special
256 {
257 next_special = pos;
258 special_type = "code";
259 }
260 if let Some(pos) = remaining.find("**")
261 && pos < next_special
262 {
263 next_special = pos;
264 special_type = "bold";
265 }
266 if let Some(pos) = remaining.find('*')
267 && pos < next_special
268 && !remaining[pos..].starts_with("**")
269 {
270 next_special = pos;
271 special_type = "italic";
272 }
273
274 let should_process_markdown_link = if let Some((pos, _, _)) = earliest_match {
276 pos < next_special
277 } else {
278 false
279 };
280
281 if should_process_markdown_link {
282 let (pos, pattern_type, match_obj) = earliest_match.unwrap();
283
284 if pos > 0 {
286 elements.push(Element::Text(remaining[..pos].to_string()));
287 }
288
289 match pattern_type {
291 "inline_image" => {
292 if let Ok(Some(caps)) = INLINE_IMAGE_FANCY_REGEX.captures(remaining) {
293 let alt = caps.get(1).map(|m| m.as_str()).unwrap_or("");
294 let url = caps.get(2).map(|m| m.as_str()).unwrap_or("");
295 elements.push(Element::InlineImage {
296 alt: alt.to_string(),
297 url: url.to_string(),
298 });
299 remaining = &remaining[match_obj.end()..];
300 } else {
301 elements.push(Element::Text("!".to_string()));
302 remaining = &remaining[1..];
303 }
304 }
305 "ref_image" => {
306 if let Ok(Some(caps)) = REF_IMAGE_REGEX.captures(remaining) {
307 let alt = caps.get(1).map(|m| m.as_str()).unwrap_or("");
308 let reference = caps.get(2).map(|m| m.as_str()).unwrap_or("");
309
310 if reference.is_empty() {
311 elements.push(Element::EmptyReferenceImage { alt: alt.to_string() });
312 } else {
313 elements.push(Element::ReferenceImage {
314 alt: alt.to_string(),
315 reference: reference.to_string(),
316 });
317 }
318 remaining = &remaining[match_obj.end()..];
319 } else {
320 elements.push(Element::Text("!".to_string()));
321 remaining = &remaining[1..];
322 }
323 }
324 "footnote_ref" => {
325 if let Ok(Some(caps)) = FOOTNOTE_REF_REGEX.captures(remaining) {
326 let note = caps.get(1).map(|m| m.as_str()).unwrap_or("");
327 elements.push(Element::FootnoteReference { note: note.to_string() });
328 remaining = &remaining[match_obj.end()..];
329 } else {
330 elements.push(Element::Text("[".to_string()));
331 remaining = &remaining[1..];
332 }
333 }
334 "inline_link" => {
335 if let Ok(Some(caps)) = INLINE_LINK_FANCY_REGEX.captures(remaining) {
336 let text = caps.get(1).map(|m| m.as_str()).unwrap_or("");
337 let url = caps.get(2).map(|m| m.as_str()).unwrap_or("");
338 elements.push(Element::Link {
339 text: text.to_string(),
340 url: url.to_string(),
341 });
342 remaining = &remaining[match_obj.end()..];
343 } else {
344 elements.push(Element::Text("[".to_string()));
346 remaining = &remaining[1..];
347 }
348 }
349 "ref_link" => {
350 if let Ok(Some(caps)) = REF_LINK_REGEX.captures(remaining) {
351 let text = caps.get(1).map(|m| m.as_str()).unwrap_or("");
352 let reference = caps.get(2).map(|m| m.as_str()).unwrap_or("");
353
354 if reference.is_empty() {
355 elements.push(Element::EmptyReferenceLink { text: text.to_string() });
357 } else {
358 elements.push(Element::ReferenceLink {
360 text: text.to_string(),
361 reference: reference.to_string(),
362 });
363 }
364 remaining = &remaining[match_obj.end()..];
365 } else {
366 elements.push(Element::Text("[".to_string()));
368 remaining = &remaining[1..];
369 }
370 }
371 "shortcut_ref" => {
372 if let Ok(Some(caps)) = SHORTCUT_REF_REGEX.captures(remaining) {
373 let reference = caps.get(1).map(|m| m.as_str()).unwrap_or("");
374 elements.push(Element::ShortcutReference {
375 reference: reference.to_string(),
376 });
377 remaining = &remaining[match_obj.end()..];
378 } else {
379 elements.push(Element::Text("[".to_string()));
381 remaining = &remaining[1..];
382 }
383 }
384 "wiki_link" => {
385 if let Ok(Some(caps)) = WIKI_LINK_REGEX.captures(remaining) {
386 let content = caps.get(1).map(|m| m.as_str()).unwrap_or("");
387 elements.push(Element::WikiLink(content.to_string()));
388 remaining = &remaining[match_obj.end()..];
389 } else {
390 elements.push(Element::Text("[[".to_string()));
391 remaining = &remaining[2..];
392 }
393 }
394 "display_math" => {
395 if let Ok(Some(caps)) = DISPLAY_MATH_REGEX.captures(remaining) {
396 let math = caps.get(1).map(|m| m.as_str()).unwrap_or("");
397 elements.push(Element::DisplayMath(math.to_string()));
398 remaining = &remaining[match_obj.end()..];
399 } else {
400 elements.push(Element::Text("$$".to_string()));
401 remaining = &remaining[2..];
402 }
403 }
404 "inline_math" => {
405 if let Ok(Some(caps)) = INLINE_MATH_REGEX.captures(remaining) {
406 let math = caps.get(1).map(|m| m.as_str()).unwrap_or("");
407 elements.push(Element::InlineMath(math.to_string()));
408 remaining = &remaining[match_obj.end()..];
409 } else {
410 elements.push(Element::Text("$".to_string()));
411 remaining = &remaining[1..];
412 }
413 }
414 "strikethrough" => {
415 if let Ok(Some(caps)) = STRIKETHROUGH_FANCY_REGEX.captures(remaining) {
416 let text = caps.get(1).map(|m| m.as_str()).unwrap_or("");
417 elements.push(Element::Strikethrough(text.to_string()));
418 remaining = &remaining[match_obj.end()..];
419 } else {
420 elements.push(Element::Text("~~".to_string()));
421 remaining = &remaining[2..];
422 }
423 }
424 "emoji" => {
425 if let Ok(Some(caps)) = EMOJI_SHORTCODE_REGEX.captures(remaining) {
426 let emoji = caps.get(1).map(|m| m.as_str()).unwrap_or("");
427 elements.push(Element::EmojiShortcode(emoji.to_string()));
428 remaining = &remaining[match_obj.end()..];
429 } else {
430 elements.push(Element::Text(":".to_string()));
431 remaining = &remaining[1..];
432 }
433 }
434 "html_entity" => {
435 elements.push(Element::HtmlEntity(remaining[..match_obj.end()].to_string()));
437 remaining = &remaining[match_obj.end()..];
438 }
439 "html_tag" => {
440 elements.push(Element::HtmlTag(remaining[..match_obj.end()].to_string()));
442 remaining = &remaining[match_obj.end()..];
443 }
444 _ => {
445 elements.push(Element::Text("[".to_string()));
447 remaining = &remaining[1..];
448 }
449 }
450 } else {
451 if next_special > 0 && next_special < remaining.len() {
455 elements.push(Element::Text(remaining[..next_special].to_string()));
456 remaining = &remaining[next_special..];
457 }
458
459 match special_type {
461 "code" => {
462 if let Some(code_end) = remaining[1..].find('`') {
464 let code = &remaining[1..1 + code_end];
465 elements.push(Element::Code(code.to_string()));
466 remaining = &remaining[1 + code_end + 1..];
467 } else {
468 elements.push(Element::Text(remaining.to_string()));
470 break;
471 }
472 }
473 "bold" => {
474 if let Some(bold_end) = remaining[2..].find("**") {
476 let bold_text = &remaining[2..2 + bold_end];
477 elements.push(Element::Bold(bold_text.to_string()));
478 remaining = &remaining[2 + bold_end + 2..];
479 } else {
480 elements.push(Element::Text("**".to_string()));
482 remaining = &remaining[2..];
483 }
484 }
485 "italic" => {
486 if let Some(italic_end) = remaining[1..].find('*') {
488 let italic_text = &remaining[1..1 + italic_end];
489 elements.push(Element::Italic(italic_text.to_string()));
490 remaining = &remaining[1 + italic_end + 1..];
491 } else {
492 elements.push(Element::Text("*".to_string()));
494 remaining = &remaining[1..];
495 }
496 }
497 _ => {
498 elements.push(Element::Text(remaining.to_string()));
500 break;
501 }
502 }
503 }
504 }
505
506 elements
507}
508
509fn reflow_elements(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
511 let mut lines = Vec::new();
512 let mut current_line = String::new();
513 let mut current_length = 0;
514
515 for element in elements {
516 let element_str = format!("{element}");
517 let element_len = element.len();
518
519 if let Element::Text(text) = element {
521 let words: Vec<&str> = text.split_whitespace().collect();
523
524 for word in words {
525 let word_len = word.chars().count();
526 if current_length > 0 && current_length + 1 + word_len > options.line_length {
527 lines.push(current_line.trim().to_string());
529 current_line = word.to_string();
530 current_length = word_len;
531 } else {
532 if current_length > 0 {
534 current_line.push(' ');
535 current_length += 1;
536 }
537 current_line.push_str(word);
538 current_length += word_len;
539 }
540 }
541 } else {
542 if current_length > 0 && current_length + 1 + element_len > options.line_length {
545 lines.push(current_line.trim().to_string());
547 current_line = element_str;
548 current_length = element_len;
549 } else {
550 if current_length > 0 {
552 current_line.push(' ');
553 current_length += 1;
554 }
555 current_line.push_str(&element_str);
556 current_length += element_len;
557 }
558 }
559 }
560
561 if !current_line.is_empty() {
563 lines.push(current_line.trim_end().to_string());
564 }
565
566 lines
567}
568
569pub fn reflow_markdown(content: &str, options: &ReflowOptions) -> String {
571 let lines: Vec<&str> = content.lines().collect();
572 let mut result = Vec::new();
573 let mut i = 0;
574
575 while i < lines.len() {
576 let line = lines[i];
577 let trimmed = line.trim();
578
579 if trimmed.is_empty() {
581 result.push(String::new());
582 i += 1;
583 continue;
584 }
585
586 if trimmed.starts_with('#') {
588 result.push(line.to_string());
589 i += 1;
590 continue;
591 }
592
593 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
595 result.push(line.to_string());
596 i += 1;
597 while i < lines.len() {
599 result.push(lines[i].to_string());
600 if lines[i].trim().starts_with("```") || lines[i].trim().starts_with("~~~") {
601 i += 1;
602 break;
603 }
604 i += 1;
605 }
606 continue;
607 }
608
609 if trimmed.starts_with('>') {
611 let quote_prefix = line[0..line.find('>').unwrap() + 1].to_string();
612 let quote_content = &line[quote_prefix.len()..].trim_start();
613
614 let reflowed = reflow_line(quote_content, options);
615 for reflowed_line in reflowed.iter() {
616 result.push(format!("{quote_prefix} {reflowed_line}"));
617 }
618 i += 1;
619 continue;
620 }
621
622 if trimmed.starts_with('-')
624 || trimmed.starts_with('*')
625 || trimmed.starts_with('+')
626 || trimmed.chars().next().is_some_and(|c| c.is_numeric())
627 {
628 let indent = line.len() - line.trim_start().len();
630 let indent_str = " ".repeat(indent);
631
632 let content_start = line
634 .find(|c: char| !(c.is_whitespace() || c == '-' || c == '*' || c == '+' || c == '.' || c.is_numeric()))
635 .unwrap_or(line.len());
636
637 let marker = &line[indent..content_start];
638 let content = &line[content_start..].trim_start();
639
640 let trimmed_marker = marker.trim_end();
643 let continuation_spaces = indent + trimmed_marker.len() + 1; let reflowed = reflow_line(content, options);
646 for (j, reflowed_line) in reflowed.iter().enumerate() {
647 if j == 0 {
648 result.push(format!("{indent_str}{trimmed_marker} {reflowed_line}"));
649 } else {
650 let continuation_indent = " ".repeat(continuation_spaces);
652 result.push(format!("{continuation_indent}{reflowed_line}"));
653 }
654 }
655 i += 1;
656 continue;
657 }
658
659 if trimmed.contains('|') {
661 result.push(line.to_string());
662 i += 1;
663 continue;
664 }
665
666 if trimmed.starts_with('[') && line.contains("]:") {
668 result.push(line.to_string());
669 i += 1;
670 continue;
671 }
672
673 let mut is_single_line_paragraph = true;
675 if i + 1 < lines.len() {
676 let next_line = lines[i + 1];
677 let next_trimmed = next_line.trim();
678 if !next_trimmed.is_empty()
680 && !next_trimmed.starts_with('#')
681 && !next_trimmed.starts_with("```")
682 && !next_trimmed.starts_with("~~~")
683 && !next_trimmed.starts_with('>')
684 && !next_trimmed.starts_with('|')
685 && !(next_trimmed.starts_with('[') && next_line.contains("]:"))
686 && !next_trimmed.starts_with('-')
687 && !next_trimmed.starts_with('*')
688 && !next_trimmed.starts_with('+')
689 && !next_trimmed.chars().next().is_some_and(|c| c.is_numeric())
690 {
691 is_single_line_paragraph = false;
692 }
693 }
694
695 if is_single_line_paragraph && line.chars().count() <= options.line_length {
697 result.push(line.to_string());
698 i += 1;
699 continue;
700 }
701
702 let mut paragraph_parts = Vec::new();
704 let mut current_part = vec![line];
705 i += 1;
706
707 while i < lines.len() {
708 let prev_line = if !current_part.is_empty() {
709 current_part.last().unwrap()
710 } else {
711 ""
712 };
713 let next_line = lines[i];
714 let next_trimmed = next_line.trim();
715
716 if next_trimmed.is_empty()
718 || next_trimmed.starts_with('#')
719 || next_trimmed.starts_with("```")
720 || next_trimmed.starts_with("~~~")
721 || next_trimmed.starts_with('>')
722 || next_trimmed.starts_with('|')
723 || (next_trimmed.starts_with('[') && next_line.contains("]:"))
724 || next_trimmed.starts_with('-')
725 || next_trimmed.starts_with('*')
726 || next_trimmed.starts_with('+')
727 || next_trimmed.chars().next().is_some_and(|c| c.is_numeric())
728 {
729 break;
730 }
731
732 if prev_line.ends_with(" ") {
734 paragraph_parts.push(current_part.join(" "));
736 current_part = vec![next_line];
737 } else {
738 current_part.push(next_line);
739 }
740 i += 1;
741 }
742
743 if !current_part.is_empty() {
745 if current_part.len() == 1 {
746 paragraph_parts.push(current_part[0].to_string());
748 } else {
749 paragraph_parts.push(current_part.join(" "));
750 }
751 }
752
753 for (j, part) in paragraph_parts.iter().enumerate() {
755 let reflowed = reflow_line(part, options);
756 result.extend(reflowed);
757
758 if j < paragraph_parts.len() - 1 && !result.is_empty() {
760 let last_idx = result.len() - 1;
761 if !result[last_idx].ends_with(" ") {
762 result[last_idx].push_str(" ");
763 }
764 }
765 }
766 }
767
768 result.join("\n")
769}
770
771#[cfg(test)]
772mod tests {
773 use super::*;
774
775 #[test]
776 fn test_reflow_simple_text() {
777 let options = ReflowOptions {
778 line_length: 20,
779 ..Default::default()
780 };
781
782 let input = "This is a very long line that needs to be wrapped";
783 let result = reflow_line(input, &options);
784
785 assert_eq!(result.len(), 3);
786 assert!(result[0].chars().count() <= 20);
787 assert!(result[1].chars().count() <= 20);
788 assert!(result[2].chars().count() <= 20);
789 }
790
791 #[test]
792 fn test_preserve_inline_code() {
793 let options = ReflowOptions {
794 line_length: 30,
795 ..Default::default()
796 };
797
798 let result = reflow_line("This line has `inline code` that should be preserved", &options);
799 let joined = result.join(" ");
801 assert!(joined.contains("`inline code`"));
802 }
803
804 #[test]
805 fn test_preserve_links() {
806 let options = ReflowOptions {
807 line_length: 40,
808 ..Default::default()
809 };
810
811 let text = "Check out [this link](https://example.com/very/long/url) for more info";
812 let result = reflow_line(text, &options);
813
814 let joined = result.join(" ");
816 assert!(joined.contains("[this link](https://example.com/very/long/url)"));
817 }
818
819 #[test]
820 fn test_reference_link_patterns_fixed() {
821 let options = ReflowOptions {
822 line_length: 30,
823 break_on_sentences: true,
824 preserve_breaks: false,
825 };
826
827 let test_cases = vec![
829 ("Check out [text][ref] for details", vec!["[text][ref]"]),
831 ("See [text][] for info", vec!["[text][]"]),
833 ("Visit [homepage] today", vec!["[homepage]"]),
835 (
837 "Links: [first][ref1] and [second][ref2] here",
838 vec!["[first][ref1]", "[second][ref2]"],
839 ),
840 (
842 "See [inline](url) and [reference][ref] links",
843 vec", "[reference][ref]"],
844 ),
845 ];
846
847 for (input, expected_patterns) in test_cases {
848 println!("\nTesting: {input}");
849 let result = reflow_line(input, &options);
850 let joined = result.join(" ");
851 println!("Result: {joined}");
852
853 for expected_pattern in expected_patterns {
855 assert!(
856 joined.contains(expected_pattern),
857 "Expected '{expected_pattern}' to be preserved in '{input}', but got '{joined}'"
858 );
859 }
860
861 assert!(
863 !joined.contains("[ ") || !joined.contains("] ["),
864 "Detected broken reference link pattern with spaces inside brackets in '{joined}'"
865 );
866 }
867 }
868
869 #[test]
870 fn test_reference_link_edge_cases() {
871 let options = ReflowOptions {
872 line_length: 40,
873 break_on_sentences: true,
874 preserve_breaks: false,
875 };
876
877 let test_cases = vec![
879 ("Text with \\[escaped\\] brackets", vec!["\\[escaped\\]"]),
881 (
883 "Link [text with [nested] content][ref]",
884 vec!["[text with [nested] content][ref]"],
885 ),
886 (
888 "First [ref][link] then [inline](url)",
889 vec!["[ref][link]", "[inline](url)"],
890 ),
891 ("Array [0] and reference [link] here", vec!["[0]", "[link]"]),
893 (
895 "Complex [text with *emphasis*][] reference",
896 vec!["[text with *emphasis*][]"],
897 ),
898 ];
899
900 for (input, expected_patterns) in test_cases {
901 println!("\nTesting edge case: {input}");
902 let result = reflow_line(input, &options);
903 let joined = result.join(" ");
904 println!("Result: {joined}");
905
906 for expected_pattern in expected_patterns {
908 assert!(
909 joined.contains(expected_pattern),
910 "Expected '{expected_pattern}' to be preserved in '{input}', but got '{joined}'"
911 );
912 }
913 }
914 }
915
916 #[test]
917 fn test_reflow_with_emphasis() {
918 let options = ReflowOptions {
919 line_length: 25,
920 ..Default::default()
921 };
922
923 let result = reflow_line("This is *emphasized* and **strong** text that needs wrapping", &options);
924
925 let joined = result.join(" ");
927 assert!(joined.contains("*emphasized*"));
928 assert!(joined.contains("**strong**"));
929 }
930
931 #[test]
932 fn test_image_patterns_preserved() {
933 let options = ReflowOptions {
934 line_length: 30,
935 ..Default::default()
936 };
937
938 let test_cases = vec for details",
943 vec"],
944 ),
945 ("See ![image][ref] for info", vec!["![image][ref]"]),
947 ("Visit ![homepage][] today", vec!["![homepage][]"]),
949 (
951 "Images:  and ![second][ref2]",
952 vec", "![second][ref2]"],
953 ),
954 ];
955
956 for (input, expected_patterns) in test_cases {
957 println!("\nTesting: {input}");
958 let result = reflow_line(input, &options);
959 let joined = result.join(" ");
960 println!("Result: {joined}");
961
962 for expected_pattern in expected_patterns {
963 assert!(
964 joined.contains(expected_pattern),
965 "Expected '{expected_pattern}' to be preserved in '{input}', but got '{joined}'"
966 );
967 }
968 }
969 }
970
971 #[test]
972 fn test_extended_markdown_patterns() {
973 let options = ReflowOptions {
974 line_length: 40,
975 ..Default::default()
976 };
977
978 let test_cases = vec![
979 ("Text with ~~strikethrough~~ preserved", vec!["~~strikethrough~~"]),
981 (
983 "Check [[wiki link]] and [[page|display]]",
984 vec!["[[wiki link]]", "[[page|display]]"],
985 ),
986 (
988 "Inline $x^2 + y^2$ and display $$\\int f(x) dx$$",
989 vec!["$x^2 + y^2$", "$$\\int f(x) dx$$"],
990 ),
991 ("Use :smile: and :heart: emojis", vec![":smile:", ":heart:"]),
993 (
995 "Text with <span>tag</span> and <br/>",
996 vec!["<span>", "</span>", "<br/>"],
997 ),
998 ("Non-breaking space and em—dash", vec![" ", "—"]),
1000 ];
1001
1002 for (input, expected_patterns) in test_cases {
1003 let result = reflow_line(input, &options);
1004 let joined = result.join(" ");
1005
1006 for pattern in expected_patterns {
1007 assert!(
1008 joined.contains(pattern),
1009 "Expected '{pattern}' to be preserved in '{input}', but got '{joined}'"
1010 );
1011 }
1012 }
1013 }
1014
1015 #[test]
1016 fn test_complex_mixed_patterns() {
1017 let options = ReflowOptions {
1018 line_length: 50,
1019 ..Default::default()
1020 };
1021
1022 let input = "Line with **bold**, `code`, [link](url), , ~~strike~~, $math$, :emoji:, and <tag> all together";
1024 let result = reflow_line(input, &options);
1025 let joined = result.join(" ");
1026
1027 assert!(joined.contains("**bold**"));
1029 assert!(joined.contains("`code`"));
1030 assert!(joined.contains("[link](url)"));
1031 assert!(joined.contains(""));
1032 assert!(joined.contains("~~strike~~"));
1033 assert!(joined.contains("$math$"));
1034 assert!(joined.contains(":emoji:"));
1035 assert!(joined.contains("<tag>"));
1036 }
1037
1038 #[test]
1039 fn test_footnote_patterns_preserved() {
1040 let options = ReflowOptions {
1041 line_length: 40,
1042 ..Default::default()
1043 };
1044
1045 let test_cases = vec![
1046 ("This has a footnote[^1] reference", vec!["[^1]"]),
1048 ("Text with [^first] and [^second] notes", vec!["[^first]", "[^second]"]),
1050 ("Reference to [^long-footnote-name] here", vec!["[^long-footnote-name]"]),
1052 ];
1053
1054 for (input, expected_patterns) in test_cases {
1055 let result = reflow_line(input, &options);
1056 let joined = result.join(" ");
1057
1058 for expected_pattern in expected_patterns {
1059 assert!(
1060 joined.contains(expected_pattern),
1061 "Expected '{expected_pattern}' to be preserved in '{input}', but got '{joined}'"
1062 );
1063 }
1064 }
1065 }
1066}