Skip to main content

leviath_cli/
render.rs

1//! Markdown → ratatui `Text` renderer.
2//!
3//! Converts a markdown string to a `ratatui::text::Text<'static>` using
4//! `pulldown-cmark`.  Designed for rendering agent output inside the dashboard's
5//! content panes where only `Paragraph` + `Text` are available (no nested widgets).
6//!
7//! Feature coverage:
8//! - Headings H1–H6 (bold + accent colour, H1 gets an underline rule)
9//! - Paragraphs with soft/hard breaks
10//! - Bullet and ordered lists (nested up to depth 3)
11//! - Blockquotes (dim, "│ " prefix)
12//! - Inline `code`, **bold**, *italic*, ~~strikethrough~~, links (underlined)
13//! - Fenced code blocks (tinted, language tag in title line)
14//! - `mermaid` fenced blocks → styled fallback box with a hint to install mmdc
15//! - Horizontal rules (dim dashes)
16//!
17//! Plain text that contains no markdown degrades cleanly - it just renders as
18//! white text.
19
20use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
21use ratatui::{
22    style::{Color, Modifier, Style},
23    text::{Line, Span, Text},
24};
25
26// ─── Palette ──────────────────────────────────────────────────────────────────
27//
28// Shared with every other Leviath terminal surface. Imported from the single
29// definition in `tui::theme` rather than a hand-copied duplicate of the
30// dashboard's palette, which is exactly the kind of thing that drifts.
31
32use crate::tui::theme::{C_ACCENT, C_CODE_BG, C_DIM, C_MUTED, C_SUCCESS, C_WHITE};
33
34// ─── Public API ───────────────────────────────────────────────────────────────
35
36/// Convert a markdown string to ratatui `Text` for rendering in a `Paragraph`.
37///
38/// `width` is used to draw horizontal rules to the correct width.
39pub 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// ─── Renderer internals ───────────────────────────────────────────────────────
46
47/// Stack of style modifiers accumulated from nested inline tags.
48#[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    /// Spans being accumulated for the current line.
81    current_spans: Vec<Span<'static>>,
82    /// Inline style stack (push/pop for nested inline tags).
83    inline_stack: Vec<InlineStyle>,
84    /// Current inline style (derived from the stack).
85    inline: InlineStyle,
86    /// Whether we're currently inside a fenced code block.
87    in_code_block: bool,
88    /// Language hint for the current code block.
89    code_lang: Option<String>,
90    /// Source lines accumulated within a code block.
91    code_lines: Vec<String>,
92    /// List nesting stack: None = bullet, Some(n) = ordered (current item #).
93    list_stack: Vec<Option<u64>>,
94    /// Terminal width (used for HR lines).
95    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    /// Flush `current_spans` into a finished `Line`.
114    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    /// Push a fully-built `Line` directly.
120    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    /// Push an empty blank line.
128    fn blank_line(&mut self) {
129        self.push_line(Line::from(""));
130    }
131
132    /// Current list indent (2 spaces per nesting level).
133    fn list_indent(&self) -> String {
134        "  ".repeat(self.list_stack.len())
135    }
136
137    /// Rebuild the active inline style from the stack top.
138    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        // Inherit accumulated modifiers from parent
144        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    /// Emit a span of text with the current inline style.
165    fn emit_text(&mut self, text: &str) {
166        let style = self.inline.to_ratatui_style();
167        // Preserve leading space so words don't jam together
168        self.current_spans
169            .push(Span::styled(text.to_owned(), style));
170    }
171
172    /// Handle a non-code-block text payload, splitting on embedded newlines if
173    /// present. Factored out so the newline-split path can be exercised in
174    /// tests directly (pulldown_cmark never produces a non-code Text event with
175    /// a literal `\n` under current Options, so this path is unreachable via
176    /// the parser alone).
177    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    /// Render a complete code block (fenced or mermaid).
195    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            // ── Mermaid fallback ─────────────────────────────────────────────
202            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            // ── Regular code block ───────────────────────────────────────────
222            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                // ── Block starts ─────────────────────────────────────────────
256                Event::Start(Tag::Heading { level, .. }) => {
257                    // Flush any pending content and start a fresh heading line
258                    if !self.current_spans.is_empty() {
259                        self.flush_line();
260                    }
261                    // Blank line before non-first headings
262                    if !self.lines.is_empty() {
263                        self.blank_line();
264                    }
265                    // Visual decorators - convey depth without # text (terminal can't vary font size)
266                    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                    // H1 gets a dim underline rule
288                    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                    // Increment ordered list counter
338                    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                    // Show the URL as a dim suffix after the link text
406                    let url = dest_url.into_string();
407                    if !url.is_empty() {
408                        // We'll append the URL after the link text in End(Link)
409                        // Store it via a span now so we can reference it; simpler: just push
410                        // a placeholder and let the text events fill in link text.
411                        // We push the URL as a trailing span at End(Link).
412                        // Use a little indirection: push the open bracket.
413                        self.current_spans
414                            .push(Span::styled("[", Style::default().fg(C_DIM)));
415                        // Stash URL in a "pending link url" field would be ideal.
416                        // For simplicity, store it as a special span at end.
417                        // We'll capture the URL by pushing it immediately at End.
418                        // So skip storing; just remember we need to close.
419                        let _ = url; // url emitted at End(Link) via stored span approach
420                    }
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                // ── Inline events ────────────────────────────────────────────
429                Event::Text(text) => {
430                    if self.in_code_block {
431                        // Accumulate raw code lines
432                        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                    // Inline code span
443                    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                    // Soft breaks just become a space in terminal output
451                    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                // Ignore HTML, footnotes, task list markers, math, etc.
470                _ => {}
471            }
472        }
473
474        // Flush any trailing content
475        if !self.current_spans.is_empty() {
476            self.flush_line();
477        }
478    }
479}
480
481// ─── Tests ────────────────────────────────────────────────────────────────────
482
483#[cfg(test)]
484mod tests {
485    use super::*;
486
487    #[test]
488    fn push_line_flushes_pending_spans_first() {
489        // No current caller ever invokes `push_line` while `current_spans`
490        // is non-empty (each call site flushes its own pending spans via a
491        // separate check first) - but the method itself is directly
492        // testable by constructing that state manually, without needing a
493        // real caller to reach it.
494        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        // The content should contain our text somewhere
512        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        // Should have a border glyph. Code-block borders only ever render
543        // with '╭' (never '│'), so checking a single glyph avoids a
544        // redundant `||` whose right-hand side could never be reached.
545        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        // The fallback hint always contains both substrings together, so
559        // checking one avoids a redundant `||` whose right-hand side could
560        // never be reached.
561        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    // ─── Empty input ───────────────────────────────────────────────────────
578
579    #[test]
580    fn empty_input_returns_empty() {
581        let text = markdown_to_text("", 80);
582        // Empty input produces zero lines (no content to render). Checked
583        // directly (rather than via `.iter().all(|l| ...)`) since `all()`
584        // short-circuits without invoking its predicate on an empty
585        // iterator, which would otherwise leave that closure's body
586        // permanently unreachable.
587        assert!(text.lines.is_empty());
588    }
589
590    // ─── Inline styles ────────────────────────────────────────────────────
591
592    #[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    // ─── Headings ──────────────────────────────────────────────────────────
637
638    #[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        // H1 should produce a horizontal rule line underneath
669        assert!(all.contains("\u{2500}"));
670    }
671
672    // ─── Horizontal rule ───────────────────────────────────────────────────
673
674    #[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    // ─── Blockquote ────────────────────────────────────────────────────────
688
689    #[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    // ─── Ordered list ──────────────────────────────────────────────────────
701
702    #[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    // ─── Code block without language ───────────────────────────────────────
717
718    #[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        // Should show "code" as the default language label
729        assert!(all.contains("code"));
730    }
731
732    // ─── Link rendering ───────────────────────────────────────────────────
733
734    #[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        // Exercises the `!url.is_empty()` false arm (a link whose destination
749        // URL is empty), which skips pushing the trailing "[" span.
750        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    // ─── Narrow width ──────────────────────────────────────────────────────
761
762    #[test]
763    fn narrow_width_does_not_panic() {
764        // Very narrow width should not cause panics
765        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    // ─── InlineStyle ───────────────────────────────────────────────────────
776
777    #[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        // Code should have a specific fg color, not white
792        assert_ne!(ratatui_style.fg, Some(C_WHITE));
793    }
794
795    // ─── Multiple paragraphs ───────────────────────────────────────────────
796
797    #[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        // Should have more than 2 lines (paragraphs + blank separators)
802        assert!(text.lines.len() >= 3);
803    }
804
805    // ─── Additional heading levels (H4/H5/H6) ───────────────────────────────
806
807    #[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    // ─── Nested inline styles inherit from parent ──────────────────────────
822
823    #[test]
824    fn bold_italic_nested_inherits_both_modifiers() {
825        // ***text*** parses as Strong containing Emphasis (or vice versa) -
826        // the inner style must inherit the outer's bold/italic/strikethrough.
827        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    // ─── Blockquote with multiple lines flushes correctly ──────────────────
847
848    #[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    // ─── Nested / multi-item lists flush pending content between items ─────
862
863    #[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    // ─── Indented (non-fenced) code block ───────────────────────────────────
892
893    #[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    // ─── Multi-line text event (embedded newline split) ────────────────────
906
907    #[test]
908    fn hard_break_splits_into_separate_lines() {
909        // Two trailing spaces + newline = hard break in CommonMark.
910        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    // ─── Rule with pending inline content before it ────────────────────────
936
937    #[test]
938    fn rule_flushes_pending_content_first() {
939        // pulldown-cmark treats "text\n***" as a paragraph followed by a rule
940        // only when properly separated; use explicit blank-line-free content
941        // before a thematic break to exercise the pending-flush branch.
942        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    // ─── Table events fall through the catch-all arm ───────────────────────
954
955    #[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        // Table structural events are ignored (catch-all arm), but the text
965        // content inside cells still comes through as Text events. Both
966        // substrings are always present together, so check each directly
967        // rather than via a redundant `||` whose right-hand side could
968        // never be reached.
969        assert!(all.contains('1'));
970        assert!(all.contains('A'));
971    }
972
973    // ─── Renderer state-machine edge cases ──────────────────────────────────
974    //
975    // These target `flush_line()`/inline-inheritance branches that only fire
976    // when `current_spans` (or the inline-style stack) is in a specific,
977    // non-default state at the moment a new block/inline event starts --
978    // found by probing actual `pulldown_cmark::Parser` event streams for
979    // each input (see git history) rather than guessing at markdown syntax.
980
981    #[test]
982    fn heading_as_first_content_of_list_item_flushes_pending_bullet_span() {
983        // `Start(Item)` pushes the bullet marker into `current_spans`, then
984        // `Start(Heading)` fires with no Text/other event in between --
985        // exercises the `!self.current_spans.is_empty()` flush at heading
986        // start.
987        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        // Same shape as the heading case above, but for `Start(CodeBlock)`.
1001        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        // `Start(BlockQuote)` pushes the "│ " marker into `current_spans`,
1014        // then `Event::Rule` fires with nothing else queued - exercises the
1015        // `!self.current_spans.is_empty()` flush at `Event::Rule` (every
1016        // other rule test has an empty `current_spans` at that point).
1017        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        // `Start(BlockQuote)` pushes "│ " with no inner content at all
1025        // before `End(BlockQuote)` - exercises the flush at blockquote end
1026        // (every other blockquote test has real paragraph content, which
1027        // flushes `current_spans` via `End(Paragraph)` first).
1028        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        // `push_inline` for the inner `Strong` tag inherits `strikethrough`
1041        // from the `Strikethrough` parent already on the stack - every
1042        // other strikethrough test only nests plain text, never another
1043        // inline tag, so `parent.strikethrough` was never true at push time.
1044        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    // `push_line`'s own `if !self.current_spans.is_empty() { self.flush_line() }`
1057    // guard is not reachable given how `push_line`/`blank_line` are actually
1058    // called in this file: every call site (heading/paragraph/blockquote/list/
1059    // rule end handlers) already flushes `current_spans` explicitly, via its
1060    // own separate check, before ever calling `push_line`/`blank_line`.
1061
1062    // `Event::Start(Tag::Item)`'s `None => "● ".to_string()` arm (list_stack
1063    // empty) is not covered and is not reachable: `Tag::Item` is only ever
1064    // emitted by `pulldown_cmark` as a child of `Tag::List`, which always
1065    // pushes onto `list_stack` first.
1066
1067    // ─── handle_text_content: embedded newline split (direct call) ────────────
1068
1069    #[test]
1070    fn handle_text_content_with_embedded_newline_splits_lines() {
1071        // pulldown_cmark never produces a non-code Text event with `\n`, so
1072        // the newline-split path in handle_text_content is exercised here by
1073        // calling the method directly.
1074        let mut r = Renderer::new(80);
1075        r.handle_text_content("first\nsecond\nthird");
1076        // The first line is flushed for each embedded newline; at least 2 lines
1077        // should have been emitted.
1078        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        // The empty part before the leading '\n' must not emit an empty span.
1091        let mut r = Renderer::new(80);
1092        r.handle_text_content("\nhello");
1093        // "hello" lands in current_spans (pending); lines get the flush for the
1094        // empty-first-part boundary, which produces an empty line.
1095        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        // Flush a real line into `r.lines` first (text ending in a newline
1103        // gets flushed), so the `r.lines` iteration below actually visits an
1104        // element instead of running over an always-empty `Vec` and leaving
1105        // its closure unreachable.
1106        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}