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