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