1use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
21use ratatui::{
22 style::{Color, Modifier, Style},
23 text::{Line, Span, Text},
24};
25
26use crate::tui::theme::{C_ACCENT, C_CODE_BG, C_DIM, C_MUTED, C_SUCCESS, C_WHITE};
33
34pub fn markdown_to_text(input: &str, width: u16) -> Text<'static> {
40 let mut renderer = Renderer::new(width);
41 renderer.render(input);
42 Text::from(renderer.lines)
43}
44
45#[derive(Default, Clone)]
49struct InlineStyle {
50 bold: bool,
51 italic: bool,
52 strikethrough: bool,
53 code: bool,
54 link: bool,
55}
56
57impl InlineStyle {
58 fn to_ratatui_style(&self) -> Style {
59 let mut style = Style::default().fg(C_WHITE);
60 if self.code {
61 style = style.fg(Color::Rgb(200, 160, 100)).bg(C_CODE_BG);
62 } else if self.link {
63 style = style.fg(C_ACCENT).add_modifier(Modifier::UNDERLINED);
64 }
65 if self.bold {
66 style = style.add_modifier(Modifier::BOLD);
67 }
68 if self.italic {
69 style = style.add_modifier(Modifier::ITALIC);
70 }
71 if self.strikethrough {
72 style = style.add_modifier(Modifier::CROSSED_OUT);
73 }
74 style
75 }
76}
77
78struct Renderer {
79 lines: Vec<Line<'static>>,
80 current_spans: Vec<Span<'static>>,
82 inline_stack: Vec<InlineStyle>,
84 inline: InlineStyle,
86 in_code_block: bool,
88 code_lang: Option<String>,
90 code_lines: Vec<String>,
92 list_stack: Vec<Option<u64>>,
94 width: u16,
96}
97
98impl Renderer {
99 fn new(width: u16) -> Self {
100 Self {
101 lines: Vec::new(),
102 current_spans: Vec::new(),
103 inline_stack: Vec::new(),
104 inline: InlineStyle::default(),
105 in_code_block: false,
106 code_lang: None,
107 code_lines: Vec::new(),
108 list_stack: Vec::new(),
109 width,
110 }
111 }
112
113 fn flush_line(&mut self) {
115 let spans = std::mem::take(&mut self.current_spans);
116 self.lines.push(Line::from(spans));
117 }
118
119 fn push_line(&mut self, line: Line<'static>) {
121 if !self.current_spans.is_empty() {
122 self.flush_line();
123 }
124 self.lines.push(line);
125 }
126
127 fn blank_line(&mut self) {
129 self.push_line(Line::from(""));
130 }
131
132 fn list_indent(&self) -> String {
134 " ".repeat(self.list_stack.len())
135 }
136
137 fn sync_inline(&mut self) {
139 self.inline = self.inline_stack.last().cloned().unwrap_or_default();
140 }
141
142 fn push_inline(&mut self, mut new_style: InlineStyle) {
143 if let Some(parent) = self.inline_stack.last() {
145 if parent.bold {
146 new_style.bold = true;
147 }
148 if parent.italic {
149 new_style.italic = true;
150 }
151 if parent.strikethrough {
152 new_style.strikethrough = true;
153 }
154 }
155 self.inline_stack.push(new_style);
156 self.sync_inline();
157 }
158
159 fn pop_inline(&mut self) {
160 self.inline_stack.pop();
161 self.sync_inline();
162 }
163
164 fn emit_text(&mut self, text: &str) {
166 let style = self.inline.to_ratatui_style();
167 self.current_spans
169 .push(Span::styled(text.to_owned(), style));
170 }
171
172 fn handle_text_content(&mut self, t: &str) {
178 if t.contains('\n') {
179 let mut first = true;
180 for part in t.split('\n') {
181 if !first {
182 self.flush_line();
183 }
184 first = false;
185 if !part.is_empty() {
186 self.emit_text(part);
187 }
188 }
189 } else {
190 self.emit_text(t);
191 }
192 }
193
194 fn flush_code_block(&mut self) {
196 let lang = self.code_lang.take().unwrap_or_default();
197 let content = std::mem::take(&mut self.code_lines);
198 let is_mermaid = lang.trim().to_lowercase() == "mermaid";
199
200 if is_mermaid {
201 self.push_line(Line::from(vec![
203 Span::styled(
204 " ◇ ",
205 Style::default().fg(C_ACCENT).add_modifier(Modifier::BOLD),
206 ),
207 Span::styled("mermaid diagram", Style::default().fg(C_ACCENT)),
208 Span::styled(" - source", Style::default().fg(C_DIM)),
209 ]));
210 for code_line in &content {
211 self.push_line(Line::from(vec![
212 Span::styled(" │ ", Style::default().fg(C_DIM)),
213 Span::styled(code_line.to_owned(), Style::default().fg(C_MUTED)),
214 ]));
215 }
216 self.push_line(Line::from(Span::styled(
217 " ↑ Install mermaid-cli (mmdc) to render as a diagram",
218 Style::default().fg(C_DIM),
219 )));
220 } else {
221 let lang_label = if lang.is_empty() {
223 "code".to_string()
224 } else {
225 lang.clone()
226 };
227 self.push_line(Line::from(vec![
228 Span::styled(" ╭─ ", Style::default().fg(C_DIM)),
229 Span::styled(
230 lang_label,
231 Style::default().fg(C_MUTED).add_modifier(Modifier::BOLD),
232 ),
233 Span::styled(" ─", Style::default().fg(C_DIM)),
234 ]));
235 for code_line in &content {
236 self.push_line(Line::from(vec![
237 Span::styled(" │ ", Style::default().fg(C_DIM)),
238 Span::styled(
239 code_line.to_owned(),
240 Style::default().fg(Color::Rgb(200, 200, 140)).bg(C_CODE_BG),
241 ),
242 ]));
243 }
244 self.push_line(Line::from(Span::styled(" ╰─", Style::default().fg(C_DIM))));
245 }
246 self.in_code_block = false;
247 }
248
249 pub fn render(&mut self, input: &str) {
250 let opts = Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES;
251 let parser = Parser::new_ext(input, opts);
252
253 for event in parser {
254 match event {
255 Event::Start(Tag::Heading { level, .. }) => {
257 if !self.current_spans.is_empty() {
259 self.flush_line();
260 }
261 if !self.lines.is_empty() {
263 self.blank_line();
264 }
265 let (color, prefix, bold) = match level {
267 HeadingLevel::H1 => (C_ACCENT, "▌ ", true),
268 HeadingLevel::H2 => (C_ACCENT, "▎ ", true),
269 HeadingLevel::H3 => (C_SUCCESS, " ", true),
270 HeadingLevel::H4 => (C_MUTED, " ", false),
271 HeadingLevel::H5 => (C_DIM, " ", false),
272 HeadingLevel::H6 => (C_DIM, " ", false),
273 };
274 let mut sty = Style::default().fg(color);
275 if bold {
276 sty = sty.add_modifier(Modifier::BOLD);
277 }
278 self.current_spans.push(Span::styled(prefix, sty));
279 self.push_inline(InlineStyle {
280 bold,
281 ..Default::default()
282 });
283 }
284 Event::End(TagEnd::Heading(level)) => {
285 self.pop_inline();
286 self.flush_line();
287 if level == HeadingLevel::H1 {
289 let rule_w = (self.width as usize).saturating_sub(2).max(8);
290 self.push_line(Line::from(Span::styled(
291 "─".repeat(rule_w),
292 Style::default().fg(C_DIM),
293 )));
294 }
295 self.blank_line();
296 }
297
298 Event::Start(Tag::Paragraph) => {}
299 Event::End(TagEnd::Paragraph) => {
300 self.flush_line();
301 self.blank_line();
302 }
303
304 Event::Start(Tag::BlockQuote(_)) => {
305 self.push_inline(InlineStyle {
306 ..Default::default()
307 });
308 self.current_spans
309 .push(Span::styled("│ ", Style::default().fg(C_DIM)));
310 }
311 Event::End(TagEnd::BlockQuote(_)) => {
312 self.pop_inline();
313 if !self.current_spans.is_empty() {
314 self.flush_line();
315 }
316 self.blank_line();
317 }
318
319 Event::Start(Tag::List(start)) => {
320 self.list_stack.push(start);
321 }
322 Event::End(TagEnd::List(_)) => {
323 self.list_stack.pop();
324 if self.list_stack.is_empty() {
325 self.blank_line();
326 }
327 }
328 Event::Start(Tag::Item) => {
329 if !self.current_spans.is_empty() {
330 self.flush_line();
331 }
332 let indent = self.list_indent();
333 let bullet = match self.list_stack.last() {
334 Some(Some(n)) => format!("{}. ", n),
335 Some(None) | None => "● ".to_string(),
336 };
337 if let Some(Some(n)) = self.list_stack.last_mut() {
339 *n += 1;
340 }
341 self.current_spans.push(Span::styled(
342 format!("{}{}", indent, bullet),
343 Style::default().fg(C_ACCENT),
344 ));
345 }
346 Event::End(TagEnd::Item) => {
347 if !self.current_spans.is_empty() {
348 self.flush_line();
349 }
350 }
351
352 Event::Start(Tag::CodeBlock(kind)) => {
353 self.in_code_block = true;
354 self.code_lang = match kind {
355 CodeBlockKind::Fenced(lang) => {
356 let s = lang.into_string();
357 if s.is_empty() { None } else { Some(s) }
358 }
359 CodeBlockKind::Indented => None,
360 };
361 self.code_lines = Vec::new();
362 if !self.current_spans.is_empty() {
363 self.flush_line();
364 }
365 }
366 Event::End(TagEnd::CodeBlock) => {
367 self.flush_code_block();
368 }
369
370 Event::Start(Tag::Strong) => {
371 self.push_inline(InlineStyle {
372 bold: true,
373 ..Default::default()
374 });
375 }
376 Event::End(TagEnd::Strong) => {
377 self.pop_inline();
378 }
379
380 Event::Start(Tag::Emphasis) => {
381 self.push_inline(InlineStyle {
382 italic: true,
383 ..Default::default()
384 });
385 }
386 Event::End(TagEnd::Emphasis) => {
387 self.pop_inline();
388 }
389
390 Event::Start(Tag::Strikethrough) => {
391 self.push_inline(InlineStyle {
392 strikethrough: true,
393 ..Default::default()
394 });
395 }
396 Event::End(TagEnd::Strikethrough) => {
397 self.pop_inline();
398 }
399
400 Event::Start(Tag::Link { dest_url, .. }) => {
401 self.push_inline(InlineStyle {
402 link: true,
403 ..Default::default()
404 });
405 let url = dest_url.into_string();
407 if !url.is_empty() {
408 self.current_spans
414 .push(Span::styled("[", Style::default().fg(C_DIM)));
415 let _ = url; }
421 }
422 Event::End(TagEnd::Link) => {
423 self.pop_inline();
424 self.current_spans
425 .push(Span::styled("]", Style::default().fg(C_DIM)));
426 }
427
428 Event::Text(text) => {
430 if self.in_code_block {
431 for line in text.lines() {
433 self.code_lines.push(line.to_string());
434 }
435 } else {
436 let t = text.into_string();
437 self.handle_text_content(&t);
438 }
439 }
440
441 Event::Code(text) => {
442 self.current_spans.push(Span::styled(
444 text.into_string(),
445 Style::default().fg(Color::Rgb(200, 160, 100)).bg(C_CODE_BG),
446 ));
447 }
448
449 Event::SoftBreak => {
450 self.current_spans.push(Span::raw(" "));
452 }
453 Event::HardBreak => {
454 self.flush_line();
455 }
456
457 Event::Rule => {
458 if !self.current_spans.is_empty() {
459 self.flush_line();
460 }
461 let w = (self.width as usize).saturating_sub(2).max(8);
462 self.push_line(Line::from(Span::styled(
463 "─".repeat(w),
464 Style::default().fg(C_DIM),
465 )));
466 self.blank_line();
467 }
468
469 _ => {}
471 }
472 }
473
474 if !self.current_spans.is_empty() {
476 self.flush_line();
477 }
478 }
479}
480
481#[cfg(test)]
484mod tests {
485 use super::*;
486
487 #[test]
488 fn push_line_flushes_pending_spans_first() {
489 let mut r = Renderer::new(80);
495 r.current_spans.push(Span::raw("pending"));
496 r.push_line(Line::from("new line"));
497 assert_eq!(r.lines.len(), 2);
498 assert!(r.current_spans.is_empty());
499 let flushed: String = r.lines[0]
500 .spans
501 .iter()
502 .map(|s| s.content.as_ref())
503 .collect();
504 assert_eq!(flushed, "pending");
505 }
506
507 #[test]
508 fn plain_text_renders_as_single_line() {
509 let text = markdown_to_text("Hello, world!", 80);
510 assert!(!text.lines.is_empty());
511 let all: String = text
513 .lines
514 .iter()
515 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
516 .collect();
517 assert!(all.contains("Hello, world!"));
518 }
519
520 #[test]
521 fn heading_produces_lines() {
522 let text = markdown_to_text("# My Heading\n\nSome paragraph.", 80);
523 let all: String = text
524 .lines
525 .iter()
526 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
527 .collect::<String>();
528 assert!(all.contains("My Heading"));
529 assert!(all.contains("Some paragraph"));
530 }
531
532 #[test]
533 fn code_block_renders_with_border() {
534 let md = "```rust\nfn main() {}\n```";
535 let text = markdown_to_text(md, 80);
536 let all: String = text
537 .lines
538 .iter()
539 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
540 .collect::<String>();
541 assert!(all.contains("fn main() {}"));
542 assert!(all.contains('╭'));
546 }
547
548 #[test]
549 fn mermaid_block_shows_hint() {
550 let md = "```mermaid\ngraph LR\n A --> B\n```";
551 let text = markdown_to_text(md, 80);
552 let all: String = text
553 .lines
554 .iter()
555 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
556 .collect::<String>();
557 assert!(all.contains("mermaid"));
558 assert!(all.contains("mmdc"));
562 }
563
564 #[test]
565 fn list_renders_bullets() {
566 let md = "- item one\n- item two";
567 let text = markdown_to_text(md, 80);
568 let all: String = text
569 .lines
570 .iter()
571 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
572 .collect::<String>();
573 assert!(all.contains("item one"));
574 assert!(all.contains("item two"));
575 }
576
577 #[test]
580 fn empty_input_returns_empty() {
581 let text = markdown_to_text("", 80);
582 assert!(text.lines.is_empty());
588 }
589
590 #[test]
593 fn bold_text_rendered() {
594 let text = markdown_to_text("**bold text**", 80);
595 let all: String = text
596 .lines
597 .iter()
598 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
599 .collect::<String>();
600 assert!(all.contains("bold text"));
601 }
602
603 #[test]
604 fn italic_text_rendered() {
605 let text = markdown_to_text("*italic text*", 80);
606 let all: String = text
607 .lines
608 .iter()
609 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
610 .collect::<String>();
611 assert!(all.contains("italic text"));
612 }
613
614 #[test]
615 fn strikethrough_text_rendered() {
616 let text = markdown_to_text("~~deleted~~", 80);
617 let all: String = text
618 .lines
619 .iter()
620 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
621 .collect::<String>();
622 assert!(all.contains("deleted"));
623 }
624
625 #[test]
626 fn inline_code_rendered() {
627 let text = markdown_to_text("use `println!()`", 80);
628 let all: String = text
629 .lines
630 .iter()
631 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
632 .collect::<String>();
633 assert!(all.contains("println!()"));
634 }
635
636 #[test]
639 fn h2_heading_rendered() {
640 let text = markdown_to_text("## Second Level", 80);
641 let all: String = text
642 .lines
643 .iter()
644 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
645 .collect::<String>();
646 assert!(all.contains("Second Level"));
647 }
648
649 #[test]
650 fn h3_heading_rendered() {
651 let text = markdown_to_text("### Third Level", 80);
652 let all: String = text
653 .lines
654 .iter()
655 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
656 .collect::<String>();
657 assert!(all.contains("Third Level"));
658 }
659
660 #[test]
661 fn h1_heading_produces_underline_rule() {
662 let text = markdown_to_text("# Heading", 80);
663 let all: String = text
664 .lines
665 .iter()
666 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
667 .collect::<String>();
668 assert!(all.contains("\u{2500}"));
670 }
671
672 #[test]
675 fn horizontal_rule_rendered() {
676 let text = markdown_to_text("above\n\n---\n\nbelow", 80);
677 let all: String = text
678 .lines
679 .iter()
680 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
681 .collect::<String>();
682 assert!(all.contains("above"));
683 assert!(all.contains("below"));
684 assert!(all.contains("\u{2500}"));
685 }
686
687 #[test]
690 fn blockquote_rendered() {
691 let text = markdown_to_text("> quoted text", 80);
692 let all: String = text
693 .lines
694 .iter()
695 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
696 .collect::<String>();
697 assert!(all.contains("quoted text"));
698 }
699
700 #[test]
703 fn ordered_list_rendered() {
704 let md = "1. first\n2. second\n3. third";
705 let text = markdown_to_text(md, 80);
706 let all: String = text
707 .lines
708 .iter()
709 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
710 .collect::<String>();
711 assert!(all.contains("first"));
712 assert!(all.contains("second"));
713 assert!(all.contains("third"));
714 }
715
716 #[test]
719 fn code_block_without_language() {
720 let md = "```\nplain code\n```";
721 let text = markdown_to_text(md, 80);
722 let all: String = text
723 .lines
724 .iter()
725 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
726 .collect::<String>();
727 assert!(all.contains("plain code"));
728 assert!(all.contains("code"));
730 }
731
732 #[test]
735 fn link_rendered() {
736 let md = "[click here](https://example.com)";
737 let text = markdown_to_text(md, 80);
738 let all: String = text
739 .lines
740 .iter()
741 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
742 .collect::<String>();
743 assert!(all.contains("click here"));
744 }
745
746 #[test]
747 fn link_with_empty_url_skips_bracket_span() {
748 let md = "[no url]()";
751 let text = markdown_to_text(md, 80);
752 let all: String = text
753 .lines
754 .iter()
755 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
756 .collect::<String>();
757 assert!(all.contains("no url"));
758 }
759
760 #[test]
763 fn narrow_width_does_not_panic() {
764 let text = markdown_to_text("# Heading\n\n---\n\nSome content", 5);
766 assert!(!text.lines.is_empty());
767 }
768
769 #[test]
770 fn zero_width_does_not_panic() {
771 let text = markdown_to_text("# Heading\n\n---", 0);
772 assert!(!text.lines.is_empty());
773 }
774
775 #[test]
778 fn inline_style_default_produces_white_text() {
779 let style = InlineStyle::default();
780 let ratatui_style = style.to_ratatui_style();
781 assert_eq!(ratatui_style.fg, Some(C_WHITE));
782 }
783
784 #[test]
785 fn inline_style_code_overrides_color() {
786 let style = InlineStyle {
787 code: true,
788 ..Default::default()
789 };
790 let ratatui_style = style.to_ratatui_style();
791 assert_ne!(ratatui_style.fg, Some(C_WHITE));
793 }
794
795 #[test]
798 fn multiple_paragraphs_have_blank_lines() {
799 let md = "First paragraph.\n\nSecond paragraph.";
800 let text = markdown_to_text(md, 80);
801 assert!(text.lines.len() >= 3);
803 }
804
805 #[test]
808 fn h4_h5_h6_headings_rendered() {
809 let md = "#### Four\n\n##### Five\n\n###### Six";
810 let text = markdown_to_text(md, 80);
811 let all: String = text
812 .lines
813 .iter()
814 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
815 .collect();
816 assert!(all.contains("Four"));
817 assert!(all.contains("Five"));
818 assert!(all.contains("Six"));
819 }
820
821 #[test]
824 fn bold_italic_nested_inherits_both_modifiers() {
825 let md = "***bold italic***";
828 let text = markdown_to_text(md, 80);
829 let style = text.lines[0].spans[0].style;
830 assert!(style.add_modifier.contains(Modifier::BOLD));
831 assert!(style.add_modifier.contains(Modifier::ITALIC));
832 }
833
834 #[test]
835 fn strikethrough_inside_bold_inherits_bold() {
836 let md = "**bold ~~and struck~~**";
837 let text = markdown_to_text(md, 80);
838 let all_styled_bold = text.lines[0].spans.iter().any(|s| {
839 s.style
840 .add_modifier
841 .contains(Modifier::CROSSED_OUT | Modifier::BOLD)
842 });
843 assert!(all_styled_bold);
844 }
845
846 #[test]
849 fn blockquote_with_multiple_lines() {
850 let md = "> line one\n> line two";
851 let text = markdown_to_text(md, 80);
852 let all: String = text
853 .lines
854 .iter()
855 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
856 .collect();
857 assert!(all.contains("line one"));
858 assert!(all.contains("line two"));
859 }
860
861 #[test]
864 fn bullet_list_with_multiple_items() {
865 let md = "- alpha\n- beta\n- gamma";
866 let text = markdown_to_text(md, 80);
867 let all: String = text
868 .lines
869 .iter()
870 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
871 .collect();
872 assert!(all.contains("alpha"));
873 assert!(all.contains("beta"));
874 assert!(all.contains("gamma"));
875 assert!(all.contains("\u{25cf}"));
876 }
877
878 #[test]
879 fn nested_list_indents() {
880 let md = "- top\n - nested\n- top2";
881 let text = markdown_to_text(md, 80);
882 let all: String = text
883 .lines
884 .iter()
885 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
886 .collect();
887 assert!(all.contains("top"));
888 assert!(all.contains("nested"));
889 }
890
891 #[test]
894 fn indented_code_block_has_no_language_label_from_lang() {
895 let md = "Normal text.\n\n indented code line\n\nMore text.";
896 let text = markdown_to_text(md, 80);
897 let all: String = text
898 .lines
899 .iter()
900 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
901 .collect();
902 assert!(all.contains("indented code line"));
903 }
904
905 #[test]
908 fn hard_break_splits_into_separate_lines() {
909 let md = "first line \nsecond line";
911 let text = markdown_to_text(md, 80);
912 assert!(text.lines.len() >= 2);
913 let all: String = text
914 .lines
915 .iter()
916 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
917 .collect();
918 assert!(all.contains("first line"));
919 assert!(all.contains("second line"));
920 }
921
922 #[test]
923 fn soft_break_becomes_space() {
924 let md = "first\nsecond";
925 let text = markdown_to_text(md, 80);
926 let all: String = text
927 .lines
928 .iter()
929 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
930 .collect();
931 assert!(all.contains("first"));
932 assert!(all.contains("second"));
933 }
934
935 #[test]
938 fn rule_flushes_pending_content_first() {
939 let md = "above text\n\n---\nbelow text";
943 let text = markdown_to_text(md, 80);
944 let all: String = text
945 .lines
946 .iter()
947 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
948 .collect();
949 assert!(all.contains("above text"));
950 assert!(all.contains("below text"));
951 }
952
953 #[test]
956 fn table_does_not_panic_and_renders_cell_text() {
957 let md = "| A | B |\n|---|---|\n| 1 | 2 |";
958 let text = markdown_to_text(md, 80);
959 let all: String = text
960 .lines
961 .iter()
962 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
963 .collect();
964 assert!(all.contains('1'));
970 assert!(all.contains('A'));
971 }
972
973 #[test]
982 fn heading_as_first_content_of_list_item_flushes_pending_bullet_span() {
983 let md = "- # nested heading in item\n- item2";
988 let text = markdown_to_text(md, 80);
989 let all: String = text
990 .lines
991 .iter()
992 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
993 .collect();
994 assert!(all.contains("nested heading in item"));
995 assert!(all.contains("item2"));
996 }
997
998 #[test]
999 fn code_block_as_first_content_of_list_item_flushes_pending_bullet_span() {
1000 let md = "- ```\ncode\n```";
1002 let text = markdown_to_text(md, 80);
1003 let all: String = text
1004 .lines
1005 .iter()
1006 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
1007 .collect();
1008 assert!(all.contains("code"));
1009 }
1010
1011 #[test]
1012 fn rule_directly_inside_blockquote_flushes_pending_quote_marker_span() {
1013 let md = "> ---";
1018 let text = markdown_to_text(md, 80);
1019 assert!(!text.lines.is_empty());
1020 }
1021
1022 #[test]
1023 fn empty_blockquote_flushes_pending_quote_marker_span_at_end() {
1024 let md = ">";
1029 let text = markdown_to_text(md, 80);
1030 let all: String = text
1031 .lines
1032 .iter()
1033 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
1034 .collect();
1035 assert!(all.contains('│'));
1036 }
1037
1038 #[test]
1039 fn nested_strong_inside_strikethrough_inherits_strikethrough_modifier() {
1040 let md = "~~strike **bold inside** more~~";
1045 let text = markdown_to_text(md, 80);
1046 let has_strikethrough_bold = text.lines.iter().any(|l| {
1047 l.spans.iter().any(|s| {
1048 s.content.contains("bold inside")
1049 && s.style.add_modifier.contains(Modifier::CROSSED_OUT)
1050 && s.style.add_modifier.contains(Modifier::BOLD)
1051 })
1052 });
1053 assert!(has_strikethrough_bold);
1054 }
1055
1056 #[test]
1070 fn handle_text_content_with_embedded_newline_splits_lines() {
1071 let mut r = Renderer::new(80);
1075 r.handle_text_content("first\nsecond\nthird");
1076 assert!(r.lines.len() >= 2);
1079 let all: String = r
1080 .lines
1081 .iter()
1082 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
1083 .collect();
1084 assert!(all.contains("first"));
1085 assert!(all.contains("second"));
1086 }
1087
1088 #[test]
1089 fn handle_text_content_with_leading_newline_skips_empty_first_part() {
1090 let mut r = Renderer::new(80);
1092 r.handle_text_content("\nhello");
1093 let pending: String = r.current_spans.iter().map(|s| s.content.as_ref()).collect();
1096 assert!(pending.contains("hello"));
1097 }
1098
1099 #[test]
1100 fn handle_text_content_without_newline_emits_directly() {
1101 let mut r = Renderer::new(80);
1102 r.handle_text_content("first line\n");
1107 r.handle_text_content("no newline here");
1108 let all: String = r
1109 .lines
1110 .iter()
1111 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
1112 .collect::<String>()
1113 + r.current_spans
1114 .iter()
1115 .map(|s| s.content.as_ref())
1116 .collect::<String>()
1117 .as_str();
1118 assert!(all.contains("no newline here"));
1119 assert!(all.contains("first line"));
1120 }
1121}