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