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