Skip to main content

recursive/tui/ui/
markdown.rs

1//! Inline-markdown renderer for assistant messages.
2//!
3//! Handles the constructs that come up most in LLM responses:
4//!
5//! - **bold** (`**...**`)
6//! - *italic* / _italic_ (`*...*`, `_..._`)
7//! - inline `code` (`` `...` ``)
8//! - line-level heading (`# `, `## `, ...)
9//! - fenced code block with syntax highlighting via `syntect`
10//! - simple bullet (`- ` / `* `)
11//! - Markdown table (`|col1|col2|` + `|---|---|`)
12//!
13//! Public entry points:
14//! - [`render_inline`] — single-line inline markdown → `Vec<Span<'static>>`
15//! - [`render_table`]  — slice of table-row lines → `Vec<Line<'static>>`
16
17use std::sync::OnceLock;
18
19use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
20use ratatui::style::{Color, Modifier, Style};
21use ratatui::text::{Line, Span};
22use syntect::easy::HighlightLines;
23use syntect::highlighting::{Style as SyntectStyle, ThemeSet};
24use syntect::parsing::SyntaxSet;
25use syntect::util::LinesWithEndings;
26
27// ── Lazy-loaded syntect state ──────────────────────────────────────────
28// Use OnceLock (stable since 1.70) instead of LazyLock (stable 1.80)
29// to stay within the project's MSRV of 1.75.
30
31fn syntax_set() -> &'static SyntaxSet {
32    static SYNTAX_SET: OnceLock<SyntaxSet> = OnceLock::new();
33    SYNTAX_SET.get_or_init(SyntaxSet::load_defaults_newlines)
34}
35
36fn theme_set() -> &'static ThemeSet {
37    static THEME_SET: OnceLock<ThemeSet> = OnceLock::new();
38    THEME_SET.get_or_init(ThemeSet::load_defaults)
39}
40
41// ── Public types ───────────────────────────────────────────────────────
42
43/// State carried across consecutive lines so fenced code blocks can span
44/// multiple lines.
45#[derive(Default, Clone)]
46pub struct MdState {
47    /// True when we are inside a ` ``` `…` ``` ` fenced code block.
48    pub in_code_block: bool,
49    /// Language tag extracted from the opening fence line (e.g. `"rust"`).
50    /// Empty string when no language was specified.
51    pub code_lang: String,
52}
53
54/// Result of rendering a single line.
55pub struct RenderedLine {
56    pub spans: Vec<Span<'static>>,
57    /// Updated state to feed into the next line.
58    pub state: MdState,
59}
60
61// ── Main entry points ──────────────────────────────────────────────────
62
63/// Render one logical line of markdown-ish text into styled spans.
64///
65/// `default_fg` is the colour for plain-text spans (e.g.
66/// `Color::White` for the assistant body so it stays bright on black).
67pub fn render_inline(line: &str, default_fg: Color, state: MdState) -> RenderedLine {
68    let trimmed = line.trim_start();
69
70    // ── fenced code block fence line ──────────────────────────────────
71    if trimmed.starts_with("```") {
72        if state.in_code_block {
73            // Closing fence
74            return RenderedLine {
75                spans: vec![Span::styled(
76                    line.to_string(),
77                    Style::default().fg(Color::Gray),
78                )],
79                state: MdState::default(),
80            };
81        } else {
82            // Opening fence — extract optional language tag
83            let lang = trimmed.trim_start_matches('`').trim().to_lowercase();
84            return RenderedLine {
85                spans: vec![Span::styled(
86                    line.to_string(),
87                    Style::default().fg(Color::Gray),
88                )],
89                state: MdState {
90                    in_code_block: true,
91                    code_lang: lang,
92                },
93            };
94        }
95    }
96
97    // ── inside fenced code block ───────────────────────────────────────
98    if state.in_code_block {
99        let spans = highlight_code_line(line, &state.code_lang);
100        return RenderedLine { spans, state };
101    }
102
103    // ── heading ────────────────────────────────────────────────────────
104    if let Some(rest) = strip_heading(line) {
105        return RenderedLine {
106            spans: vec![Span::styled(
107                rest.to_string(),
108                Style::default()
109                    .fg(Color::LightCyan)
110                    .add_modifier(Modifier::BOLD),
111            )],
112            state,
113        };
114    }
115
116    // ── bullet list ────────────────────────────────────────────────────
117    if let Some((indent, rest)) = strip_bullet(line) {
118        let mut out: Vec<Span<'static>> = Vec::with_capacity(3);
119        out.push(Span::raw(indent.to_string()));
120        out.push(Span::styled(
121            "• ".to_string(),
122            Style::default().fg(Color::LightYellow),
123        ));
124        out.extend(parse_inline(rest, default_fg));
125        return RenderedLine { spans: out, state };
126    }
127
128    // ── plain inline parse ─────────────────────────────────────────────
129    RenderedLine {
130        spans: parse_inline(line, default_fg),
131        state,
132    }
133}
134
135/// Render a Markdown table (sequence of `|...|` lines, including the
136/// optional `|---|---|` separator row) into a set of styled `Line`s.
137///
138/// `gutter_style` is applied to the leading gutter prefix (e.g. `"│  "`).
139pub fn render_table(rows: &[&str], gutter_prefix: &str, gutter_style: Style) -> Vec<Line<'static>> {
140    let parsed = parse_table_rows(rows);
141    if parsed.is_empty() {
142        return Vec::new();
143    }
144
145    // Determine how many columns we have (max across all rows).
146    let ncols = parsed
147        .iter()
148        .map(|(_, cells)| cells.len())
149        .max()
150        .unwrap_or(0);
151    if ncols == 0 {
152        return Vec::new();
153    }
154
155    // Find which row is the separator row (contains only dashes/colons).
156    let separator_idx = parsed.iter().position(|(is_sep, _)| *is_sep);
157
158    // Compute maximum content width per column.
159    let mut col_widths: Vec<usize> = vec![0; ncols];
160    for (is_sep, cells) in &parsed {
161        if *is_sep {
162            continue;
163        }
164        for (ci, cell) in cells.iter().enumerate().take(ncols) {
165            col_widths[ci] = col_widths[ci].max(cell.len());
166        }
167    }
168    // Minimum width of 1.
169    for w in &mut col_widths {
170        if *w == 0 {
171            *w = 1;
172        }
173    }
174
175    let mut out: Vec<Line<'static>> = Vec::new();
176    let gutter_owned = gutter_prefix.to_string();
177
178    // Build top border.
179    out.push(make_border_line(
180        &col_widths,
181        '┌',
182        '─',
183        '┬',
184        '┐',
185        &gutter_owned,
186        gutter_style,
187    ));
188
189    let data_rows: Vec<(bool, Vec<String>, bool)> = parsed
190        .iter()
191        .enumerate()
192        .filter_map(|(row_idx, (is_sep, cells))| {
193            if *is_sep {
194                None
195            } else {
196                let is_header = separator_idx.is_some_and(|si| row_idx < si);
197                Some((*is_sep, cells.clone(), is_header))
198            }
199        })
200        .collect();
201
202    for (data_idx, (_is_sep, cells, is_header)) in data_rows.iter().enumerate() {
203        // Data row.
204        let mut spans: Vec<Span<'static>> = Vec::new();
205        spans.push(Span::styled(gutter_owned.clone(), gutter_style));
206        spans.push(Span::raw("│"));
207        for (ci, w) in col_widths.iter().enumerate() {
208            let cell_text = cells.get(ci).map(String::as_str).unwrap_or("");
209            let padded = format!(" {:<width$} ", cell_text, width = w);
210            let style = if *is_header {
211                Style::default()
212                    .fg(Color::LightCyan)
213                    .add_modifier(Modifier::BOLD)
214            } else {
215                Style::default().fg(Color::White)
216            };
217            spans.push(Span::styled(padded, style));
218            spans.push(Span::raw("│"));
219        }
220        out.push(Line::from(spans));
221
222        // After header row → print a divider.
223        let is_last_data = data_idx == data_rows.len() - 1;
224        if *is_header && !is_last_data {
225            out.push(make_border_line(
226                &col_widths,
227                '├',
228                '─',
229                '┼',
230                '┤',
231                &gutter_owned,
232                gutter_style,
233            ));
234        }
235    }
236
237    // Bottom border.
238    out.push(make_border_line(
239        &col_widths,
240        '└',
241        '─',
242        '┴',
243        '┘',
244        &gutter_owned,
245        gutter_style,
246    ));
247
248    out
249}
250
251// ── True when a line looks like a table row ────────────────────────────
252
253/// Returns `true` when the line should be treated as part of a Markdown
254/// table (starts with `|` after optional whitespace, or contains `|`).
255pub fn is_table_line(line: &str) -> bool {
256    let t = line.trim();
257    // Must start and (ideally) end with `|`, or at minimum contain `|`
258    // with the first visible char being `|`.
259    t.starts_with('|')
260}
261
262// ── Goal-172: pulldown-cmark based full-document renderer ─────────────
263
264/// Parse `text` as Markdown and return ratatui [`Line`]s for display.
265///
266/// Supported elements (minimum viable set):
267/// - **Bold** `**text**` → bold style
268/// - *Italic* `*text*` → italic style
269/// - `Inline code` `` `code` `` → `Color::Cyan`
270/// - Fenced code blocks ` ``` ` → each line prefixed with `│ ` in cyan
271/// - Unordered lists `- item` / `* item` → prefixed with `• `
272/// - Ordered lists `1. item` → prefixed with `N. `
273/// - Horizontal rules `---` → a line of `─` chars filling `wrap_width`
274/// - Plain paragraphs → rendered as-is
275///
276/// Falls back to a single raw line per `\n` if parsing produces no
277/// output (e.g. an empty string or whitespace-only input).
278pub fn render_markdown(text: &str, wrap_width: u16) -> Vec<Line<'static>> {
279    let width = if wrap_width == 0 { 80 } else { wrap_width } as usize;
280
281    // Fallback: empty / whitespace-only input.
282    if text.trim().is_empty() {
283        return text.lines().map(|l| Line::from(l.to_string())).collect();
284    }
285
286    let parser = Parser::new_ext(text, Options::ENABLE_TABLES);
287    let mut out: Vec<Line<'static>> = Vec::new();
288    let mut current: Vec<Span<'static>> = Vec::new();
289    let mut pending_text: String = String::new();
290    let mut style_stack: Vec<Style> = vec![Style::default().fg(Color::White)];
291    let mut list_stack: Vec<ListState> = Vec::new();
292    let mut in_code_block = false;
293    let mut code_block_lang: String = String::new();
294    let mut code_block_buffer: Vec<String> = Vec::new();
295    // Table accumulation state.
296    let mut in_table = false;
297    let mut table_rows: Vec<Vec<String>> = Vec::new();
298    let mut current_table_row: Vec<String> = Vec::new();
299    let mut current_table_cell = String::new();
300
301    // Helper: append `pending_text` to `current` with the active style.
302    let flush_pending =
303        |current: &mut Vec<Span<'static>>, pending_text: &mut String, style_stack: &[Style]| {
304            if !pending_text.is_empty() {
305                let style = style_stack.last().copied().unwrap_or_default();
306                current.push(Span::styled(std::mem::take(pending_text), style));
307            }
308        };
309
310    // Helper: close out the current logical line.
311    let flush_line = |out: &mut Vec<Line<'static>>,
312                      current: &mut Vec<Span<'static>>,
313                      pending_text: &mut String,
314                      style_stack: &[Style]| {
315        flush_pending(current, pending_text, style_stack);
316        if !current.is_empty() {
317            out.push(Line::from(std::mem::take(current)));
318        }
319    };
320
321    for event in parser {
322        match event {
323            Event::Start(tag) => match tag {
324                Tag::Paragraph => {
325                    // No-op: paragraphs accumulate inline until End.
326                }
327                Tag::Strong => {
328                    flush_pending(&mut current, &mut pending_text, &style_stack);
329                    let base = style_stack.last().copied().unwrap_or_default();
330                    style_stack.push(base.add_modifier(Modifier::BOLD));
331                }
332                Tag::Emphasis => {
333                    flush_pending(&mut current, &mut pending_text, &style_stack);
334                    let base = style_stack.last().copied().unwrap_or_default();
335                    style_stack.push(base.add_modifier(Modifier::ITALIC));
336                }
337                Tag::CodeBlock(kind) => {
338                    // Flush any pending paragraph line first.
339                    flush_line(&mut out, &mut current, &mut pending_text, &style_stack);
340                    in_code_block = true;
341                    code_block_lang = match kind {
342                        CodeBlockKind::Fenced(s) => s.into_string(),
343                        CodeBlockKind::Indented => String::new(),
344                    };
345                    code_block_buffer.clear();
346                }
347                Tag::List(start) => {
348                    // Flush any pending paragraph line first.
349                    flush_line(&mut out, &mut current, &mut pending_text, &style_stack);
350                    list_stack.push(ListState {
351                        ordered: start.is_some(),
352                        next_num: start.unwrap_or(1),
353                    });
354                }
355                Tag::Item => {
356                    // Flush any pending paragraph line first.
357                    flush_line(&mut out, &mut current, &mut pending_text, &style_stack);
358                    let prefix = if let Some(list) = list_stack.last_mut() {
359                        if list.ordered {
360                            let n = list.next_num;
361                            list.next_num += 1;
362                            format!("{}. ", n)
363                        } else {
364                            "\u{2022} ".to_string()
365                        }
366                    } else {
367                        "\u{2022} ".to_string()
368                    };
369                    current.push(Span::styled(
370                        prefix,
371                        Style::default().fg(Color::LightYellow),
372                    ));
373                }
374                Tag::Heading { level, .. } => {
375                    flush_line(&mut out, &mut current, &mut pending_text, &style_stack);
376                    let _ = level; // currently we render heading body as bold+cyan.
377                }
378                Tag::Table(_) => {
379                    flush_line(&mut out, &mut current, &mut pending_text, &style_stack);
380                    in_table = true;
381                    table_rows.clear();
382                    current_table_row.clear();
383                    current_table_cell.clear();
384                }
385                Tag::TableHead => {
386                    current_table_row.clear();
387                    current_table_cell.clear();
388                }
389                Tag::TableRow => {
390                    current_table_row.clear();
391                    current_table_cell.clear();
392                }
393                Tag::TableCell => {
394                    current_table_cell.clear();
395                }
396                _ => {
397                    // Tags we don't render specially (links, images, block
398                    // quotes, footnote defs, html blocks, def lists,
399                    // strikethrough, metadata, math): fall through; their
400                    // children become plain text.
401                }
402            },
403            Event::End(tag_end) => match tag_end {
404                TagEnd::Paragraph => {
405                    flush_line(&mut out, &mut current, &mut pending_text, &style_stack);
406                }
407                TagEnd::Strong | TagEnd::Emphasis => {
408                    flush_pending(&mut current, &mut pending_text, &style_stack);
409                    if style_stack.len() > 1 {
410                        style_stack.pop();
411                    }
412                }
413                TagEnd::CodeBlock => {
414                    // Emit each code block line as a separate `Line` with a `│ ` prefix.
415                    for line in code_block_buffer.drain(..) {
416                        out.push(Line::from(vec![
417                            Span::styled("\u{2502} ".to_string(), Style::default().fg(Color::Cyan)),
418                            Span::styled(line, Style::default().fg(Color::Cyan)),
419                        ]));
420                    }
421                    in_code_block = false;
422                    code_block_lang.clear();
423                }
424                TagEnd::List(_) => {
425                    list_stack.pop();
426                }
427                TagEnd::Item => {
428                    flush_line(&mut out, &mut current, &mut pending_text, &style_stack);
429                }
430                TagEnd::Heading(_) => {
431                    flush_line(&mut out, &mut current, &mut pending_text, &style_stack);
432                }
433                TagEnd::TableCell => {
434                    current_table_row.push(std::mem::take(&mut current_table_cell));
435                }
436                TagEnd::TableHead => {
437                    // In pulldown-cmark 0.12 the header cells are NOT wrapped in a
438                    // TableRow event — TableHead directly contains TableCells.
439                    // Push the accumulated header row, then add the separator.
440                    table_rows.push(std::mem::take(&mut current_table_row));
441                    // Insert a separator row so render_table draws a header divider.
442                    let ncols = table_rows.last().map_or(0, |r| r.len());
443                    if ncols > 0 {
444                        table_rows.push((0..ncols).map(|_| "---".to_string()).collect());
445                    }
446                }
447                TagEnd::TableRow => {
448                    table_rows.push(std::mem::take(&mut current_table_row));
449                }
450                TagEnd::Table => {
451                    // Rebuild raw |-delimited rows and pass them to render_table.
452                    let raw_rows: Vec<String> = table_rows
453                        .iter()
454                        .map(|row| {
455                            format!(
456                                "|{}|",
457                                row.iter()
458                                    .map(|c| format!(" {c} "))
459                                    .collect::<Vec<_>>()
460                                    .join("|")
461                            )
462                        })
463                        .collect();
464                    let row_refs: Vec<&str> = raw_rows.iter().map(String::as_str).collect();
465                    out.extend(render_table(
466                        &row_refs,
467                        "  ",
468                        Style::default().fg(Color::DarkGray),
469                    ));
470                    in_table = false;
471                    table_rows.clear();
472                    current_table_row.clear();
473                    current_table_cell.clear();
474                }
475                _ => {}
476            },
477            Event::Text(s) => {
478                let text = s.into_string();
479                if in_table {
480                    // Accumulate plain text into the current table cell.
481                    current_table_cell.push_str(&text);
482                } else if in_code_block {
483                    // Code block text is line-delimited; split on '\n' so
484                    // each physical line becomes a separate `Line` later.
485                    // We skip a trailing empty element (the natural result
486                    // of splitting text that ends in '\n').
487                    let mut parts = text.split('\n');
488                    if let Some(first) = parts.next() {
489                        if !first.is_empty() {
490                            code_block_buffer.push(first.to_string());
491                        }
492                        for part in parts {
493                            code_block_buffer.push(part.to_string());
494                        }
495                    }
496                } else {
497                    pending_text.push_str(&text);
498                }
499            }
500            Event::Code(s) => {
501                if in_table {
502                    // Inline code inside a table cell: accumulate as plain text
503                    // (table renderer doesn't support per-cell spans).
504                    current_table_cell.push('`');
505                    current_table_cell.push_str(&s);
506                    current_table_cell.push('`');
507                } else if in_code_block {
508                    // Should not happen in well-formed input.
509                    code_block_buffer.push(s.into_string());
510                } else {
511                    flush_pending(&mut current, &mut pending_text, &style_stack);
512                    let base = style_stack.last().copied().unwrap_or_default();
513                    let code_style = base.fg(Color::Cyan);
514                    current.push(Span::styled(s.into_string(), code_style));
515                }
516            }
517            Event::SoftBreak => {
518                if in_table {
519                    current_table_cell.push(' ');
520                } else if in_code_block {
521                    // Treat as hard break inside code blocks.
522                    code_block_buffer.push(String::new());
523                } else {
524                    pending_text.push(' ');
525                }
526            }
527            Event::HardBreak => {
528                if in_table {
529                    // Table cells are single-line; treat as space.
530                    current_table_cell.push(' ');
531                } else if in_code_block {
532                    code_block_buffer.push(String::new());
533                } else {
534                    flush_line(&mut out, &mut current, &mut pending_text, &style_stack);
535                }
536            }
537            Event::Rule => {
538                flush_line(&mut out, &mut current, &mut pending_text, &style_stack);
539                out.push(Line::from(Span::styled(
540                    "\u{2500}".repeat(width),
541                    Style::default().fg(Color::Gray),
542                )));
543            }
544            _ => {
545                // Html, InlineHtml, InlineMath, DisplayMath, FootnoteReference,
546                // TaskListMarker: ignored (rendered as nothing).
547            }
548        }
549    }
550
551    // Flush any trailing content.
552    flush_line(&mut out, &mut current, &mut pending_text, &style_stack);
553
554    if out.is_empty() {
555        // Parser produced no events (e.g. text was just punctuation or
556        // stripped by an option). Fall back to raw lines.
557        return text.lines().map(|l| Line::from(l.to_string())).collect();
558    }
559
560    out
561}
562
563/// State for a single list level, used to render ordered/unordered prefixes.
564struct ListState {
565    /// True for `1. …`, `2. …`; false for `- …` / `* …` / `+ …`.
566    ordered: bool,
567    /// Next item number for ordered lists. Ignored for unordered.
568    next_num: u64,
569}
570
571// ── Internal helpers ───────────────────────────────────────────────────
572
573fn highlight_code_line(line: &str, lang: &str) -> Vec<Span<'static>> {
574    let ss = syntax_set();
575    let ts = theme_set();
576
577    // Try to find the syntax for the given language tag.
578    let syntax = if lang.is_empty() {
579        None
580    } else {
581        ss.find_syntax_by_token(lang)
582    };
583
584    let Some(syntax) = syntax else {
585        // Fallback: plain LightYellow (Goal 150 behaviour).
586        return vec![Span::styled(
587            line.to_string(),
588            Style::default().fg(Color::LightYellow),
589        )];
590    };
591
592    // "base16-ocean.dark" is a good dark-terminal theme bundled with syntect.
593    let theme = ts
594        .themes
595        .get("base16-ocean.dark")
596        .or_else(|| ts.themes.values().next());
597    let Some(theme) = theme else {
598        return vec![Span::styled(
599            line.to_string(),
600            Style::default().fg(Color::LightYellow),
601        )];
602    };
603
604    let mut highlighter = HighlightLines::new(syntax, theme);
605    let mut out: Vec<Span<'static>> = Vec::new();
606
607    // syntect expects lines-with-endings; give it one line.
608    let text_with_newline = format!("{line}\n");
609    for piece in LinesWithEndings::from(&text_with_newline) {
610        if let Ok(highlighted) = highlighter.highlight_line(piece, ss) {
611            for (style, text) in highlighted {
612                let ratatui_color = syntect_color_to_ratatui(style);
613                let clean = text.trim_end_matches('\n').to_string();
614                if !clean.is_empty() {
615                    out.push(Span::styled(clean, Style::default().fg(ratatui_color)));
616                }
617            }
618        }
619    }
620
621    if out.is_empty() {
622        out.push(Span::styled(
623            line.to_string(),
624            Style::default().fg(Color::LightYellow),
625        ));
626    }
627    out
628}
629
630fn syntect_color_to_ratatui(style: SyntectStyle) -> Color {
631    let c = style.foreground;
632    Color::Rgb(c.r, c.g, c.b)
633}
634
635/// Parse a slice of raw table-row strings into
636/// `Vec<(is_separator, cells)>` where `cells` are trimmed cell strings.
637fn parse_table_rows(rows: &[&str]) -> Vec<(bool, Vec<String>)> {
638    rows.iter()
639        .map(|row| {
640            let trimmed = row.trim();
641            // Strip leading and trailing `|` then split.
642            let inner = trimmed.strip_prefix('|').unwrap_or(trimmed);
643            let inner = inner.strip_suffix('|').unwrap_or(inner);
644            let cells: Vec<String> = inner.split('|').map(|c| c.trim().to_string()).collect();
645            let is_sep = cells
646                .iter()
647                .all(|c| c.is_empty() || c.chars().all(|ch| ch == '-' || ch == ':' || ch == ' '));
648            (is_sep, cells)
649        })
650        .collect()
651}
652
653/// Build a horizontal border line for the table.
654fn make_border_line(
655    col_widths: &[usize],
656    left: char,
657    fill: char,
658    sep: char,
659    right: char,
660    gutter: &str,
661    gutter_style: Style,
662) -> Line<'static> {
663    let mut spans: Vec<Span<'static>> = Vec::new();
664    spans.push(Span::styled(gutter.to_string(), gutter_style));
665    let mut s = String::new();
666    s.push(left);
667    for (i, &w) in col_widths.iter().enumerate() {
668        // +2 for the spaces around cell content
669        for _ in 0..w + 2 {
670            s.push(fill);
671        }
672        if i < col_widths.len() - 1 {
673            s.push(sep);
674        }
675    }
676    s.push(right);
677    spans.push(Span::styled(s, Style::default().fg(Color::Gray)));
678    Line::from(spans)
679}
680
681// ── Shared inline-parse helpers ────────────────────────────────────────
682
683/// `# heading` / `## heading` / ... → return the heading body without the
684/// leading hashes.
685fn strip_heading(line: &str) -> Option<&str> {
686    let mut rest = line;
687    let mut hashes = 0;
688    while rest.starts_with('#') && hashes < 6 {
689        rest = &rest[1..];
690        hashes += 1;
691    }
692    if hashes == 0 {
693        return None;
694    }
695    if let Some(stripped) = rest.strip_prefix(' ') {
696        Some(stripped)
697    } else if rest.is_empty() {
698        Some("")
699    } else {
700        None
701    }
702}
703
704/// Match a leading `- ` / `* ` / `+ ` bullet → return `(indent, body)`.
705fn strip_bullet(line: &str) -> Option<(&str, &str)> {
706    let trimmed = line.trim_start();
707    let indent_len = line.len() - trimmed.len();
708    let body = trimmed
709        .strip_prefix("- ")
710        .or_else(|| trimmed.strip_prefix("* "))
711        .or_else(|| trimmed.strip_prefix("+ "))?;
712    Some((&line[..indent_len], body))
713}
714
715/// Parse a line for **bold**, *italic*, _italic_, `code` and emit styled
716/// spans.
717fn parse_inline(line: &str, default_fg: Color) -> Vec<Span<'static>> {
718    let mut out: Vec<Span<'static>> = Vec::new();
719    let bytes = line.as_bytes();
720    let mut i = 0;
721    let mut plain_start = 0;
722
723    let plain_style = Style::default().fg(default_fg);
724    let bold_style = Style::default()
725        .fg(Color::LightCyan)
726        .add_modifier(Modifier::BOLD);
727    let italic_style = Style::default()
728        .fg(default_fg)
729        .add_modifier(Modifier::ITALIC);
730    let code_style = Style::default().fg(Color::LightYellow);
731
732    let flush_plain =
733        |out: &mut Vec<Span<'static>>, line: &str, start: usize, end: usize, style: Style| {
734            if end > start {
735                out.push(Span::styled(line[start..end].to_string(), style));
736            }
737        };
738
739    while i < bytes.len() {
740        // **bold**
741        if i + 1 < bytes.len() && &bytes[i..i + 2] == b"**" {
742            if let Some(close) = find_close(line, i + 2, "**") {
743                flush_plain(&mut out, line, plain_start, i, plain_style);
744                out.push(Span::styled(line[i + 2..close].to_string(), bold_style));
745                i = close + 2;
746                plain_start = i;
747                continue;
748            }
749        }
750        // `code`
751        if bytes[i] == b'`' {
752            if let Some(close) = find_close(line, i + 1, "`") {
753                flush_plain(&mut out, line, plain_start, i, plain_style);
754                out.push(Span::styled(line[i + 1..close].to_string(), code_style));
755                i = close + 1;
756                plain_start = i;
757                continue;
758            }
759        }
760        // *italic* / _italic_
761        if (bytes[i] == b'*' || bytes[i] == b'_') && !is_double(bytes, i) {
762            let marker = bytes[i] as char;
763            let pat: String = marker.to_string();
764            if let Some(close) = find_close(line, i + 1, &pat) {
765                if close > i + 1 {
766                    flush_plain(&mut out, line, plain_start, i, plain_style);
767                    out.push(Span::styled(line[i + 1..close].to_string(), italic_style));
768                    i = close + 1;
769                    plain_start = i;
770                    continue;
771                }
772            }
773        }
774        i += 1;
775    }
776    flush_plain(&mut out, line, plain_start, bytes.len(), plain_style);
777    if out.is_empty() {
778        out.push(Span::raw(String::new()));
779    }
780    out
781}
782
783fn is_double(bytes: &[u8], i: usize) -> bool {
784    if bytes[i] != b'*' {
785        return false;
786    }
787    let prev = i > 0 && bytes[i - 1] == b'*';
788    let next = i + 1 < bytes.len() && bytes[i + 1] == b'*';
789    prev || next
790}
791
792fn find_close(line: &str, start: usize, pat: &str) -> Option<usize> {
793    line[start..].find(pat).map(|p| p + start)
794}
795
796// ──────────────────────────────────────────────────────────────────────
797// Tests
798// ──────────────────────────────────────────────────────────────────────
799
800#[cfg(test)]
801mod tests {
802    use super::*;
803
804    fn collect_text(spans: &[Span<'_>]) -> String {
805        spans.iter().map(|s| s.content.as_ref()).collect()
806    }
807
808    // ── Goal-150 regression tests ──────────────────────────────────────
809
810    #[test]
811    fn plain_text_renders_with_default_fg() {
812        let r = render_inline("hello world", Color::White, MdState::default());
813        assert_eq!(collect_text(&r.spans), "hello world");
814        assert!(!r.state.in_code_block);
815    }
816
817    #[test]
818    fn bold_double_star_styles_inner() {
819        let r = render_inline("hi **there** friend", Color::White, MdState::default());
820        let text = collect_text(&r.spans);
821        assert_eq!(text, "hi there friend");
822        let has_bold = r
823            .spans
824            .iter()
825            .any(|s| s.style.add_modifier.contains(Modifier::BOLD));
826        assert!(has_bold);
827    }
828
829    #[test]
830    fn inline_code_rendered_with_code_colour() {
831        let r = render_inline("call `foo()` now", Color::White, MdState::default());
832        let has_yellow = r
833            .spans
834            .iter()
835            .any(|s| s.style.fg == Some(Color::LightYellow));
836        assert!(has_yellow);
837        assert_eq!(collect_text(&r.spans), "call foo() now");
838    }
839
840    #[test]
841    fn italic_with_underscore() {
842        let r = render_inline("an _emphasised_ word", Color::White, MdState::default());
843        assert_eq!(collect_text(&r.spans), "an emphasised word");
844        let has_italic = r
845            .spans
846            .iter()
847            .any(|s| s.style.add_modifier.contains(Modifier::ITALIC));
848        assert!(has_italic);
849    }
850
851    #[test]
852    fn heading_strips_hashes_and_bolds() {
853        let r = render_inline("## Hello", Color::White, MdState::default());
854        assert_eq!(collect_text(&r.spans), "Hello");
855        let h = &r.spans[0];
856        assert!(h.style.add_modifier.contains(Modifier::BOLD));
857        assert_eq!(h.style.fg, Some(Color::LightCyan));
858    }
859
860    #[test]
861    fn bullet_replaces_dash_with_dot() {
862        let r = render_inline("- first item", Color::White, MdState::default());
863        let text = collect_text(&r.spans);
864        assert!(text.contains("• "));
865        assert!(text.contains("first item"));
866    }
867
868    #[test]
869    fn fence_toggles_code_block_state() {
870        let s0 = MdState::default();
871        let r1 = render_inline("```rust", Color::White, s0);
872        assert!(r1.state.in_code_block);
873        let r2 = render_inline("let x = 1;", Color::White, r1.state);
874        assert!(r2.state.in_code_block);
875        let r3 = render_inline("```", Color::White, r2.state);
876        assert!(!r3.state.in_code_block);
877    }
878
879    #[test]
880    fn unmatched_marker_treated_as_plain() {
881        let r = render_inline("a*b c", Color::White, MdState::default());
882        assert_eq!(collect_text(&r.spans), "a*b c");
883    }
884
885    #[test]
886    fn empty_line_yields_an_empty_span() {
887        let r = render_inline("", Color::White, MdState::default());
888        assert_eq!(r.spans.len(), 1);
889        assert_eq!(r.spans[0].content.as_ref(), "");
890    }
891
892    // ── fenced_block_multiline_threading_unchanged (regression) ────────
893
894    #[test]
895    fn fenced_block_multiline_threading_unchanged() {
896        let s0 = MdState::default();
897        let r1 = render_inline("```", Color::White, s0);
898        assert!(r1.state.in_code_block);
899        let r2 = render_inline("some code", Color::White, r1.state);
900        assert!(r2.state.in_code_block);
901        assert_eq!(r2.spans.len(), 1);
902        let r3 = render_inline("```", Color::White, r2.state);
903        assert!(!r3.state.in_code_block);
904    }
905
906    // ── Goal-159: table tests ──────────────────────────────────────────
907
908    #[test]
909    fn table_three_columns_renders_cells() {
910        let rows = ["| A | B | C |", "|---|---|---|", "| 1 | 2 | 3 |"];
911        let rows_ref: Vec<&str> = rows.iter().map(|s| s.as_ref()).collect();
912        let lines = render_table(&rows_ref, "│  ", Style::default().fg(Color::Gray));
913        // Should have: top border, header, divider, data row, bottom border = 5 lines
914        assert_eq!(lines.len(), 5);
915        // Header line should contain A, B, C
916        let header_text: String = lines[1].spans.iter().map(|s| s.content.as_ref()).collect();
917        assert!(header_text.contains('A'));
918        assert!(header_text.contains('B'));
919        assert!(header_text.contains('C'));
920        // Data line should contain 1, 2, 3
921        let data_text: String = lines[3].spans.iter().map(|s| s.content.as_ref()).collect();
922        assert!(data_text.contains('1'));
923        assert!(data_text.contains('2'));
924        assert!(data_text.contains('3'));
925    }
926
927    #[test]
928    fn table_header_separator_data_parses_correctly() {
929        let rows = [
930            "| Name     | Value |",
931            "|----------|-------|",
932            "| foo      | 42    |",
933        ];
934        let rows_ref: Vec<&str> = rows.iter().map(|s| s.as_ref()).collect();
935        let lines = render_table(&rows_ref, "", Style::default());
936        // top + header + divider + data + bottom = 5
937        assert_eq!(lines.len(), 5);
938        let hdr: String = lines[1].spans.iter().map(|s| s.content.as_ref()).collect();
939        assert!(hdr.contains("Name"));
940        assert!(hdr.contains("Value"));
941    }
942
943    #[test]
944    fn table_without_separator_falls_back_to_plain() {
945        let rows = ["| A | B |", "| 1 | 2 |"];
946        let rows_ref: Vec<&str> = rows.iter().map(|s| s.as_ref()).collect();
947        let lines = render_table(&rows_ref, "", Style::default());
948        // No header detection means both treated as data rows.
949        // top + 2 data + bottom = 4
950        assert_eq!(lines.len(), 4);
951    }
952
953    // ── Goal-159: syntax-highlight tests ──────────────────────────────
954
955    #[test]
956    fn syntax_rust_keywords_get_color_spans() {
957        let state = MdState {
958            in_code_block: true,
959            code_lang: "rust".to_string(),
960        };
961        let r = render_inline("fn main() {}", Color::White, state);
962        // Should produce multiple coloured spans (not just one yellow fallback).
963        assert!(!r.spans.is_empty());
964        let text = collect_text(&r.spans);
965        assert!(text.contains("fn"));
966        assert!(text.contains("main"));
967    }
968
969    #[test]
970    fn syntax_unknown_language_uses_fallback_color() {
971        let state = MdState {
972            in_code_block: true,
973            code_lang: "notalang_xyz".to_string(),
974        };
975        let r = render_inline("some code here", Color::White, state);
976        assert_eq!(r.spans.len(), 1);
977        assert_eq!(r.spans[0].style.fg, Some(Color::LightYellow));
978    }
979
980    #[test]
981    fn syntax_empty_code_block_no_panic() {
982        let state = MdState {
983            in_code_block: true,
984            code_lang: "rust".to_string(),
985        };
986        let r = render_inline("", Color::White, state);
987        // Empty line shouldn't panic; may produce 0 or 1 span.
988        let _ = r.spans;
989    }
990
991    // ── Goal-172: render_markdown tests ────────────────────────────────
992
993    fn lines_text(lines: &[Line<'_>]) -> String {
994        lines
995            .iter()
996            .map(|l| {
997                l.spans
998                    .iter()
999                    .map(|s| s.content.as_ref())
1000                    .collect::<String>()
1001            })
1002            .collect::<Vec<_>>()
1003            .join("\n")
1004    }
1005
1006    #[test]
1007    fn bold_renders_as_bold_span() {
1008        let lines = render_markdown("hello **world**!", 80);
1009        let all_spans: Vec<&Span<'static>> = lines.iter().flat_map(|l| l.spans.iter()).collect();
1010        let has_bold = all_spans
1011            .iter()
1012            .any(|s| s.style.add_modifier.contains(Modifier::BOLD));
1013        assert!(
1014            has_bold,
1015            "expected at least one bold span; got spans={all_spans:?}"
1016        );
1017    }
1018
1019    #[test]
1020    fn inline_code_renders_cyan() {
1021        let lines = render_markdown("call `foo()` now", 80);
1022        let all_spans: Vec<&Span<'static>> = lines.iter().flat_map(|l| l.spans.iter()).collect();
1023        let has_cyan = all_spans.iter().any(|s| s.style.fg == Some(Color::Cyan));
1024        assert!(
1025            has_cyan,
1026            "expected at least one cyan span for inline code; got spans={all_spans:?}"
1027        );
1028    }
1029
1030    #[test]
1031    fn fenced_code_block_prefixed() {
1032        let src = "```\nsome code\n```";
1033        let lines = render_markdown(src, 80);
1034        assert!(!lines.is_empty(), "fenced code block produced no lines");
1035        // Every emitted line should start with the `│ ` (U+2502) prefix.
1036        for line in &lines {
1037            let first = line.spans.first().map(|s| s.content.as_ref()).unwrap_or("");
1038            assert!(
1039                first.starts_with('\u{2502}'),
1040                "expected fenced code line to start with `\u{2502} `, got {first:?}"
1041            );
1042        }
1043        // The original code text should be present in the output.
1044        let all_text = lines_text(&lines);
1045        assert!(
1046            all_text.contains("some code"),
1047            "expected code text in output: {all_text:?}"
1048        );
1049    }
1050
1051    #[test]
1052    fn bullet_list_prefixed() {
1053        let lines = render_markdown("- first item\n- second", 80);
1054        let all_text = lines_text(&lines);
1055        assert!(
1056            all_text.contains('\u{2022}'),
1057            "expected bullet `\u{2022}` prefix in list output: {all_text:?}"
1058        );
1059        // Each non-empty line should start with `• `.
1060        for line in &lines {
1061            let first = line.spans.first().map(|s| s.content.as_ref()).unwrap_or("");
1062            if first.is_empty() {
1063                continue;
1064            }
1065            assert!(
1066                first.starts_with('\u{2022}'),
1067                "expected list line to start with `\u{2022} `, got {first:?}"
1068            );
1069        }
1070    }
1071
1072    #[test]
1073    fn plain_text_passthrough() {
1074        let lines = render_markdown("hello world", 80);
1075        assert!(!lines.is_empty(), "expected at least one line");
1076        let all_text = lines_text(&lines);
1077        assert!(
1078            all_text.contains("hello world"),
1079            "expected plain text in output: {all_text:?}"
1080        );
1081    }
1082
1083    #[test]
1084    fn empty_string_returns_empty() {
1085        let lines = render_markdown("", 80);
1086        // Empty input: no panic, output is either empty or matches raw lines
1087        // (which is empty for "").
1088        assert!(
1089            lines.is_empty(),
1090            "expected empty Vec for empty input, got {lines:?}"
1091        );
1092    }
1093
1094    #[test]
1095    fn zero_wrap_width_falls_back_to_80() {
1096        // wrap_width = 0 must not panic and must produce output.
1097        let lines = render_markdown("hello", 0);
1098        assert!(!lines.is_empty());
1099    }
1100
1101    #[test]
1102    fn horizontal_rule_fills_wrap_width() {
1103        let lines = render_markdown("---", 40);
1104        assert_eq!(lines.len(), 1);
1105        let text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
1106        let rule_count = text.chars().filter(|c| *c == '\u{2500}').count();
1107        assert_eq!(
1108            rule_count, 40,
1109            "expected 40 box-drawing chars, got {text:?}"
1110        );
1111    }
1112}
1113
1114/// Inline code `` `x` `` inside a table cell must appear in the rendered
1115/// output (with surrounding backticks) rather than being swallowed.
1116#[test]
1117fn render_markdown_table_with_inline_code_in_cell() {
1118    let text = "| File | Desc |\n|------|------|\n| `foo.rs` | new file |\n| `bar.rs` | updated |";
1119    let lines = render_markdown(text, 80);
1120    let all_text: String = lines
1121        .iter()
1122        .flat_map(|l| l.spans.iter())
1123        .map(|s| s.content.as_ref())
1124        .collect();
1125    // Both file names must survive rendering.
1126    assert!(all_text.contains("foo.rs"), "inline code 'foo.rs' missing");
1127    assert!(all_text.contains("bar.rs"), "inline code 'bar.rs' missing");
1128}
1129
1130/// Verify that render_markdown produces a properly boxed table for a
1131/// standard GFM table (header + separator + data rows).
1132#[test]
1133fn render_markdown_table_end_to_end() {
1134    let text = "| Name | Value |\n|------|-------|\n| foo | 42 |\n| bar | 99 |";
1135    let lines = render_markdown(text, 80);
1136    let all_text: String = lines
1137        .iter()
1138        .flat_map(|l| l.spans.iter())
1139        .map(|s| s.content.as_ref())
1140        .collect();
1141    // Should contain box-drawing chars and cell content.
1142    assert!(all_text.contains("Name"), "header 'Name' missing");
1143    assert!(all_text.contains("Value"), "header 'Value' missing");
1144    assert!(all_text.contains("foo"), "data 'foo' missing");
1145    assert!(all_text.contains("42"), "data '42' missing");
1146    assert!(all_text.contains("bar"), "data 'bar' missing");
1147    assert!(all_text.contains("99"), "data '99' missing");
1148    // Box borders should be present.
1149    assert!(
1150        all_text.contains('┌') || all_text.contains('─'),
1151        "no box-drawing chars found"
1152    );
1153    // Should be more than 3 lines (top + header + divider + 2 data + bottom = 6).
1154    assert!(lines.len() >= 6, "expected ≥6 lines, got {}", lines.len());
1155}