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::{UnicodeWidthChar, UnicodeWidthStr};
5
6use crate::render::theme::Theme;
7
8/// A parsed markdown line plus whether it is **preformatted** — i.e. must NOT be
9/// word-wrapped by the chat renderer (which collapses runs of whitespace). Code
10/// blocks and tables are preformatted: their exact spacing carries meaning
11/// (indentation, column alignment). Everything else word-wraps normally.
12#[derive(Debug, Clone)]
13pub struct MarkdownLine {
14    pub line: Line<'static>,
15    pub preformatted: bool,
16}
17
18#[derive(Debug, Clone)]
19struct ListState {
20    next_number: Option<u64>,
21    /// Leading whitespace that aligns a continuation block (a 2nd+ paragraph in
22    /// a loose list item) under the current item's text — set when the item's
23    /// marker is emitted.
24    cont_indent: String,
25}
26
27/// Style the list markers ("• ", "1. ") are emitted with. Shared with
28/// [`line_hanging_indent`] so the indent logic recognizes a marker span without
29/// the two definitions drifting apart.
30fn list_marker_style(theme: &Theme) -> Style {
31    Style::new().fg(theme.colors.text_secondary.to_color())
32}
33
34/// Hanging indent (display cells) for hard-wrapping `line`: the column where the
35/// line's content begins, so a wrapped list item's continuation lines align
36/// under its text (after the marker) instead of snapping back to the flat
37/// message gutter. Counts leading whitespace plus a leading list marker; returns
38/// 0 for ordinary paragraphs and headings.
39pub fn line_hanging_indent(line: &Line, theme: &Theme) -> usize {
40    let marker = list_marker_style(theme);
41    let mut indent = 0usize;
42    for span in &line.spans {
43        let text = span.content.as_ref();
44        let trimmed = text.trim_start_matches(' ');
45        if trimmed.is_empty() {
46            indent += text.width(); // a blank span is part of the leading indent
47            continue;
48        }
49        // First span with real content: count its own leading spaces, and include
50        // the marker glyph itself when this span is the list marker.
51        indent += text.width() - trimmed.width();
52        if span.style == marker {
53            indent += trimmed.width();
54        }
55        break;
56    }
57    indent
58}
59
60/// Parse markdown into theme-styled lines, each flagged [`MarkdownLine::preformatted`]
61/// when it must not be word-wrapped — code blocks and tables, whose exact spacing
62/// carries meaning (indentation, column alignment). `width` is the available
63/// content width in display cells; tables are sized and wrapped to fit it.
64///
65/// Code-block lines also keep the theme's `code_background` on their base style
66/// (the gray panel look). Inline code carries the background on the *span*, not
67/// the line, so prose that merely contains `code` still word-wraps normally.
68pub fn parse_markdown(input: &str, theme: &Theme, width: usize) -> Vec<MarkdownLine> {
69    let mut options = Options::empty();
70    options.insert(Options::ENABLE_STRIKETHROUGH);
71    options.insert(Options::ENABLE_TABLES);
72
73    // Resolve the theme palette once.
74    let c = &theme.colors;
75    let code_bg = c.code_background.to_color();
76    let code_fg = c.code_foreground.to_color();
77    let heading1 = Style::new().fg(c.header.to_color()).bold();
78    let heading2 = Style::new().fg(c.info.to_color()).bold();
79    let heading3 = Style::new().fg(c.success.to_color()).bold();
80    let heading_other = Style::new().fg(c.warning.to_color()).bold();
81    let link_style = Style::new()
82        .fg(c.info.to_color())
83        .add_modifier(Modifier::UNDERLINED);
84    let marker_style = list_marker_style(theme);
85    let rule_style = Style::new().fg(c.text_disabled.to_color());
86    let quote_bar_style = Style::new().fg(c.text_disabled.to_color());
87    let quote_text_style = Style::new()
88        .fg(c.text_secondary.to_color())
89        .add_modifier(Modifier::ITALIC);
90
91    let parser = Parser::new_ext(input, options);
92    let mut lines: Vec<Line<'static>> = Vec::new();
93    let mut current_line_spans: Vec<Span<'static>> = Vec::new();
94    let mut style_stack = vec![Style::default()];
95    let mut in_code_block = false;
96    let mut code_block_content = String::new();
97    let mut code_block_lang = String::new();
98    let mut current_link_url: Option<String> = None;
99    let mut list_stack: Vec<ListState> = Vec::new();
100
101    // Table state
102    let mut in_table = false;
103    let mut table_rows: Vec<Vec<String>> = Vec::new();
104    let mut current_row: Vec<String> = Vec::new();
105    let mut current_cell = String::new();
106    let mut table_header_len: usize = 0;
107    // Indices into `lines` produced by `render_table` — these are preformatted
108    // (column-aligned) and must not be word-wrapped by the chat renderer.
109    let mut table_line_indices: std::collections::HashSet<usize> = std::collections::HashSet::new();
110
111    for event in parser {
112        match event {
113            Event::Start(tag) => {
114                let new_style = match tag {
115                    Tag::Heading { level, .. } => {
116                        if !current_line_spans.is_empty() {
117                            lines.push(Line::from(std::mem::take(&mut current_line_spans)));
118                        }
119                        // Blank line before heading (except the first thing).
120                        if !lines.is_empty() {
121                            lines.push(Line::from(""));
122                        }
123                        match level {
124                            HeadingLevel::H1 => heading1,
125                            HeadingLevel::H2 => heading2,
126                            HeadingLevel::H3 => heading3,
127                            _ => heading_other,
128                        }
129                    },
130                    Tag::Emphasis => style_stack.last().copied().unwrap_or_default().italic(),
131                    Tag::Strong => style_stack.last().copied().unwrap_or_default().bold(),
132                    Tag::Strikethrough => style_stack
133                        .last()
134                        .copied()
135                        .unwrap_or_default()
136                        .crossed_out(),
137                    Tag::CodeBlock(kind) => {
138                        in_code_block = true;
139                        code_block_content.clear();
140                        if !current_line_spans.is_empty() {
141                            lines.push(Line::from(std::mem::take(&mut current_line_spans)));
142                        }
143                        code_block_lang = match kind {
144                            CodeBlockKind::Fenced(lang) => lang.to_string(),
145                            CodeBlockKind::Indented => String::new(),
146                        };
147                        if !code_block_lang.is_empty() {
148                            lines.push(Line::from(Span::styled(
149                                code_block_lang.clone(),
150                                Style::new()
151                                    .fg(c.text_disabled.to_color())
152                                    .add_modifier(Modifier::ITALIC),
153                            )));
154                        }
155                        Style::default().fg(code_fg)
156                    },
157                    Tag::List(start) => {
158                        list_stack.push(ListState {
159                            next_number: start,
160                            cont_indent: String::new(),
161                        });
162                        if !current_line_spans.is_empty() {
163                            lines.push(Line::from(std::mem::take(&mut current_line_spans)));
164                        }
165                        style_stack.last().copied().unwrap_or_default()
166                    },
167                    Tag::Item => {
168                        let indent = "  ".repeat(list_stack.len());
169                        let marker = if let Some(state) = list_stack.last_mut() {
170                            if let Some(current) = state.next_number {
171                                state.next_number = Some(current + 1);
172                                format!("{}. ", current)
173                            } else {
174                                "• ".to_string()
175                            }
176                        } else {
177                            "• ".to_string()
178                        };
179                        // Body paragraphs of this item hang-indent to align under
180                        // the text that follows the marker.
181                        let cont_indent =
182                            format!("{}{}", indent, " ".repeat(marker.as_str().width()));
183                        if let Some(state) = list_stack.last_mut() {
184                            state.cont_indent = cont_indent;
185                        }
186                        current_line_spans.push(Span::raw(indent));
187                        current_line_spans.push(Span::styled(marker, marker_style));
188                        style_stack.last().copied().unwrap_or_default()
189                    },
190                    Tag::Paragraph => {
191                        // First paragraph of an item still carries the marker
192                        // spans (non-empty); a continuation paragraph starts
193                        // empty, so re-indent it to align under the item text.
194                        if current_line_spans.is_empty()
195                            && let Some(state) = list_stack.last()
196                            && !state.cont_indent.is_empty()
197                        {
198                            current_line_spans.push(Span::raw(state.cont_indent.clone()));
199                        }
200                        style_stack.last().copied().unwrap_or_default()
201                    },
202                    Tag::Table(_alignments) => {
203                        in_table = true;
204                        table_rows.clear();
205                        table_header_len = 0;
206                        if !current_line_spans.is_empty() {
207                            lines.push(Line::from(std::mem::take(&mut current_line_spans)));
208                        }
209                        style_stack.last().copied().unwrap_or_default()
210                    },
211                    Tag::TableHead | Tag::TableRow => {
212                        current_row.clear();
213                        style_stack.last().copied().unwrap_or_default()
214                    },
215                    Tag::TableCell => {
216                        current_cell.clear();
217                        style_stack.last().copied().unwrap_or_default()
218                    },
219                    Tag::Link { dest_url, .. } => {
220                        // Render the link text underlined in the accent color;
221                        // the destination URL is appended dimmed on the End tag
222                        // (terminals can't follow it without OSC-8).
223                        current_link_url = Some(dest_url.to_string());
224                        link_style
225                    },
226                    Tag::BlockQuote(_) => {
227                        if !current_line_spans.is_empty() {
228                            lines.push(Line::from(std::mem::take(&mut current_line_spans)));
229                        }
230                        current_line_spans.push(Span::styled("│ ", quote_bar_style));
231                        quote_text_style
232                    },
233                    _ => style_stack.last().copied().unwrap_or_default(),
234                };
235                style_stack.push(new_style);
236            },
237            Event::End(tag) => {
238                style_stack.pop();
239                match tag {
240                    // Every block-level close flushes the pending inline spans
241                    // as one finished line.
242                    TagEnd::Heading(_)
243                    | TagEnd::Paragraph
244                    | TagEnd::Item
245                    | TagEnd::BlockQuote(_)
246                        if !current_line_spans.is_empty() =>
247                    {
248                        lines.push(Line::from(std::mem::take(&mut current_line_spans)));
249                    },
250                    TagEnd::CodeBlock => {
251                        in_code_block = false;
252                        let prefixes = line_comment_prefixes(&code_block_lang);
253                        let base = Style::default().fg(code_fg).bg(code_bg);
254                        for line_text in code_block_content.lines() {
255                            let spans = highlight_code_line(line_text, prefixes, theme);
256                            // Mark the LINE base style with the code bg so the
257                            // chat renderer treats it as pre-formatted.
258                            lines.push(Line::from(spans).style(base));
259                        }
260                        code_block_content.clear();
261                        code_block_lang.clear();
262                    },
263                    TagEnd::List(_) => {
264                        let _ = list_stack.pop();
265                        if list_stack.is_empty() {
266                            lines.push(Line::from(""));
267                        }
268                    },
269                    TagEnd::TableCell => {
270                        current_row.push(std::mem::take(&mut current_cell));
271                    },
272                    TagEnd::TableHead => {
273                        table_header_len = current_row.len();
274                        table_rows.push(std::mem::take(&mut current_row));
275                    },
276                    TagEnd::TableRow => {
277                        table_rows.push(std::mem::take(&mut current_row));
278                    },
279                    TagEnd::Table => {
280                        in_table = false;
281                        let from = lines.len();
282                        render_table(&mut lines, &table_rows, table_header_len, theme, width);
283                        table_line_indices.extend(from..lines.len());
284                        table_rows.clear();
285                    },
286                    TagEnd::Link => {
287                        // Append the destination as dimmed " (url)" unless it's
288                        // identical to the visible text (autolinks) or empty.
289                        if let Some(url) = current_link_url.take() {
290                            let text: String = current_line_spans
291                                .iter()
292                                .map(|s| s.content.as_ref())
293                                .collect();
294                            if !url.is_empty() && !text.ends_with(&url) {
295                                current_line_spans.push(Span::styled(
296                                    format!(" ({})", url),
297                                    Style::new().fg(c.text_disabled.to_color()),
298                                ));
299                            }
300                        }
301                    },
302                    _ => {},
303                }
304            },
305            Event::Text(text) => {
306                if in_code_block {
307                    code_block_content.push_str(&text);
308                } else if in_table {
309                    current_cell.push_str(&text);
310                } else {
311                    let style = style_stack.last().copied().unwrap_or_default();
312                    current_line_spans.push(Span::styled(text.to_string(), style));
313                }
314            },
315            Event::Code(code) => {
316                if in_table {
317                    current_cell.push_str(&code);
318                } else {
319                    // Inline code: tight (no padding spaces), code colors. The
320                    // background lives on the SPAN only — prose lines with
321                    // inline code still word-wrap normally.
322                    let style = Style::default().fg(code_fg).bg(code_bg);
323                    current_line_spans.push(Span::styled(code.to_string(), style));
324                }
325            },
326            Event::Rule => {
327                if !current_line_spans.is_empty() {
328                    lines.push(Line::from(std::mem::take(&mut current_line_spans)));
329                }
330                lines.push(Line::from(Span::styled("─".repeat(40), rule_style)));
331            },
332            Event::SoftBreak | Event::HardBreak if !current_line_spans.is_empty() => {
333                lines.push(Line::from(std::mem::take(&mut current_line_spans)));
334            },
335            _ => {},
336        }
337    }
338
339    if !current_line_spans.is_empty() {
340        lines.push(Line::from(current_line_spans));
341    }
342
343    // A line is preformatted (no word-wrap) if it's a code-block line (tagged with
344    // the code background on its base style) or a table line (column-aligned).
345    lines
346        .into_iter()
347        .enumerate()
348        .map(|(i, line)| MarkdownLine {
349            preformatted: line.style.bg == Some(code_bg) || table_line_indices.contains(&i),
350            line,
351        })
352        .collect()
353}
354
355/// Render the accumulated table rows into aligned, themed lines that fit `width`
356/// display cells. Column widths come from content (CJK-safe, min 3); if the
357/// natural table is wider than `width`, the widest columns are shrunk and long
358/// cells are word-wrapped within their column — so nothing is lost and no row
359/// overflows the viewport.
360fn render_table(
361    lines: &mut Vec<Line<'static>>,
362    table_rows: &[Vec<String>],
363    table_header_len: usize,
364    theme: &Theme,
365    width: usize,
366) {
367    let c = &theme.colors;
368    let num_cols = table_rows.iter().map(|r| r.len()).max().unwrap_or(0);
369    if num_cols == 0 {
370        return;
371    }
372
373    // Natural column widths in DISPLAY CELLS (CJK-safe), min 3.
374    let mut col_widths = vec![0usize; num_cols];
375    for row in table_rows {
376        for (i, cell) in row.iter().enumerate() {
377            if i < num_cols {
378                col_widths[i] = col_widths[i].max(cell.width());
379            }
380        }
381    }
382    for w in &mut col_widths {
383        *w = (*w).max(3);
384    }
385
386    // Borders/padding cost: leading "| " (2) + " | " (3) per column. If the table
387    // is wider than the viewport, shrink the widest columns until it fits; cell
388    // text is then wrapped within the budgeted width. Columns shrink all the way
389    // to a 1-cell minimum on a narrow terminal (no `num_cols * 3` budget floor),
390    // so a many-column table fits instead of overflowing and clipping at the edge.
391    // (In the extreme where the per-column border overhead alone exceeds the
392    // width — more columns than the terminal has cells — nothing fits in-budget
393    // and the terminal clips the row; that's unavoidable without dropping columns.)
394    let overhead = 2 + 3 * num_cols;
395    if col_widths.iter().sum::<usize>() + overhead > width {
396        let budget = width.saturating_sub(overhead);
397        let mut total: usize = col_widths.iter().sum();
398        while total > budget {
399            let widest = (0..num_cols)
400                .filter(|&i| col_widths[i] > 1)
401                .max_by_key(|&i| col_widths[i]);
402            match widest {
403                Some(i) => {
404                    col_widths[i] -= 1;
405                    total -= 1;
406                },
407                None => break, // every column already at the 1-cell floor
408            }
409        }
410    }
411
412    let border_style = Style::default().fg(c.text_disabled.to_color());
413    let header_style = Style::default().fg(c.header.to_color()).bold();
414    let cell_style = Style::default().fg(c.text_primary.to_color());
415
416    for (row_idx, row) in table_rows.iter().enumerate() {
417        let style = if row_idx == 0 && table_header_len > 0 {
418            header_style
419        } else {
420            cell_style
421        };
422        // Wrap each cell to its column width; the row is as tall as its tallest cell.
423        let wrapped: Vec<Vec<String>> = (0..num_cols)
424            .map(|ci| {
425                wrap_cell(
426                    row.get(ci).map(String::as_str).unwrap_or(""),
427                    col_widths[ci],
428                )
429            })
430            .collect();
431        let row_height = wrapped.iter().map(Vec::len).max().unwrap_or(1).max(1);
432
433        for li in 0..row_height {
434            let mut spans = vec![Span::styled("| ", border_style)];
435            for ci in 0..num_cols {
436                let w = col_widths[ci];
437                let cell_line = wrapped[ci].get(li).map(String::as_str).unwrap_or("");
438                let padding = w.saturating_sub(cell_line.width());
439                let padded = format!("{}{}", cell_line, " ".repeat(padding));
440                spans.push(Span::styled(padded, style));
441                spans.push(Span::styled(" | ", border_style));
442            }
443            lines.push(Line::from(spans));
444        }
445
446        if row_idx == 0 && table_header_len > 0 {
447            let mut sep_spans = vec![Span::styled("|-", border_style)];
448            for (col, &w) in col_widths.iter().enumerate() {
449                sep_spans.push(Span::styled("-".repeat(w), border_style));
450                // A data row ends `" | "` — pipe then a SPACE, which is
451                // invisible. Ending the separator `"-|-"` put a dash where that
452                // space is, so every table hung one stray dash past its right
453                // edge. The last column closes on the pipe instead.
454                let closer = if col + 1 == num_cols { "-|" } else { "-|-" };
455                sep_spans.push(Span::styled(closer, border_style));
456            }
457            lines.push(Line::from(sep_spans));
458        }
459    }
460
461    lines.push(Line::from(""));
462}
463
464/// Word-wrap `text` to `width` display cells, hard-breaking any word longer than
465/// the column. Always returns at least one (possibly empty) line.
466fn wrap_cell(text: &str, width: usize) -> Vec<String> {
467    if width == 0 {
468        return vec![String::new()];
469    }
470    let mut lines: Vec<String> = Vec::new();
471    let mut cur = String::new();
472    let mut cur_w = 0usize;
473    for word in text.split_whitespace() {
474        let ww = word.width();
475        if ww > width {
476            // A word too wide for the column: flush the current line, then
477            // hard-break the word; the final chunk stays open so the next word
478            // can continue after it.
479            if !cur.is_empty() {
480                lines.push(std::mem::take(&mut cur));
481                cur_w = 0;
482            }
483            let chunks = chunk_by_width(word, width);
484            let n = chunks.len();
485            for (k, chunk) in chunks.into_iter().enumerate() {
486                if k + 1 < n {
487                    lines.push(chunk);
488                } else {
489                    cur_w = chunk.width();
490                    cur = chunk;
491                }
492            }
493            continue;
494        }
495        let sep = usize::from(!cur.is_empty());
496        if cur_w + sep + ww > width {
497            lines.push(std::mem::take(&mut cur));
498            cur.push_str(word);
499            cur_w = ww;
500        } else {
501            if sep == 1 {
502                cur.push(' ');
503            }
504            cur.push_str(word);
505            cur_w += sep + ww;
506        }
507    }
508    if !cur.is_empty() || lines.is_empty() {
509        lines.push(cur);
510    }
511    lines
512}
513
514/// Split `s` into chunks each at most `width` display cells, never splitting a
515/// character. Used to hard-break a word longer than its column.
516fn chunk_by_width(s: &str, width: usize) -> Vec<String> {
517    let mut chunks: Vec<String> = Vec::new();
518    let mut cur = String::new();
519    let mut cur_w = 0usize;
520    for ch in s.chars() {
521        let cw = ch.width().unwrap_or(0);
522        if cur_w + cw > width && !cur.is_empty() {
523            chunks.push(std::mem::take(&mut cur));
524            cur_w = 0;
525        }
526        cur.push(ch);
527        cur_w += cw;
528    }
529    if !cur.is_empty() {
530        chunks.push(cur);
531    }
532    if chunks.is_empty() {
533        chunks.push(String::new());
534    }
535    chunks
536}
537
538/// Line-comment prefix(es) for a fenced-code language hint. Falls back to a
539/// permissive set so unknown languages still get comment coloring.
540fn line_comment_prefixes(lang: &str) -> &'static [&'static str] {
541    match lang.trim().to_ascii_lowercase().as_str() {
542        "rust" | "rs" | "c" | "cpp" | "c++" | "h" | "hpp" | "java" | "js" | "javascript" | "ts"
543        | "typescript" | "tsx" | "jsx" | "go" | "golang" | "swift" | "kotlin" | "kt" | "scala"
544        | "cs" | "csharp" | "php" | "dart" | "zig" | "rust,no_run" => &["//"],
545        "python" | "py" | "ruby" | "rb" | "sh" | "bash" | "zsh" | "shell" | "console" | "yaml"
546        | "yml" | "toml" | "ini" | "perl" | "pl" | "r" | "elixir" | "ex" | "makefile"
547        | "dockerfile" | "nix" => &["#"],
548        "sql" | "lua" | "haskell" | "hs" | "ada" => &["--"],
549        "lisp" | "clojure" | "clj" | "scheme" | "el" => &[";"],
550        _ => &["//", "#"],
551    }
552}
553
554/// Cross-language keyword set for the lightweight in-house highlighter.
555fn is_keyword(w: &str) -> bool {
556    matches!(
557        w,
558        "fn" | "let"
559            | "const"
560            | "mut"
561            | "pub"
562            | "struct"
563            | "enum"
564            | "impl"
565            | "trait"
566            | "use"
567            | "mod"
568            | "match"
569            | "if"
570            | "else"
571            | "for"
572            | "while"
573            | "loop"
574            | "return"
575            | "break"
576            | "continue"
577            | "async"
578            | "await"
579            | "move"
580            | "ref"
581            | "where"
582            | "type"
583            | "dyn"
584            | "as"
585            | "in"
586            | "static"
587            | "unsafe"
588            | "extern"
589            | "crate"
590            | "self"
591            | "Self"
592            | "super"
593            | "function"
594            | "var"
595            | "def"
596            | "class"
597            | "import"
598            | "from"
599            | "export"
600            | "default"
601            | "public"
602            | "private"
603            | "protected"
604            | "void"
605            | "int"
606            | "long"
607            | "float"
608            | "double"
609            | "bool"
610            | "boolean"
611            | "char"
612            | "string"
613            | "true"
614            | "false"
615            | "null"
616            | "nil"
617            | "None"
618            | "True"
619            | "False"
620            | "this"
621            | "new"
622            | "try"
623            | "catch"
624            | "finally"
625            | "throw"
626            | "throws"
627            | "package"
628            | "interface"
629            | "extends"
630            | "implements"
631            | "do"
632            | "then"
633            | "elif"
634            | "lambda"
635            | "yield"
636            | "with"
637            | "and"
638            | "or"
639            | "not"
640            | "is"
641            | "end"
642            | "begin"
643            | "val"
644            | "func"
645            | "defer"
646            | "select"
647            | "chan"
648            | "range"
649            | "switch"
650            | "case"
651    )
652}
653
654/// Tokenize one code line into styled spans (all sharing the code background)
655/// using a small, language-agnostic lexer: line comments, quoted strings, and
656/// a cross-language keyword set. Everything else is the default code color.
657fn highlight_code_line(text: &str, comment_prefixes: &[&str], theme: &Theme) -> Vec<Span<'static>> {
658    let c = &theme.colors;
659    let bg = c.code_background.to_color();
660    let base = Style::default().fg(c.code_foreground.to_color()).bg(bg);
661    let kw_style = Style::default().fg(c.code_keyword.to_color()).bg(bg);
662    let str_style = Style::default().fg(c.code_string.to_color()).bg(bg);
663    let com_style = Style::default().fg(c.code_comment.to_color()).bg(bg);
664
665    let mut spans: Vec<Span<'static>> = Vec::new();
666    let mut pending = String::new();
667    let flush = |spans: &mut Vec<Span<'static>>, pending: &mut String| {
668        if !pending.is_empty() {
669            spans.push(Span::styled(std::mem::take(pending), base));
670        }
671    };
672
673    let mut it = text.char_indices().peekable();
674    while let Some(&(byte_idx, ch)) = it.peek() {
675        // Line comment → rest of the line.
676        if comment_prefixes
677            .iter()
678            .any(|p| text[byte_idx..].starts_with(p))
679        {
680            flush(&mut spans, &mut pending);
681            spans.push(Span::styled(text[byte_idx..].to_string(), com_style));
682            break;
683        }
684        // String literal.
685        if ch == '"' || ch == '\'' || ch == '`' {
686            flush(&mut spans, &mut pending);
687            let quote = ch;
688            let start = byte_idx;
689            it.next(); // opening quote
690            let mut end = text.len();
691            let mut escaped = false;
692            while let Some(&(bi, ci)) = it.peek() {
693                it.next();
694                end = bi + ci.len_utf8();
695                if escaped {
696                    escaped = false;
697                } else if ci == '\\' {
698                    escaped = true;
699                } else if ci == quote {
700                    break;
701                }
702            }
703            spans.push(Span::styled(text[start..end].to_string(), str_style));
704            continue;
705        }
706        // Identifier / keyword.
707        if ch.is_alphanumeric() || ch == '_' {
708            let start = byte_idx;
709            let mut end = byte_idx + ch.len_utf8();
710            it.next();
711            while let Some(&(bi, ci)) = it.peek() {
712                if ci.is_alphanumeric() || ci == '_' {
713                    end = bi + ci.len_utf8();
714                    it.next();
715                } else {
716                    break;
717                }
718            }
719            let word = &text[start..end];
720            if is_keyword(word) {
721                flush(&mut spans, &mut pending);
722                spans.push(Span::styled(word.to_string(), kw_style));
723            } else {
724                pending.push_str(word);
725            }
726            continue;
727        }
728        // Anything else (whitespace, punctuation) → default run.
729        pending.push(ch);
730        it.next();
731    }
732    flush(&mut spans, &mut pending);
733    if spans.is_empty() {
734        spans.push(Span::styled(String::new(), base));
735    }
736    spans
737}
738
739#[cfg(test)]
740mod tests {
741    use super::*;
742
743    /// Parse with the dark theme at a typical width, returning the bare lines
744    /// (most tests here assert on text/structure, not the preformatted flag).
745    fn md(input: &str) -> Vec<Line<'static>> {
746        parse_markdown(input, &Theme::dark(), 80)
747            .into_iter()
748            .map(|ml| ml.line)
749            .collect()
750    }
751
752    /// Flatten all spans in all lines into a single string.
753    fn lines_to_text(lines: &[Line]) -> String {
754        lines
755            .iter()
756            .map(|line| {
757                line.spans
758                    .iter()
759                    .map(|s| s.content.as_ref())
760                    .collect::<String>()
761            })
762            .collect::<Vec<_>>()
763            .join("\n")
764    }
765
766    #[test]
767    fn wide_table_fits_narrow_viewport() {
768        // #136: a 3-column table whose natural width far exceeds a narrow
769        // viewport must shrink to fit, not overflow and clip at the edge.
770        let width = 16;
771        let lines = parse_markdown(
772            "| aaaa | bbbb | cccc |\n|---|---|---|\n| aaaaaaaa | bbbbbbbb | cccccccc |\n",
773            &Theme::dark(),
774            width,
775        );
776        for ml in &lines {
777            let w: usize = ml.line.spans.iter().map(|s| s.content.width()).sum();
778            assert!(w <= width, "table row width {w} exceeds viewport {width}");
779        }
780    }
781
782    #[test]
783    fn test_plain_text() {
784        let lines = md("Hello, world!");
785        assert!(!lines.is_empty());
786        assert!(lines_to_text(&lines).contains("Hello, world!"));
787    }
788
789    #[test]
790    fn test_heading_levels() {
791        let lines = md("# H1\n## H2\n### H3");
792        let text = lines_to_text(&lines);
793        assert!(text.contains("H1"));
794        assert!(text.contains("H2"));
795        assert!(text.contains("H3"));
796        assert!(lines.len() >= 3);
797    }
798
799    #[test]
800    fn line_hanging_indent_aligns_under_list_marker() {
801        let theme = Theme::dark();
802        fn find<'a>(lines: &'a [Line<'static>], needle: &str) -> &'a Line<'static> {
803            lines
804                .iter()
805                .find(|l| lines_to_text(std::slice::from_ref(l)).contains(needle))
806                .expect("line present")
807        }
808
809        // Bulleted item: continuations hang under the text after "• "
810        // (2-cell nesting indent + 2-cell marker).
811        let bullet = md("- Alpha item");
812        assert_eq!(
813            line_hanging_indent(find(&bullet, "Alpha"), &theme),
814            4,
815            "bullet: 2 indent + 2 marker"
816        );
817
818        // Numbered item: the marker "1. " is 3 cells wide.
819        let numbered = md("1. First item");
820        assert_eq!(
821            line_hanging_indent(find(&numbered, "First"), &theme),
822            5,
823            "numbered: 2 indent + 3 marker"
824        );
825
826        // Ordinary paragraph: no marker, no leading indent, so no hang.
827        let para = md("Just a sentence.");
828        assert_eq!(
829            line_hanging_indent(find(&para, "sentence"), &theme),
830            0,
831            "paragraph: flush to the gutter"
832        );
833    }
834
835    #[test]
836    fn test_code_block() {
837        let lines = md("```rust\nfn main() {}\n```");
838        let text = lines_to_text(&lines);
839        assert!(text.contains("fn main() {}"));
840        assert!(text.contains("rust"));
841    }
842
843    #[test]
844    fn code_block_lines_tagged_with_code_background() {
845        let lines = md("```rust\nfn main() {}\n```");
846        let code_bg = Theme::dark().colors.code_background.to_color();
847        // The line carrying the code body must be flagged via its base style
848        // background (this is what the chat renderer keys off to skip wrap).
849        assert!(
850            lines.iter().any(|l| l.style.bg == Some(code_bg)
851                && l.spans
852                    .iter()
853                    .map(|s| s.content.as_ref())
854                    .collect::<String>()
855                    .contains("fn main")),
856            "code body line must carry the code_background marker"
857        );
858    }
859
860    #[test]
861    fn code_block_highlights_keywords() {
862        let lines = md("```rust\nfn main() {}\n```");
863        let kw = Theme::dark().colors.code_keyword.to_color();
864        // "fn" should be styled with the keyword color.
865        let fn_styled_as_keyword = lines.iter().any(|l| {
866            l.spans
867                .iter()
868                .any(|s| s.content.as_ref() == "fn" && s.style.fg == Some(kw))
869        });
870        assert!(
871            fn_styled_as_keyword,
872            "`fn` should be highlighted as a keyword"
873        );
874    }
875
876    #[test]
877    fn code_block_preserves_indentation() {
878        let lines = md("```rust\n    indented();\n```");
879        // The leading 4 spaces must survive (no whitespace collapse).
880        assert!(
881            lines.iter().any(|l| l
882                .spans
883                .iter()
884                .map(|s| s.content.as_ref())
885                .collect::<String>()
886                .starts_with("    indented")),
887            "code indentation must be preserved verbatim"
888        );
889    }
890
891    #[test]
892    fn test_code_block_no_lang() {
893        let lines = md("```\nsome code\n```");
894        assert!(lines_to_text(&lines).contains("some code"));
895    }
896
897    #[test]
898    fn test_inline_code_has_no_padding() {
899        let lines = md("Use `cargo build` to compile");
900        let code_bg = Theme::dark().colors.code_background.to_color();
901        // The inline-code span must be exactly "cargo build" — not the old
902        // " cargo build " with padding spaces baked into the highlight.
903        let tight = lines.iter().any(|l| {
904            l.spans
905                .iter()
906                .any(|s| s.style.bg == Some(code_bg) && s.content.as_ref() == "cargo build")
907        });
908        assert!(
909            tight,
910            "inline code should be tight (no surrounding padding spaces)"
911        );
912    }
913
914    #[test]
915    fn test_unordered_list() {
916        let lines = md("- Item 1\n- Item 2\n- Item 3");
917        let text = lines_to_text(&lines);
918        assert!(text.contains("Item 1"));
919        assert!(text.contains("•"));
920    }
921
922    #[test]
923    fn test_ordered_list_preserves_numbers() {
924        let lines = md("1. First\n2. Second\n3. Third");
925        let text = lines_to_text(&lines);
926        assert!(text.contains("1. First"));
927        assert!(text.contains("2. Second"));
928        assert!(!text.contains("• First"));
929    }
930
931    #[test]
932    fn loose_list_item_body_hangs_under_item_text() {
933        // A 2nd+ paragraph inside a list item must align under the item's text
934        // (hanging indent), not fall back flush to column 0.
935        let lines = md("- **Finding** — verified\n\n  Body paragraph explaining the finding.");
936        let rendered: Vec<String> = lines
937            .iter()
938            .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
939            .collect();
940        assert!(
941            rendered
942                .iter()
943                .any(|l| l.starts_with("  • ") && l.contains("Finding")),
944            "marker line should carry the bullet + indent"
945        );
946        let body = rendered
947            .iter()
948            .find(|l| l.contains("Body paragraph"))
949            .expect("body line present");
950        // 4 cols of hanging indent: 2 (depth) + 2 ("• " marker width), aligning
951        // the body under the item text rather than flush at column 0.
952        assert_eq!(
953            body, "    Body paragraph explaining the finding.",
954            "continuation paragraph must hang-indent under the item text"
955        );
956    }
957
958    #[test]
959    fn test_nested_list() {
960        let lines = md("- Outer\n  - Inner");
961        let text = lines_to_text(&lines);
962        assert!(text.contains("Outer"));
963        assert!(text.contains("Inner"));
964    }
965
966    #[test]
967    fn test_bold_and_italic() {
968        let lines = md("**bold** and *italic*");
969        let text = lines_to_text(&lines);
970        assert!(text.contains("bold"));
971        assert!(text.contains("italic"));
972    }
973
974    #[test]
975    fn test_link_shows_text_and_url() {
976        let lines = md("[click here](https://example.com)");
977        let text = lines_to_text(&lines);
978        assert!(text.contains("click here"));
979        // The destination is appended (dimmed) so the user can see where it goes.
980        assert!(text.contains("https://example.com"));
981    }
982
983    #[test]
984    fn test_autolink_does_not_duplicate_url() {
985        // When the visible text already is the URL, don't append it twice.
986        let lines = md("<https://example.com>");
987        let text = lines_to_text(&lines);
988        assert_eq!(text.matches("https://example.com").count(), 1);
989    }
990
991    #[test]
992    fn test_blockquote() {
993        let lines = md("> Quoted text");
994        let text = lines_to_text(&lines);
995        assert!(text.contains("Quoted text"));
996        assert!(text.contains("│"));
997    }
998
999    #[test]
1000    fn test_horizontal_rule() {
1001        let lines = md("above\n\n---\n\nbelow");
1002        let text = lines_to_text(&lines);
1003        assert!(text.contains("above"));
1004        assert!(text.contains("below"));
1005        // The rule renders as a run of box-drawing dashes.
1006        assert!(text.contains("───"), "thematic break should render a rule");
1007    }
1008
1009    #[test]
1010    fn test_table() {
1011        let lines = md("| Header1 | Header2 |\n|---------|--------|\n| Cell1   | Cell2  |");
1012        let text = lines_to_text(&lines);
1013        assert!(text.contains("Header1"));
1014        assert!(text.contains("Cell1"));
1015        assert!(text.contains("|"));
1016    }
1017
1018    #[test]
1019    fn test_strikethrough() {
1020        let lines = md("~~deleted~~");
1021        assert!(lines_to_text(&lines).contains("deleted"));
1022    }
1023
1024    #[test]
1025    fn test_empty_input() {
1026        assert!(md("").is_empty());
1027    }
1028
1029    #[test]
1030    fn test_multiple_paragraphs() {
1031        let lines = md("Paragraph 1\n\nParagraph 2");
1032        let text = lines_to_text(&lines);
1033        assert!(text.contains("Paragraph 1"));
1034        assert!(text.contains("Paragraph 2"));
1035    }
1036
1037    #[test]
1038    fn highlight_code_line_marks_strings_and_comments() {
1039        let theme = Theme::dark();
1040        let spans = highlight_code_line("let s = \"hi\"; // note", &["//"], &theme);
1041        let str_color = theme.colors.code_string.to_color();
1042        let com_color = theme.colors.code_comment.to_color();
1043        assert!(
1044            spans
1045                .iter()
1046                .any(|s| s.content.contains("\"hi\"") && s.style.fg == Some(str_color)),
1047            "string literal must use the string color"
1048        );
1049        assert!(
1050            spans
1051                .iter()
1052                .any(|s| s.content.contains("// note") && s.style.fg == Some(com_color)),
1053            "trailing comment must use the comment color"
1054        );
1055    }
1056
1057    /// Tables with CJK cells align because column widths are display-cell
1058    /// based, not byte based.
1059    #[test]
1060    fn table_column_widths_use_display_cells() {
1061        let lines = md("| Name | Score |\n|------|-------|\n| 你好 | 100   |\n| ab   | 50    |");
1062        let mut cjk_row_width = 0usize;
1063        let mut ascii_row_width = 0usize;
1064        for line in &lines {
1065            let rendered: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
1066            if rendered.contains("你好") {
1067                cjk_row_width = rendered.width();
1068            } else if rendered.contains("ab") && rendered.contains("|") {
1069                ascii_row_width = rendered.width();
1070            }
1071        }
1072        assert!(cjk_row_width > 0, "did not find the CJK body row");
1073        assert!(ascii_row_width > 0, "did not find the ASCII body row");
1074        assert_eq!(
1075            cjk_row_width, ascii_row_width,
1076            "CJK and ASCII rows must have equal display width to align"
1077        );
1078    }
1079
1080    #[test]
1081    fn table_lines_flagged_preformatted_prose_is_not() {
1082        // Table rows must be flagged preformatted so the chat renderer doesn't
1083        // word-wrap them (which would collapse the column padding); prose must not.
1084        let out = parse_markdown(
1085            "Intro paragraph.\n\n| A | B |\n|---|---|\n| 1 | 2 |",
1086            &Theme::dark(),
1087            80,
1088        );
1089        let para = out
1090            .iter()
1091            .find(|ml| ml.line.spans.iter().any(|s| s.content.contains("Intro")))
1092            .expect("paragraph present");
1093        assert!(!para.preformatted, "prose must word-wrap normally");
1094        let table_rows: Vec<_> = out
1095            .iter()
1096            .filter(|ml| {
1097                ml.line
1098                    .spans
1099                    .first()
1100                    .is_some_and(|s| s.content.starts_with('|'))
1101            })
1102            .collect();
1103        assert!(!table_rows.is_empty(), "table should render rows");
1104        assert!(
1105            table_rows.iter().all(|ml| ml.preformatted),
1106            "every table line must be preformatted"
1107        );
1108    }
1109
1110    #[test]
1111    fn code_lines_flagged_preformatted() {
1112        let out = parse_markdown("```\nlet x = 1;\n```", &Theme::dark(), 80);
1113        assert!(
1114            out.iter()
1115                .filter(|ml| ml.line.spans.iter().any(|s| s.content.contains("let x")))
1116                .all(|ml| ml.preformatted),
1117            "code-block lines must be preformatted"
1118        );
1119    }
1120
1121    #[test]
1122    fn wide_table_wraps_cells_to_fit() {
1123        // A table wider than the viewport wraps cell text within columns rather
1124        // than overflowing. Every rendered table line must fit `width`, and no
1125        // cell content is lost.
1126        let width = 30;
1127        let out = parse_markdown(
1128            "| Item | Detail |\n|------|--------|\n| one | a very long cell that cannot fit on a single line at this width |",
1129            &Theme::dark(),
1130            width,
1131        );
1132        let mut saw_table = false;
1133        for ml in &out {
1134            let rendered: String = ml.line.spans.iter().map(|s| s.content.as_ref()).collect();
1135            if rendered.starts_with('|') {
1136                saw_table = true;
1137                assert!(
1138                    rendered.width() <= width,
1139                    "table line must fit width {width}, got {} for {rendered:?}",
1140                    rendered.width()
1141                );
1142            }
1143        }
1144        assert!(saw_table, "table should have rendered");
1145        // No content lost: every word of the long cell appears across the wraps.
1146        let all: String = out
1147            .iter()
1148            .map(|ml| {
1149                ml.line
1150                    .spans
1151                    .iter()
1152                    .map(|s| s.content.as_ref())
1153                    .collect::<String>()
1154            })
1155            .collect::<Vec<_>>()
1156            .join(" ");
1157        for word in ["very", "long", "cell", "cannot", "single", "width"] {
1158            assert!(all.contains(word), "wrapped table lost the word {word:?}");
1159        }
1160    }
1161}