Skip to main content

mermaid_cli/render/
markdown.rs

1use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
2use ratatui::style::{Modifier, Style};
3use ratatui::text::{Line, Span};
4use unicode_width::UnicodeWidthStr;
5
6use crate::render::theme::Theme;
7
8#[derive(Debug, Clone)]
9struct ListState {
10    next_number: Option<u64>,
11    /// Leading whitespace that aligns a continuation block (a 2nd+ paragraph in
12    /// a loose list item) under the current item's text — set when the item's
13    /// marker is emitted.
14    cont_indent: String,
15}
16
17/// Parse markdown and convert to theme-styled ratatui Lines.
18///
19/// Code-block lines are tagged by setting the returned `Line`'s base style
20/// background to the theme's `code_background`. The chat renderer keys off
21/// that to skip word-wrapping them (so indentation survives) and to know
22/// they're pre-formatted. Inline code carries the background on the *span*,
23/// not the line, so prose lines that merely contain `code` still word-wrap.
24pub fn parse_markdown(input: &str, theme: &Theme) -> Vec<Line<'static>> {
25    let mut options = Options::empty();
26    options.insert(Options::ENABLE_STRIKETHROUGH);
27    options.insert(Options::ENABLE_TABLES);
28
29    // Resolve the theme palette once.
30    let c = &theme.colors;
31    let code_bg = c.code_background.to_color();
32    let code_fg = c.code_foreground.to_color();
33    let heading1 = Style::new().fg(c.header.to_color()).bold();
34    let heading2 = Style::new().fg(c.info.to_color()).bold();
35    let heading3 = Style::new().fg(c.success.to_color()).bold();
36    let heading_other = Style::new().fg(c.warning.to_color()).bold();
37    let link_style = Style::new()
38        .fg(c.info.to_color())
39        .add_modifier(Modifier::UNDERLINED);
40    let marker_style = Style::new().fg(c.text_secondary.to_color());
41    let rule_style = Style::new().fg(c.text_disabled.to_color());
42    let quote_bar_style = Style::new().fg(c.text_disabled.to_color());
43    let quote_text_style = Style::new()
44        .fg(c.text_secondary.to_color())
45        .add_modifier(Modifier::ITALIC);
46
47    let parser = Parser::new_ext(input, options);
48    let mut lines: Vec<Line<'static>> = Vec::new();
49    let mut current_line_spans: Vec<Span<'static>> = Vec::new();
50    let mut style_stack = vec![Style::default()];
51    let mut in_code_block = false;
52    let mut code_block_content = String::new();
53    let mut code_block_lang = String::new();
54    let mut current_link_url: Option<String> = None;
55    let mut list_stack: Vec<ListState> = Vec::new();
56
57    // Table state
58    let mut in_table = false;
59    let mut table_rows: Vec<Vec<String>> = Vec::new();
60    let mut current_row: Vec<String> = Vec::new();
61    let mut current_cell = String::new();
62    let mut table_header_len: usize = 0;
63
64    for event in parser {
65        match event {
66            Event::Start(tag) => {
67                let new_style = match tag {
68                    Tag::Heading { level, .. } => {
69                        if !current_line_spans.is_empty() {
70                            lines.push(Line::from(std::mem::take(&mut current_line_spans)));
71                        }
72                        // Blank line before heading (except the first thing).
73                        if !lines.is_empty() {
74                            lines.push(Line::from(""));
75                        }
76                        match level {
77                            HeadingLevel::H1 => heading1,
78                            HeadingLevel::H2 => heading2,
79                            HeadingLevel::H3 => heading3,
80                            _ => heading_other,
81                        }
82                    },
83                    Tag::Emphasis => style_stack.last().copied().unwrap_or_default().italic(),
84                    Tag::Strong => style_stack.last().copied().unwrap_or_default().bold(),
85                    Tag::Strikethrough => style_stack
86                        .last()
87                        .copied()
88                        .unwrap_or_default()
89                        .crossed_out(),
90                    Tag::CodeBlock(kind) => {
91                        in_code_block = true;
92                        code_block_content.clear();
93                        if !current_line_spans.is_empty() {
94                            lines.push(Line::from(std::mem::take(&mut current_line_spans)));
95                        }
96                        code_block_lang = match kind {
97                            CodeBlockKind::Fenced(lang) => lang.to_string(),
98                            CodeBlockKind::Indented => String::new(),
99                        };
100                        if !code_block_lang.is_empty() {
101                            lines.push(Line::from(Span::styled(
102                                code_block_lang.clone(),
103                                Style::new()
104                                    .fg(c.text_disabled.to_color())
105                                    .add_modifier(Modifier::ITALIC),
106                            )));
107                        }
108                        Style::default().fg(code_fg)
109                    },
110                    Tag::List(start) => {
111                        list_stack.push(ListState {
112                            next_number: start,
113                            cont_indent: String::new(),
114                        });
115                        if !current_line_spans.is_empty() {
116                            lines.push(Line::from(std::mem::take(&mut current_line_spans)));
117                        }
118                        style_stack.last().copied().unwrap_or_default()
119                    },
120                    Tag::Item => {
121                        let indent = "  ".repeat(list_stack.len());
122                        let marker = if let Some(state) = list_stack.last_mut() {
123                            if let Some(current) = state.next_number {
124                                state.next_number = Some(current + 1);
125                                format!("{}. ", current)
126                            } else {
127                                "• ".to_string()
128                            }
129                        } else {
130                            "• ".to_string()
131                        };
132                        // Body paragraphs of this item hang-indent to align under
133                        // the text that follows the marker.
134                        let cont_indent =
135                            format!("{}{}", indent, " ".repeat(marker.as_str().width()));
136                        if let Some(state) = list_stack.last_mut() {
137                            state.cont_indent = cont_indent;
138                        }
139                        current_line_spans.push(Span::raw(indent));
140                        current_line_spans.push(Span::styled(marker, marker_style));
141                        style_stack.last().copied().unwrap_or_default()
142                    },
143                    Tag::Paragraph => {
144                        // First paragraph of an item still carries the marker
145                        // spans (non-empty); a continuation paragraph starts
146                        // empty, so re-indent it to align under the item text.
147                        if current_line_spans.is_empty()
148                            && let Some(state) = list_stack.last()
149                            && !state.cont_indent.is_empty()
150                        {
151                            current_line_spans.push(Span::raw(state.cont_indent.clone()));
152                        }
153                        style_stack.last().copied().unwrap_or_default()
154                    },
155                    Tag::Table(_alignments) => {
156                        in_table = true;
157                        table_rows.clear();
158                        table_header_len = 0;
159                        if !current_line_spans.is_empty() {
160                            lines.push(Line::from(std::mem::take(&mut current_line_spans)));
161                        }
162                        style_stack.last().copied().unwrap_or_default()
163                    },
164                    Tag::TableHead | Tag::TableRow => {
165                        current_row.clear();
166                        style_stack.last().copied().unwrap_or_default()
167                    },
168                    Tag::TableCell => {
169                        current_cell.clear();
170                        style_stack.last().copied().unwrap_or_default()
171                    },
172                    Tag::Link { dest_url, .. } => {
173                        // Render the link text underlined in the accent color;
174                        // the destination URL is appended dimmed on the End tag
175                        // (terminals can't follow it without OSC-8).
176                        current_link_url = Some(dest_url.to_string());
177                        link_style
178                    },
179                    Tag::BlockQuote(_) => {
180                        if !current_line_spans.is_empty() {
181                            lines.push(Line::from(std::mem::take(&mut current_line_spans)));
182                        }
183                        current_line_spans.push(Span::styled("│ ", quote_bar_style));
184                        quote_text_style
185                    },
186                    _ => style_stack.last().copied().unwrap_or_default(),
187                };
188                style_stack.push(new_style);
189            },
190            Event::End(tag) => {
191                style_stack.pop();
192                match tag {
193                    TagEnd::Heading(_) => {
194                        if !current_line_spans.is_empty() {
195                            lines.push(Line::from(std::mem::take(&mut current_line_spans)));
196                        }
197                    },
198                    TagEnd::Paragraph | TagEnd::Item => {
199                        if !current_line_spans.is_empty() {
200                            lines.push(Line::from(std::mem::take(&mut current_line_spans)));
201                        }
202                    },
203                    TagEnd::CodeBlock => {
204                        in_code_block = false;
205                        let prefixes = line_comment_prefixes(&code_block_lang);
206                        let base = Style::default().fg(code_fg).bg(code_bg);
207                        for line_text in code_block_content.lines() {
208                            let spans = highlight_code_line(line_text, prefixes, theme);
209                            // Mark the LINE base style with the code bg so the
210                            // chat renderer treats it as pre-formatted.
211                            lines.push(Line::from(spans).style(base));
212                        }
213                        code_block_content.clear();
214                        code_block_lang.clear();
215                    },
216                    TagEnd::List(_) => {
217                        let _ = list_stack.pop();
218                        if list_stack.is_empty() {
219                            lines.push(Line::from(""));
220                        }
221                    },
222                    TagEnd::TableCell => {
223                        current_row.push(std::mem::take(&mut current_cell));
224                    },
225                    TagEnd::TableHead => {
226                        table_header_len = current_row.len();
227                        table_rows.push(std::mem::take(&mut current_row));
228                    },
229                    TagEnd::TableRow => {
230                        table_rows.push(std::mem::take(&mut current_row));
231                    },
232                    TagEnd::Table => {
233                        in_table = false;
234                        render_table(&mut lines, &table_rows, table_header_len, theme);
235                        table_rows.clear();
236                    },
237                    TagEnd::Link => {
238                        // Append the destination as dimmed " (url)" unless it's
239                        // identical to the visible text (autolinks) or empty.
240                        if let Some(url) = current_link_url.take() {
241                            let text: String = current_line_spans
242                                .iter()
243                                .map(|s| s.content.as_ref())
244                                .collect();
245                            if !url.is_empty() && !text.ends_with(&url) {
246                                current_line_spans.push(Span::styled(
247                                    format!(" ({})", url),
248                                    Style::new().fg(c.text_disabled.to_color()),
249                                ));
250                            }
251                        }
252                    },
253                    TagEnd::BlockQuote(_) => {
254                        if !current_line_spans.is_empty() {
255                            lines.push(Line::from(std::mem::take(&mut current_line_spans)));
256                        }
257                    },
258                    _ => {},
259                }
260            },
261            Event::Text(text) => {
262                if in_code_block {
263                    code_block_content.push_str(&text);
264                } else if in_table {
265                    current_cell.push_str(&text);
266                } else {
267                    let style = style_stack.last().copied().unwrap_or_default();
268                    current_line_spans.push(Span::styled(text.to_string(), style));
269                }
270            },
271            Event::Code(code) => {
272                if in_table {
273                    current_cell.push_str(&code);
274                } else {
275                    // Inline code: tight (no padding spaces), code colors. The
276                    // background lives on the SPAN only — prose lines with
277                    // inline code still word-wrap normally.
278                    let style = Style::default().fg(code_fg).bg(code_bg);
279                    current_line_spans.push(Span::styled(code.to_string(), style));
280                }
281            },
282            Event::Rule => {
283                if !current_line_spans.is_empty() {
284                    lines.push(Line::from(std::mem::take(&mut current_line_spans)));
285                }
286                lines.push(Line::from(Span::styled("─".repeat(40), rule_style)));
287            },
288            Event::SoftBreak | Event::HardBreak => {
289                if !current_line_spans.is_empty() {
290                    lines.push(Line::from(std::mem::take(&mut current_line_spans)));
291                }
292            },
293            _ => {},
294        }
295    }
296
297    if !current_line_spans.is_empty() {
298        lines.push(Line::from(current_line_spans));
299    }
300
301    lines
302}
303
304/// Render the accumulated table rows into aligned, themed lines.
305fn render_table(
306    lines: &mut Vec<Line<'static>>,
307    table_rows: &[Vec<String>],
308    table_header_len: usize,
309    theme: &Theme,
310) {
311    let c = &theme.colors;
312    // Column widths in DISPLAY CELLS (CJK-safe), min 3.
313    let num_cols = table_rows.iter().map(|r| r.len()).max().unwrap_or(0);
314    let mut col_widths = vec![0usize; num_cols];
315    for row in table_rows {
316        for (i, cell) in row.iter().enumerate() {
317            if i < num_cols {
318                col_widths[i] = col_widths[i].max(cell.width());
319            }
320        }
321    }
322    for w in &mut col_widths {
323        *w = (*w).max(3);
324    }
325
326    let border_style = Style::default().fg(c.text_disabled.to_color());
327    let header_style = Style::default().fg(c.header.to_color()).bold();
328    let cell_style = Style::default().fg(c.text_primary.to_color());
329
330    for (row_idx, row) in table_rows.iter().enumerate() {
331        let mut spans = vec![Span::styled("| ", border_style)];
332        for (col_idx, cell) in row.iter().enumerate() {
333            let width = col_widths.get(col_idx).copied().unwrap_or(3);
334            let padding = width.saturating_sub(cell.width());
335            let padded = format!("{}{}", cell, " ".repeat(padding));
336            let style = if row_idx == 0 && table_header_len > 0 {
337                header_style
338            } else {
339                cell_style
340            };
341            spans.push(Span::styled(padded, style));
342            spans.push(Span::styled(" | ", border_style));
343        }
344        lines.push(Line::from(spans));
345
346        if row_idx == 0 && table_header_len > 0 {
347            let mut sep_spans = vec![Span::styled("|-", border_style)];
348            for (col_idx, _) in row.iter().enumerate() {
349                let width = col_widths.get(col_idx).copied().unwrap_or(3);
350                sep_spans.push(Span::styled("-".repeat(width), border_style));
351                sep_spans.push(Span::styled("-|-", border_style));
352            }
353            lines.push(Line::from(sep_spans));
354        }
355    }
356
357    lines.push(Line::from(""));
358}
359
360/// Line-comment prefix(es) for a fenced-code language hint. Falls back to a
361/// permissive set so unknown languages still get comment coloring.
362fn line_comment_prefixes(lang: &str) -> &'static [&'static str] {
363    match lang.trim().to_ascii_lowercase().as_str() {
364        "rust" | "rs" | "c" | "cpp" | "c++" | "h" | "hpp" | "java" | "js" | "javascript" | "ts"
365        | "typescript" | "tsx" | "jsx" | "go" | "golang" | "swift" | "kotlin" | "kt" | "scala"
366        | "cs" | "csharp" | "php" | "dart" | "zig" | "rust,no_run" => &["//"],
367        "python" | "py" | "ruby" | "rb" | "sh" | "bash" | "zsh" | "shell" | "console" | "yaml"
368        | "yml" | "toml" | "ini" | "perl" | "pl" | "r" | "elixir" | "ex" | "makefile"
369        | "dockerfile" | "nix" => &["#"],
370        "sql" | "lua" | "haskell" | "hs" | "ada" => &["--"],
371        "lisp" | "clojure" | "clj" | "scheme" | "el" => &[";"],
372        _ => &["//", "#"],
373    }
374}
375
376/// Cross-language keyword set for the lightweight in-house highlighter.
377fn is_keyword(w: &str) -> bool {
378    matches!(
379        w,
380        "fn" | "let"
381            | "const"
382            | "mut"
383            | "pub"
384            | "struct"
385            | "enum"
386            | "impl"
387            | "trait"
388            | "use"
389            | "mod"
390            | "match"
391            | "if"
392            | "else"
393            | "for"
394            | "while"
395            | "loop"
396            | "return"
397            | "break"
398            | "continue"
399            | "async"
400            | "await"
401            | "move"
402            | "ref"
403            | "where"
404            | "type"
405            | "dyn"
406            | "as"
407            | "in"
408            | "static"
409            | "unsafe"
410            | "extern"
411            | "crate"
412            | "self"
413            | "Self"
414            | "super"
415            | "function"
416            | "var"
417            | "def"
418            | "class"
419            | "import"
420            | "from"
421            | "export"
422            | "default"
423            | "public"
424            | "private"
425            | "protected"
426            | "void"
427            | "int"
428            | "long"
429            | "float"
430            | "double"
431            | "bool"
432            | "boolean"
433            | "char"
434            | "string"
435            | "true"
436            | "false"
437            | "null"
438            | "nil"
439            | "None"
440            | "True"
441            | "False"
442            | "this"
443            | "new"
444            | "try"
445            | "catch"
446            | "finally"
447            | "throw"
448            | "throws"
449            | "package"
450            | "interface"
451            | "extends"
452            | "implements"
453            | "do"
454            | "then"
455            | "elif"
456            | "lambda"
457            | "yield"
458            | "with"
459            | "and"
460            | "or"
461            | "not"
462            | "is"
463            | "end"
464            | "begin"
465            | "val"
466            | "func"
467            | "defer"
468            | "select"
469            | "chan"
470            | "range"
471            | "switch"
472            | "case"
473    )
474}
475
476/// Tokenize one code line into styled spans (all sharing the code background)
477/// using a small, language-agnostic lexer: line comments, quoted strings, and
478/// a cross-language keyword set. Everything else is the default code color.
479fn highlight_code_line(text: &str, comment_prefixes: &[&str], theme: &Theme) -> Vec<Span<'static>> {
480    let c = &theme.colors;
481    let bg = c.code_background.to_color();
482    let base = Style::default().fg(c.code_foreground.to_color()).bg(bg);
483    let kw_style = Style::default().fg(c.code_keyword.to_color()).bg(bg);
484    let str_style = Style::default().fg(c.code_string.to_color()).bg(bg);
485    let com_style = Style::default().fg(c.code_comment.to_color()).bg(bg);
486
487    let mut spans: Vec<Span<'static>> = Vec::new();
488    let mut pending = String::new();
489    let flush = |spans: &mut Vec<Span<'static>>, pending: &mut String| {
490        if !pending.is_empty() {
491            spans.push(Span::styled(std::mem::take(pending), base));
492        }
493    };
494
495    let mut it = text.char_indices().peekable();
496    while let Some(&(byte_idx, ch)) = it.peek() {
497        // Line comment → rest of the line.
498        if comment_prefixes
499            .iter()
500            .any(|p| text[byte_idx..].starts_with(p))
501        {
502            flush(&mut spans, &mut pending);
503            spans.push(Span::styled(text[byte_idx..].to_string(), com_style));
504            break;
505        }
506        // String literal.
507        if ch == '"' || ch == '\'' || ch == '`' {
508            flush(&mut spans, &mut pending);
509            let quote = ch;
510            let start = byte_idx;
511            it.next(); // opening quote
512            let mut end = text.len();
513            let mut escaped = false;
514            while let Some(&(bi, ci)) = it.peek() {
515                it.next();
516                end = bi + ci.len_utf8();
517                if escaped {
518                    escaped = false;
519                } else if ci == '\\' {
520                    escaped = true;
521                } else if ci == quote {
522                    break;
523                }
524            }
525            spans.push(Span::styled(text[start..end].to_string(), str_style));
526            continue;
527        }
528        // Identifier / keyword.
529        if ch.is_alphanumeric() || ch == '_' {
530            let start = byte_idx;
531            let mut end = byte_idx + ch.len_utf8();
532            it.next();
533            while let Some(&(bi, ci)) = it.peek() {
534                if ci.is_alphanumeric() || ci == '_' {
535                    end = bi + ci.len_utf8();
536                    it.next();
537                } else {
538                    break;
539                }
540            }
541            let word = &text[start..end];
542            if is_keyword(word) {
543                flush(&mut spans, &mut pending);
544                spans.push(Span::styled(word.to_string(), kw_style));
545            } else {
546                pending.push_str(word);
547            }
548            continue;
549        }
550        // Anything else (whitespace, punctuation) → default run.
551        pending.push(ch);
552        it.next();
553    }
554    flush(&mut spans, &mut pending);
555    if spans.is_empty() {
556        spans.push(Span::styled(String::new(), base));
557    }
558    spans
559}
560
561#[cfg(test)]
562mod tests {
563    use super::*;
564
565    /// Parse with the dark theme (tests only assert on text/structure).
566    fn md(input: &str) -> Vec<Line<'static>> {
567        parse_markdown(input, &Theme::dark())
568    }
569
570    /// Flatten all spans in all lines into a single string.
571    fn lines_to_text(lines: &[Line]) -> String {
572        lines
573            .iter()
574            .map(|line| {
575                line.spans
576                    .iter()
577                    .map(|s| s.content.as_ref())
578                    .collect::<String>()
579            })
580            .collect::<Vec<_>>()
581            .join("\n")
582    }
583
584    #[test]
585    fn test_plain_text() {
586        let lines = md("Hello, world!");
587        assert!(!lines.is_empty());
588        assert!(lines_to_text(&lines).contains("Hello, world!"));
589    }
590
591    #[test]
592    fn test_heading_levels() {
593        let lines = md("# H1\n## H2\n### H3");
594        let text = lines_to_text(&lines);
595        assert!(text.contains("H1"));
596        assert!(text.contains("H2"));
597        assert!(text.contains("H3"));
598        assert!(lines.len() >= 3);
599    }
600
601    #[test]
602    fn test_code_block() {
603        let lines = md("```rust\nfn main() {}\n```");
604        let text = lines_to_text(&lines);
605        assert!(text.contains("fn main() {}"));
606        assert!(text.contains("rust"));
607    }
608
609    #[test]
610    fn code_block_lines_tagged_with_code_background() {
611        let lines = md("```rust\nfn main() {}\n```");
612        let code_bg = Theme::dark().colors.code_background.to_color();
613        // The line carrying the code body must be flagged via its base style
614        // background (this is what the chat renderer keys off to skip wrap).
615        assert!(
616            lines.iter().any(|l| l.style.bg == Some(code_bg)
617                && l.spans
618                    .iter()
619                    .map(|s| s.content.as_ref())
620                    .collect::<String>()
621                    .contains("fn main")),
622            "code body line must carry the code_background marker"
623        );
624    }
625
626    #[test]
627    fn code_block_highlights_keywords() {
628        let lines = md("```rust\nfn main() {}\n```");
629        let kw = Theme::dark().colors.code_keyword.to_color();
630        // "fn" should be styled with the keyword color.
631        let fn_styled_as_keyword = lines.iter().any(|l| {
632            l.spans
633                .iter()
634                .any(|s| s.content.as_ref() == "fn" && s.style.fg == Some(kw))
635        });
636        assert!(
637            fn_styled_as_keyword,
638            "`fn` should be highlighted as a keyword"
639        );
640    }
641
642    #[test]
643    fn code_block_preserves_indentation() {
644        let lines = md("```rust\n    indented();\n```");
645        // The leading 4 spaces must survive (no whitespace collapse).
646        assert!(
647            lines.iter().any(|l| l
648                .spans
649                .iter()
650                .map(|s| s.content.as_ref())
651                .collect::<String>()
652                .starts_with("    indented")),
653            "code indentation must be preserved verbatim"
654        );
655    }
656
657    #[test]
658    fn test_code_block_no_lang() {
659        let lines = md("```\nsome code\n```");
660        assert!(lines_to_text(&lines).contains("some code"));
661    }
662
663    #[test]
664    fn test_inline_code_has_no_padding() {
665        let lines = md("Use `cargo build` to compile");
666        let code_bg = Theme::dark().colors.code_background.to_color();
667        // The inline-code span must be exactly "cargo build" — not the old
668        // " cargo build " with padding spaces baked into the highlight.
669        let tight = lines.iter().any(|l| {
670            l.spans
671                .iter()
672                .any(|s| s.style.bg == Some(code_bg) && s.content.as_ref() == "cargo build")
673        });
674        assert!(
675            tight,
676            "inline code should be tight (no surrounding padding spaces)"
677        );
678    }
679
680    #[test]
681    fn test_unordered_list() {
682        let lines = md("- Item 1\n- Item 2\n- Item 3");
683        let text = lines_to_text(&lines);
684        assert!(text.contains("Item 1"));
685        assert!(text.contains("•"));
686    }
687
688    #[test]
689    fn test_ordered_list_preserves_numbers() {
690        let lines = md("1. First\n2. Second\n3. Third");
691        let text = lines_to_text(&lines);
692        assert!(text.contains("1. First"));
693        assert!(text.contains("2. Second"));
694        assert!(!text.contains("• First"));
695    }
696
697    #[test]
698    fn loose_list_item_body_hangs_under_item_text() {
699        // A 2nd+ paragraph inside a list item must align under the item's text
700        // (hanging indent), not fall back flush to column 0.
701        let lines = md("- **Finding** — verified\n\n  Body paragraph explaining the finding.");
702        let rendered: Vec<String> = lines
703            .iter()
704            .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
705            .collect();
706        assert!(
707            rendered
708                .iter()
709                .any(|l| l.starts_with("  • ") && l.contains("Finding")),
710            "marker line should carry the bullet + indent"
711        );
712        let body = rendered
713            .iter()
714            .find(|l| l.contains("Body paragraph"))
715            .expect("body line present");
716        // 4 cols of hanging indent: 2 (depth) + 2 ("• " marker width), aligning
717        // the body under the item text rather than flush at column 0.
718        assert_eq!(
719            body, "    Body paragraph explaining the finding.",
720            "continuation paragraph must hang-indent under the item text"
721        );
722    }
723
724    #[test]
725    fn test_nested_list() {
726        let lines = md("- Outer\n  - Inner");
727        let text = lines_to_text(&lines);
728        assert!(text.contains("Outer"));
729        assert!(text.contains("Inner"));
730    }
731
732    #[test]
733    fn test_bold_and_italic() {
734        let lines = md("**bold** and *italic*");
735        let text = lines_to_text(&lines);
736        assert!(text.contains("bold"));
737        assert!(text.contains("italic"));
738    }
739
740    #[test]
741    fn test_link_shows_text_and_url() {
742        let lines = md("[click here](https://example.com)");
743        let text = lines_to_text(&lines);
744        assert!(text.contains("click here"));
745        // The destination is appended (dimmed) so the user can see where it goes.
746        assert!(text.contains("https://example.com"));
747    }
748
749    #[test]
750    fn test_autolink_does_not_duplicate_url() {
751        // When the visible text already is the URL, don't append it twice.
752        let lines = md("<https://example.com>");
753        let text = lines_to_text(&lines);
754        assert_eq!(text.matches("https://example.com").count(), 1);
755    }
756
757    #[test]
758    fn test_blockquote() {
759        let lines = md("> Quoted text");
760        let text = lines_to_text(&lines);
761        assert!(text.contains("Quoted text"));
762        assert!(text.contains("│"));
763    }
764
765    #[test]
766    fn test_horizontal_rule() {
767        let lines = md("above\n\n---\n\nbelow");
768        let text = lines_to_text(&lines);
769        assert!(text.contains("above"));
770        assert!(text.contains("below"));
771        // The rule renders as a run of box-drawing dashes.
772        assert!(text.contains("───"), "thematic break should render a rule");
773    }
774
775    #[test]
776    fn test_table() {
777        let lines = md("| Header1 | Header2 |\n|---------|--------|\n| Cell1   | Cell2  |");
778        let text = lines_to_text(&lines);
779        assert!(text.contains("Header1"));
780        assert!(text.contains("Cell1"));
781        assert!(text.contains("|"));
782    }
783
784    #[test]
785    fn test_strikethrough() {
786        let lines = md("~~deleted~~");
787        assert!(lines_to_text(&lines).contains("deleted"));
788    }
789
790    #[test]
791    fn test_empty_input() {
792        assert!(md("").is_empty());
793    }
794
795    #[test]
796    fn test_multiple_paragraphs() {
797        let lines = md("Paragraph 1\n\nParagraph 2");
798        let text = lines_to_text(&lines);
799        assert!(text.contains("Paragraph 1"));
800        assert!(text.contains("Paragraph 2"));
801    }
802
803    #[test]
804    fn highlight_code_line_marks_strings_and_comments() {
805        let theme = Theme::dark();
806        let spans = highlight_code_line("let s = \"hi\"; // note", &["//"], &theme);
807        let str_color = theme.colors.code_string.to_color();
808        let com_color = theme.colors.code_comment.to_color();
809        assert!(
810            spans
811                .iter()
812                .any(|s| s.content.contains("\"hi\"") && s.style.fg == Some(str_color)),
813            "string literal must use the string color"
814        );
815        assert!(
816            spans
817                .iter()
818                .any(|s| s.content.contains("// note") && s.style.fg == Some(com_color)),
819            "trailing comment must use the comment color"
820        );
821    }
822
823    /// Tables with CJK cells align because column widths are display-cell
824    /// based, not byte based.
825    #[test]
826    fn table_column_widths_use_display_cells() {
827        let lines = md("| Name | Score |\n|------|-------|\n| 你好 | 100   |\n| ab   | 50    |");
828        let mut cjk_row_width = 0usize;
829        let mut ascii_row_width = 0usize;
830        for line in &lines {
831            let rendered: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
832            if rendered.contains("你好") {
833                cjk_row_width = rendered.width();
834            } else if rendered.contains("ab") && rendered.contains("|") {
835                ascii_row_width = rendered.width();
836            }
837        }
838        assert!(cjk_row_width > 0, "did not find the CJK body row");
839        assert!(ascii_row_width > 0, "did not find the ASCII body row");
840        assert_eq!(
841            cjk_row_width, ascii_row_width,
842            "CJK and ASCII rows must have equal display width to align"
843        );
844    }
845}