Skip to main content

oxicode_vtui/tui/ui/markdown/
mod.rs

1//! Minimal markdown → InlineSegment renderer with full inline styling.
2use anstyle::{Color as AnsiColorEnum, Effects, RgbColor};
3use oxicode_vtui_compat::ui_protocol::{InlineSegment, InlineTextStyle};
4use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
5use std::sync::{Arc, LazyLock};
6use syntect::easy::HighlightLines;
7use syntect::highlighting::ThemeSet;
8use syntect::parsing::SyntaxSet;
9use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
10
11// Cached once at module scope — never rebuild per call.
12static SYNTAX_SET: LazyLock<SyntaxSet> = LazyLock::new(SyntaxSet::load_defaults_newlines);
13static THEME_SET: LazyLock<ThemeSet> = LazyLock::new(ThemeSet::load_defaults);
14
15// Per-line syntax-highlight memo for `render_code_block`. Same `(lang, line,
16// width, theme)` triple → same highlight output; LLMs re-streaming a code
17// block pay zero cost the second time around. Bounded at 4096 entries so a
18// runaway stream can't OOM the UI thread; cleared on theme changes via the
19// `theme_epoch` key (active syntax theme name) so re-themes never serve a
20// stale palette.
21type SyntectMemoMap =
22    std::collections::HashMap<(String, String, usize, String), Vec<InlineSegment>>;
23thread_local! {
24    static SYNTECT_LINE_MEMO: std::cell::RefCell<SyntectMemoMap> =
25        std::cell::RefCell::new(std::collections::HashMap::new());
26}
27const SYNTECT_MEMO_CAP: usize = 4096;
28fn memo_get(lang: &str, line: &str, width: usize, theme: &str) -> Option<Vec<InlineSegment>> {
29    SYNTECT_LINE_MEMO.with(|m| {
30        m.borrow()
31            .get(&(lang.to_string(), line.to_string(), width, theme.to_string()))
32            .cloned()
33    })
34}
35
36fn memo_put(lang: &str, line: &str, width: usize, theme: &str, segs: Vec<InlineSegment>) {
37    SYNTECT_LINE_MEMO.with(|m| {
38        let mut map = m.borrow_mut();
39        if map.len() >= SYNTECT_MEMO_CAP {
40            // Clear-and-rebuild rather than tracking insertion: a stream
41            // that hammers the same 200 lines repeatedly would otherwise
42            // evict the lines it's about to re-request.
43            map.clear();
44        }
45        map.insert(
46            (lang.to_string(), line.to_string(), width, theme.to_string()),
47            segs,
48        );
49    });
50}
51
52/// Increment-only fast-path cache for the streaming assistant render site.
53///
54/// Holds the last `(text, width)` pair plus the lines `render_markdown`
55/// produced for it. `render_markdown_cached` is a thin wrapper that
56/// returns the cached lines when the new call's `(text, width)` exactly
57/// equals the cached one — the streaming typewriter advances a few bytes
58/// each frame, but most frames the visible message and viewport are
59/// unchanged, so the equality fast-path is the common case. Anything else
60/// (different text, different width, first call) falls through to the
61/// full renderer and refreshes the cache.
62#[derive(Default, Debug)]
63pub struct MdRenderCache {
64    prev_text: String,
65    prev_width: usize,
66    lines: Vec<Vec<InlineSegment>>,
67    /// Fast-path hit counter; exposed via `debug_hits` so the streaming
68    /// render tests can assert the cache is being consulted.
69    hits: usize,
70}
71
72impl MdRenderCache {
73    /// Number of times the fast path returned the cached lines without
74    /// invoking `render_markdown`. Test-only accessor — kept on the type
75    /// so the test module can read it without a `pub(crate)` escape.
76    pub fn debug_hits(&self) -> usize {
77        self.hits
78    }
79}
80
81/// Cached counterpart of [`render_markdown`]. When `(text, width)` matches
82/// the previous call, returns a clone of the cached lines without invoking
83/// the full parser — the streaming typewriter re-renders many times per
84/// second but most frames have not actually changed. A miss always
85/// refreshes the cache.
86pub fn render_markdown_cached(
87    text: &str,
88    width: usize,
89    cache: &mut MdRenderCache,
90) -> Vec<Vec<InlineSegment>> {
91    if width == cache.prev_width && text == cache.prev_text && !cache.lines.is_empty() {
92        cache.hits += 1;
93        return cache.lines.clone();
94    }
95    let lines = render_markdown(text, width);
96    cache.prev_text = text.to_string();
97    cache.prev_width = width;
98    cache.lines = lines.clone();
99    lines
100}
101
102/// Parse markdown text into styled InlineSegment lines.
103///
104/// `width` is the usable cell width of the destination surface. Tables
105/// are the only block that pre-computes its own geometry — a table built
106/// wider than the viewport wraps at the terminal edge and every border
107/// row breaks. Pass the scrollback content width; other blocks wrap at
108/// render time as before.
109pub fn render_markdown(text: &str, width: usize) -> Vec<Vec<InlineSegment>> {
110    let mut opts = Options::empty();
111    opts.insert(Options::ENABLE_TABLES);
112    opts.insert(Options::ENABLE_STRIKETHROUGH);
113    opts.insert(Options::ENABLE_HEADING_ATTRIBUTES);
114
115    let mut lines: Vec<Vec<InlineSegment>> = Vec::new();
116    let mut cur: Vec<InlineSegment> = Vec::new();
117    let mut effects: Effects = Effects::default();
118    let mut code_buf: Option<CodeBlockState> = None;
119    let mut table_buf: Option<TableState> = None;
120    let mut list_stack: Vec<ListLevel> = Vec::new();
121    let ss = &*SYNTAX_SET;
122
123    for event in Parser::new_ext(text, opts) {
124        if let Some(tb) = &mut table_buf {
125            match event {
126                Event::Text(t) | Event::Html(t) | Event::Code(t) => tb.current_cell.push_str(&t),
127                Event::End(TagEnd::TableCell) => {
128                    tb.current_row.push(std::mem::take(&mut tb.current_cell));
129                }
130                Event::End(TagEnd::TableRow) => {
131                    tb.rows.push(std::mem::take(&mut tb.current_row));
132                }
133                Event::End(TagEnd::TableHead) => {
134                    // pulldown-cmark 0.13 does not emit End(TableRow) for the
135                    // head — the head IS the row, so the accumulated cells
136                    // are still in `current_row`. Move them straight into
137                    // `header` so the body parser starts clean.
138                    if tb.header.is_empty() {
139                        tb.header = std::mem::take(&mut tb.current_row);
140                    }
141                }
142                Event::End(TagEnd::Table) => {
143                    let tb = table_buf.take().unwrap();
144                    let table_lines = render_table(&tb.header, &tb.rows, width);
145                    lines.extend(table_lines);
146                    lines.push(Vec::new());
147                }
148                _ => {}
149            }
150            continue;
151        }
152
153        // Code block capture mode
154        if let Some(cb) = &mut code_buf {
155            match event {
156                Event::Text(t) => cb.code.push_str(&t),
157                Event::End(TagEnd::CodeBlock) => {
158                    let cb = code_buf.take().unwrap();
159                    flush_line(&mut cur, &mut lines);
160                    let block_lines = render_code_block(&cb.code, cb.lang.as_deref(), ss, width);
161                    lines.extend(block_lines);
162                    lines.push(Vec::new());
163                }
164                _ => {}
165            }
166            continue;
167        }
168
169        // Flat main match
170        match event {
171            // ── Code block ─────────────────────────────────────────────
172            Event::Start(Tag::CodeBlock(kind)) => {
173                flush_line(&mut cur, &mut lines);
174                let lang = match kind {
175                    CodeBlockKind::Fenced(l) => Some(l.to_string()),
176                    CodeBlockKind::Indented => None,
177                };
178                code_buf = Some(CodeBlockState {
179                    code: String::new(),
180                    lang,
181                });
182            }
183
184            // ── Tables ─────────────────────────────────────────────────
185            Event::Start(Tag::Table(_)) => {
186                flush_line(&mut cur, &mut lines);
187                table_buf = Some(TableState::default());
188            }
189
190            // ── Lists ──────────────────────────────────────────────────
191            Event::Start(Tag::List(start)) => {
192                list_stack.push(ListLevel {
193                    is_ordered: start.is_some(),
194                    index: start.map(|n| n.saturating_sub(1)).unwrap_or(0),
195                });
196            }
197            Event::End(TagEnd::List(_)) => {
198                list_stack.pop();
199            }
200            Event::Start(Tag::Item) => {
201                flush_line(&mut cur, &mut lines);
202                let depth = list_stack.len();
203                if let Some(top) = list_stack.last_mut() {
204                    let indent = " ".repeat((depth.saturating_sub(1)) * 2);
205                    let marker = if top.is_ordered {
206                        let n = top.index + 1;
207                        top.index += 1;
208                        format!("{}{}. ", indent, n)
209                    } else {
210                        format!("{}• ", indent)
211                    };
212                    let seg = InlineSegment {
213                        text: marker,
214                        style: Arc::new(InlineTextStyle::default()),
215                    };
216                    merge_or_push(&mut cur, seg);
217                }
218            }
219
220            // ── Block-level ────────────────────────────────────────────
221            Event::Start(Tag::Paragraph) => {}
222
223            Event::Start(Tag::Heading { level, .. }) => {
224                flush_line(&mut cur, &mut lines);
225                {
226                    effects = effects.insert(Effects::BOLD);
227                };
228                {
229                    effects = effects.insert(if level == HeadingLevel::H1 {
230                        Effects::UNDERLINE
231                    } else {
232                        Effects::default()
233                    });
234                }
235            }
236            Event::End(TagEnd::Heading(_)) => {
237                {
238                    effects = effects.remove(Effects::BOLD | Effects::UNDERLINE);
239                };
240                flush_line(&mut cur, &mut lines);
241            }
242
243            Event::Start(Tag::BlockQuote(_)) => {
244                {
245                    effects = effects.insert(Effects::DIMMED);
246                };
247            }
248            Event::End(TagEnd::BlockQuote(_)) => {
249                {
250                    effects = effects.remove(Effects::DIMMED);
251                };
252            }
253
254            Event::End(TagEnd::Paragraph) | Event::End(TagEnd::Item) => {
255                flush_line(&mut cur, &mut lines);
256            }
257
258            Event::Rule => {
259                flush_line(&mut cur, &mut lines);
260                lines.push(vec![InlineSegment {
261                    text: "\u{2500}".repeat(40),
262                    style: Arc::new(InlineTextStyle::default().dim()),
263                }]);
264            }
265
266            // ── Inline formatting ──────────────────────────────────────
267            Event::Start(Tag::Emphasis) => {
268                {
269                    effects = effects.insert(Effects::ITALIC);
270                };
271            }
272            Event::End(TagEnd::Emphasis) => {
273                {
274                    effects = effects.remove(Effects::ITALIC);
275                };
276            }
277
278            Event::Start(Tag::Strong) => {
279                {
280                    effects = effects.insert(Effects::BOLD);
281                };
282            }
283            Event::End(TagEnd::Strong) => {
284                {
285                    effects = effects.remove(Effects::BOLD);
286                };
287            }
288
289            Event::Start(Tag::Strikethrough) => {
290                {
291                    effects = effects.insert(Effects::STRIKETHROUGH);
292                };
293            }
294            Event::End(TagEnd::Strikethrough) => {
295                {
296                    effects = effects.remove(Effects::STRIKETHROUGH);
297                };
298            }
299
300            Event::Start(Tag::Link { .. }) => {
301                {
302                    effects = effects.insert(Effects::UNDERLINE);
303                };
304            }
305            Event::End(TagEnd::Link) => {
306                {
307                    effects = effects.remove(Effects::UNDERLINE);
308                };
309            }
310
311            // ── Text content ───────────────────────────────────────────
312            Event::Text(t) | Event::Html(t) => {
313                let style = apply_effects(InlineTextStyle::default(), effects);
314                let seg = InlineSegment {
315                    text: t.to_string(),
316                    style: Arc::new(style),
317                };
318                merge_or_push(&mut cur, seg);
319            }
320
321            Event::Code(t) => {
322                let seg = InlineSegment {
323                    text: t.to_string(),
324                    style: Arc::new(InlineTextStyle::default().bold()),
325                };
326                merge_or_push(&mut cur, seg);
327            }
328
329            Event::SoftBreak | Event::HardBreak => {
330                flush_line(&mut cur, &mut lines);
331            }
332
333            Event::FootnoteReference(t) => {
334                let seg = InlineSegment {
335                    text: format!("[^{}]", t),
336                    style: Arc::new(InlineTextStyle::default().dim()),
337                };
338                merge_or_push(&mut cur, seg);
339            }
340
341            _ => {}
342        }
343    }
344
345    flush_line(&mut cur, &mut lines);
346    lines
347}
348
349/// Render a code block with syntect syntax highlighting.
350///
351/// `width == 0` preserves the historical "no wrap" behavior so internal
352/// callers (and tests) that don't pass a viewport still work. Any other
353/// value hard-wraps the highlighted output at display-width boundaries —
354/// LLMs frequently emit tables inside code fences, and the un-wrapped
355/// version overflowed the terminal before this parameter existed.
356/// Wrapping happens *after* syntax highlighting so each chunk keeps its
357/// token color; tabs expand to four spaces first to keep the math
358/// simple (syntect preserves tabs verbatim and a tab stop is variable).
359pub fn render_code_block(
360    code: &str,
361    lang: Option<&str>,
362    ss: &SyntaxSet,
363    width: usize,
364) -> Vec<Vec<InlineSegment>> {
365    let syntax = lang
366        .and_then(|l| ss.find_syntax_by_token(l))
367        .unwrap_or_else(|| ss.find_syntax_plain_text());
368    // syntect's bundled `ThemeSet` ships only 7 themes (base16-*, Solarized,
369    // InspiredGitHub). Many UI themes map to names outside that set; fall back
370    // to a real bundled dark theme rather than the plain `Theme::default()` so
371    // code is always colored (see `theme::syntax::get_active_syntax_theme`).
372    let theme_name: &'static str = crate::get_active_syntax_theme();
373    let theme = THEME_SET
374        .themes
375        .get(theme_name)
376        .or_else(|| THEME_SET.themes.get("base16-ocean.dark"))
377        .cloned()
378        .unwrap_or_default();
379    #[allow(unused_mut)]
380    let mut h = HighlightLines::new(syntax, &theme);
381    let mut lines = Vec::new();
382
383    for line in syntect::util::LinesWithEndings::from(code) {
384        // Per-line highlight cache: streaming assistants re-render the
385        // same code block every frame while tokens trickle in. The
386        // wrapped output is keyed by `(lang, line, width, theme)` so a
387        // re-emit of an already-highlighted line is a clone instead of
388        // a syntect pass. The cache busts when the active syntax theme
389        // changes (keyed into the tuple) — see `SYNTECT_LINE_MEMO`.
390        let cache_key_lang = lang.unwrap_or("");
391        let highlighted: Vec<InlineSegment> =
392            if let Some(seg) = memo_get(cache_key_lang, line, width, theme_name) {
393                seg
394            } else if let Ok(ranges) = h.highlight_line(line, ss) {
395                let seg: Vec<InlineSegment> =
396                    ranges
397                        .into_iter()
398                        .map(|(s, t)| {
399                            let fg = s.foreground;
400                            InlineSegment {
401                                text: t.to_string(),
402                                style: Arc::new(InlineTextStyle::default().with_color(Some(
403                                    AnsiColorEnum::Rgb(RgbColor(fg.r, fg.g, fg.b)),
404                                ))),
405                            }
406                        })
407                        .collect();
408                memo_put(cache_key_lang, line, width, theme_name, seg.clone());
409                seg
410            } else {
411                vec![InlineSegment {
412                    text: line.to_string(),
413                    style: Arc::new(InlineTextStyle::default()),
414                }]
415            };
416        if width == 0 {
417            lines.push(highlighted);
418        } else {
419            for wrapped in wrap_segments_to_rows(&highlighted, width) {
420                lines.push(wrapped);
421            }
422        }
423    }
424    lines
425}
426
427/// Soft-wrap a flat segment list into rows that each fit `width` (display
428/// cells). Splits a segment mid-text when crossing a boundary so styles
429/// stay attached to their content; expands `\t` to four spaces on the way
430/// through so a tab character never silently costs one cell and gets
431/// pushed off the edge by adjacent chars.
432fn wrap_segments_to_rows(segs: &[InlineSegment], width: usize) -> Vec<Vec<InlineSegment>> {
433    let mut rows: Vec<Vec<InlineSegment>> = Vec::new();
434    let mut cur_row: Vec<InlineSegment> = Vec::new();
435    let mut cur_buf = String::new();
436    let mut cur_style: Option<Arc<InlineTextStyle>> = None;
437    let mut cur_w: usize = 0;
438
439    for seg in segs {
440        for ch in seg.text.chars() {
441            if ch == '\t' {
442                // Pad to the next 4-cell boundary so visual indentation
443                // lines up with the source's intent.
444                let pad = 4 - (cur_w % 4);
445                for _ in 0..pad {
446                    if cur_w + 1 > width {
447                        flush_wrap_chunk(
448                            &mut cur_buf,
449                            &mut cur_style,
450                            &mut cur_row,
451                            &mut rows,
452                            &mut cur_w,
453                        );
454                    }
455                    cur_buf.push(' ');
456                    if cur_style.is_none() {
457                        cur_style = Some(Arc::clone(&seg.style));
458                    }
459                    cur_w += 1;
460                }
461                continue;
462            }
463            let ch_w = UnicodeWidthChar::width(ch).unwrap_or(0);
464            // Zero-width characters: always attach to the current chunk.
465            if ch_w == 0 {
466                cur_buf.push(ch);
467                if cur_style.is_none() {
468                    cur_style = Some(Arc::clone(&seg.style));
469                }
470                continue;
471            }
472            // Char wider than the row by itself: drop (preferable to a
473            // terminal-breaking overflow when the viewport is narrow).
474            if ch_w > width {
475                continue;
476            }
477            if cur_w + ch_w > width {
478                flush_wrap_chunk(
479                    &mut cur_buf,
480                    &mut cur_style,
481                    &mut cur_row,
482                    &mut rows,
483                    &mut cur_w,
484                );
485            }
486            cur_buf.push(ch);
487            if cur_style.is_none() {
488                cur_style = Some(Arc::clone(&seg.style));
489            }
490            cur_w += ch_w;
491        }
492    }
493    flush_wrap_chunk(
494        &mut cur_buf,
495        &mut cur_style,
496        &mut cur_row,
497        &mut rows,
498        &mut cur_w,
499    );
500    if rows.is_empty() {
501        rows.push(Vec::new());
502    }
503    rows
504}
505
506/// Flush the in-progress chunk to `cur_row`, and push the row to `rows`
507/// when it has content. Resets `cur_w` to 0.
508fn flush_wrap_chunk(
509    buf: &mut String,
510    style: &mut Option<Arc<InlineTextStyle>>,
511    row: &mut Vec<InlineSegment>,
512    rows: &mut Vec<Vec<InlineSegment>>,
513    used: &mut usize,
514) {
515    if !buf.is_empty()
516        && let Some(s) = style.take()
517    {
518        row.push(InlineSegment {
519            text: std::mem::take(buf),
520            style: s,
521        });
522    }
523    if !row.is_empty() {
524        rows.push(std::mem::take(row));
525    }
526    *used = 0;
527}
528/// Render a GFM table with box-drawing borders, fitted to `max_w`.
529///
530/// Natural column widths come from cell contents; when the table would
531/// exceed `max_w`, the widest columns shrink one cell at a time (labels
532/// keep their width, prose columns pay) and cell text wraps inside its
533/// column, expanding short rows to as many physical lines as their
534/// tallest cell needs. The table never exceeds the viewport, so border
535/// rows never wrap at the terminal edge.
536fn render_table(header: &[String], rows: &[Vec<String>], max_w: usize) -> Vec<Vec<InlineSegment>> {
537    let num_cols = std::cmp::max(
538        header.len(),
539        rows.iter().map(|r| r.len()).max().unwrap_or(0),
540    );
541    if num_cols == 0 {
542        return Vec::new();
543    }
544
545    // Natural column widths from display width.
546    let mut col_width: Vec<usize> = vec![0; num_cols];
547    for (c, cell) in header.iter().enumerate() {
548        col_width[c] = std::cmp::max(col_width[c], cell.width());
549    }
550    for row in rows {
551        for (c, cell) in row.iter().enumerate() {
552            col_width[c] = std::cmp::max(col_width[c], cell.width());
553        }
554    }
555
556    // Fit: total = Σ(w + 2) + (n − 1) + 2 border chars = Σw + 3n + 1.
557    // The +1 accounts for inter-column separators being n − 1, not n;
558    // the old formula (3n) under-budgeted by one cell and the last
559    // column silently grew past the viewport on narrow widths.
560    let chrome = 3 * num_cols + 1;
561    let budget = max_w.saturating_sub(chrome);
562    while col_width.iter().sum::<usize>() > budget {
563        // Take one cell from the widest column; stop once every column
564        // is down to the floor of 1.
565        let widest = col_width
566            .iter()
567            .enumerate()
568            .max_by_key(|&(i, w)| (w, std::cmp::Reverse(i)))
569            .filter(|&(_, w)| *w > 1)
570            .map(|(i, _)| i);
571        match widest {
572            Some(i) => col_width[i] -= 1,
573            None => break,
574        }
575    }
576
577    let mut out: Vec<Vec<InlineSegment>> = Vec::new();
578
579    let border = |l: &str, j: &str, r: &str| {
580        format!(
581            "{l}{}{r}",
582            col_width
583                .iter()
584                .map(|w| "─".repeat(w + 2))
585                .collect::<Vec<_>>()
586                .join(j)
587        )
588    };
589
590    let plain = |s: String| {
591        vec![InlineSegment {
592            text: s,
593            style: Arc::new(InlineTextStyle::default()),
594        }]
595    };
596    let bold = |s: String| {
597        vec![InlineSegment {
598            text: s,
599            style: Arc::new(InlineTextStyle::default().bold()),
600        }]
601    };
602
603    out.push(plain(border("┌", "┬", "┐")));
604
605    let mut cell_rows: Vec<(&[String], bool)> = vec![(header, true)];
606    cell_rows.extend(rows.iter().map(|r| (r.as_slice(), false)));
607    for (cells, is_header) in cell_rows {
608        // Wrap every cell to its column width, then emit one physical
609        // row per line of the tallest cell.
610        let wrapped: Vec<Vec<String>> = col_width
611            .iter()
612            .enumerate()
613            .map(|(c, &w)| wrap_cell(cells.get(c).map(String::as_str).unwrap_or(""), w))
614            .collect();
615        let height = wrapped.iter().map(Vec::len).max().unwrap_or(1).max(1);
616        for line_idx in 0..height {
617            let text = format_wrapped_row(&wrapped, line_idx, &col_width);
618            let segs = if is_header { bold(text) } else { plain(text) };
619            out.push(segs);
620        }
621        if is_header {
622            out.push(plain(border("├", "┼", "┤")));
623        }
624    }
625
626    out.push(plain(border("└", "┴", "┘")));
627    out
628}
629
630/// Hard-wrap one cell to its column's display width (CJK-aware).
631fn wrap_cell(text: &str, w: usize) -> Vec<String> {
632    if w == 0 {
633        return vec![String::new()];
634    }
635    if text.width() <= w {
636        return vec![text.to_string()];
637    }
638    let mut out: Vec<String> = Vec::new();
639    let mut cur = String::new();
640    let mut cur_w = 0usize;
641    for ch in text.chars() {
642        let ch_w = UnicodeWidthChar::width(ch).unwrap_or(0);
643        if cur_w + ch_w > w && !cur.is_empty() {
644            out.push(std::mem::take(&mut cur));
645            cur_w = 0;
646        }
647        cur.push(ch);
648        cur_w += ch_w;
649    }
650    if !cur.is_empty() {
651        out.push(cur);
652    }
653    if out.is_empty() {
654        out.push(String::new());
655    }
656    out
657}
658
659/// One physical row line: line `i` of every wrapped cell, padded to the
660/// column width so the right border stays aligned under CJK content.
661fn format_wrapped_row(wrapped: &[Vec<String>], line_idx: usize, col_width: &[usize]) -> String {
662    let mut parts: Vec<String> = Vec::with_capacity(col_width.len());
663    for (c, &w) in col_width.iter().enumerate() {
664        let text = wrapped
665            .get(c)
666            .and_then(|lines| lines.get(line_idx))
667            .map(String::as_str)
668            .unwrap_or("");
669        let pad = w.saturating_sub(text.width());
670        parts.push(format!(" {text}{} ", " ".repeat(pad)));
671    }
672    format!("│{}│", parts.join("│"))
673}
674// ── Helpers ─────────────────────────────────────────────────────────────────
675
676struct CodeBlockState {
677    code: String,
678    lang: Option<String>,
679}
680
681#[derive(Default)]
682struct TableState {
683    header: Vec<String>,
684    rows: Vec<Vec<String>>,
685    current_cell: String,
686    current_row: Vec<String>,
687}
688
689struct ListLevel {
690    is_ordered: bool,
691    index: u64,
692}
693
694fn flush_line(cur: &mut Vec<InlineSegment>, lines: &mut Vec<Vec<InlineSegment>>) {
695    if !cur.is_empty() {
696        lines.push(std::mem::take(cur));
697    }
698}
699
700fn merge_or_push(cur: &mut Vec<InlineSegment>, seg: InlineSegment) {
701    if let Some(last) = cur.last_mut() {
702        if last.style == seg.style {
703            last.text.push_str(&seg.text);
704            return;
705        }
706    }
707    cur.push(seg);
708}
709
710fn apply_effects(mut style: InlineTextStyle, effects: Effects) -> InlineTextStyle {
711    if effects.contains(Effects::BOLD) {
712        style = style.bold();
713    }
714    if effects.contains(Effects::ITALIC) {
715        style = style.italic();
716    }
717    if effects.contains(Effects::UNDERLINE) {
718        style = style.underline();
719    }
720    if effects.contains(Effects::DIMMED) {
721        style = style.dim();
722    }
723    if effects.contains(Effects::STRIKETHROUGH) {
724        style.effects |= Effects::STRIKETHROUGH;
725    }
726    style
727}
728
729#[cfg(test)]
730mod tests {
731    use super::*;
732
733    fn line_text(line: &[InlineSegment]) -> String {
734        line.iter().map(|s| s.text.as_str()).collect()
735    }
736
737    #[test]
738    fn unordered_list_has_markers() {
739        let out = render_markdown("- a\n- b\n", 200);
740        // Find the lines that contain "a" and "b".
741        let combined: Vec<String> = out.iter().map(|l| line_text(l)).collect();
742        let line_a = combined
743            .iter()
744            .find(|l| l.contains('a'))
745            .expect("line with 'a'");
746        let line_b = combined
747            .iter()
748            .find(|l| l.contains('b'))
749            .expect("line with 'b'");
750        assert!(line_a.contains('\u{2022}'), "missing bullet in: {line_a:?}");
751        assert!(line_b.contains('\u{2022}'), "missing bullet in: {line_b:?}");
752    }
753
754    #[test]
755    fn ordered_list_has_numbers() {
756        let out = render_markdown("1. first\n2. second\n", 200);
757        let combined: Vec<String> = out.iter().map(|l| line_text(l)).collect();
758        let has_one = combined
759            .iter()
760            .any(|l| l.contains("1.") && l.contains("first"));
761        let has_two = combined
762            .iter()
763            .any(|l| l.contains("2.") && l.contains("second"));
764        assert!(has_one, "missing '1.' marker in {combined:?}");
765        assert!(has_two, "missing '2.' marker in {combined:?}");
766    }
767
768    #[test]
769    fn table_renders_borders() {
770        let md = "| h1 | h2 |\n|----|----|\n| a  | b  |\n| c  | d  |\n";
771        let out = render_markdown(md, 200);
772        let combined: Vec<String> = out.iter().map(|l| line_text(l)).collect();
773        let bar_lines = combined.iter().filter(|l| l.contains('\u{2502}')).count();
774        assert!(bar_lines >= 3, "expected ≥3 lines with │, got {combined:?}");
775        let has_top_or_bottom = combined
776            .iter()
777            .any(|l| l.contains('\u{250C}') || l.contains('\u{2514}'));
778        assert!(
779            has_top_or_bottom,
780            "expected ┌ or └ in output, got {combined:?}"
781        );
782    }
783
784    #[test]
785    fn inline_still_works() {
786        let out = render_markdown("**bold**", 200);
787        let bold_found = out.iter().any(|line| {
788            line.iter()
789                .any(|seg| seg.style.effects.contains(anstyle::Effects::BOLD))
790        });
791        assert!(bold_found, "expected BOLD effect in rendered segments");
792    }
793    #[test]
794    fn table_cell_keeps_inline_code() {
795        // Inline code (backticks) arrives as Event::Code, not Event::Text — the
796        // table router must capture it or the cell renders blank.
797        let md = "| type | example |\n|------|----------|\n| foo  | `bar`    |\n";
798        let out = render_markdown(md, 200);
799        let joined: String = out
800            .iter()
801            .map(|l| line_text(l))
802            .collect::<Vec<_>>()
803            .join("\n");
804        assert!(
805            joined.contains("bar"),
806            "inline code `bar` dropped from table cell: {joined:?}"
807        );
808    }
809
810    #[test]
811    fn table_cjk_columns_align() {
812        // Wide chars (CJK) have display width 2; padding must use display width
813        // so every data row has the same width and the │ borders line up.
814        let md = "| a | b  |\n|---|----|\n| 中 | x  |\n| 1 | yy |\n";
815        let out = render_markdown(md, 200);
816        let rows: Vec<String> = out
817            .iter()
818            .map(|l| line_text(l))
819            .filter(|l| l.starts_with('\u{2502}'))
820            .collect();
821        let widths: Vec<usize> = rows
822            .iter()
823            .map(|l| unicode_width::UnicodeWidthStr::width(l.as_str()))
824            .collect();
825        let first = widths[0];
826        assert!(
827            widths.iter().all(|&w| w == first),
828            "CJK column misalignment — row display widths differ: {widths:?}\n{rows:?}"
829        );
830    }
831
832    #[test]
833    fn table_fits_the_given_width_and_wraps_cells() {
834        // — natural width overflows — must shrink to fit. Border rows
835        // never exceed the width, and the long cell wraps inside its
836        // column instead of breaking the table.
837        let md = "\
838| colA | colB |\n\
839|------|------|\n\
840| alpha | xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx |\n";
841        let width = 30usize;
842        let out = render_markdown(md, width);
843        let rows: Vec<String> = out.iter().map(|l| line_text(l)).collect();
844        assert!(!rows.is_empty(), "table produced no rows");
845        for (i, row) in rows.iter().enumerate() {
846            let w = unicode_width::UnicodeWidthStr::width(row.as_str());
847            assert!(w <= width, "row {i} overflows: {w} > {width}\n{row}");
848        }
849        let borders: Vec<usize> = rows
850            .iter()
851            .filter(|r| r.starts_with('┌') || r.starts_with('├') || r.starts_with('└'))
852            .map(|r| unicode_width::UnicodeWidthStr::width(r.as_str()))
853            .collect();
854        assert_eq!(borders.len(), 3, "top/mid/bottom borders");
855        assert!(
856            borders.iter().all(|&w| w == borders[0]),
857            "border widths differ: {borders:?}"
858        );
859        // The long cell must have wrapped into multiple physical rows.
860        let data_rows = rows.iter().filter(|r| r.starts_with('│')).count();
861        assert!(
862            data_rows > 1,
863            "the long cell should wrap to multiple rows, got {data_rows}\n{rows:?}"
864        );
865    }
866
867    #[test]
868    fn table_narrower_than_viewport_keeps_natural_width() {
869        let md = "| a | b |\n|---|---|\n| 1 | 2 |\n";
870        let out = render_markdown(md, 200);
871        let top = out
872            .iter()
873            .map(|l| line_text(l))
874            .find(|l| l.starts_with('┌'))
875            .expect("top border");
876        assert!(
877            unicode_width::UnicodeWidthStr::width(top.as_str()) <= 200,
878            "natural width exceeds viewport"
879        );
880    }
881    #[test]
882    fn code_block_hard_wraps_to_given_width() {
883        // 200-char ASCII line inside a ``` fence — must wrap to the
884        // requested width and never overflow it. Concatenated row text
885        // contains the full original line so the wrap is lossless.
886        let line: String = "a".repeat(200);
887        let md = format!("```\n{line}\n```\n");
888        let out = render_markdown(&md, 80);
889        let rows: Vec<String> = out.iter().map(|l| line_text(l)).collect();
890        assert!(!rows.is_empty(), "code block produced no rows");
891        for (i, row) in rows.iter().enumerate() {
892            let w = unicode_width::UnicodeWidthStr::width(row.as_str());
893            assert!(w <= 80, "code row {i} overflows: {w} > 80\n{row}");
894        }
895        let joined: String = rows.join("");
896        assert!(
897            joined.contains(&line),
898            "concatenated code rows must contain the full original line\njoined={joined:?}\nwant={line:?}"
899        );
900    }
901
902    #[test]
903    fn code_block_width_zero_keeps_natural_lines() {
904        // width == 0 preserves the old "no wrap" behavior so existing
905        // callers (tests, internal markdown channels) keep working.
906        let line: String = "z".repeat(40);
907        let md = format!("```\n{line}\n```\n");
908        let out = render_markdown(&md, 0);
909        let rows: Vec<String> = out.iter().map(|l| line_text(l)).collect();
910        assert!(
911            rows.iter().any(|r| r.contains(&line)),
912            "width 0 must preserve natural-length lines, got {rows:?}"
913        );
914    }
915
916    // ── T6: incremental streaming markdown render cache ───────────────────
917
918    /// Flatten a rendered line/segment list to a row of plain text
919    /// so we can compare two renderings for equality on `text` alone
920    /// without depending on `InlineSegment: PartialEq` (which is not
921    /// derived on the protocol type — see
922    /// `oxicode_vtui_compat::ui_protocol::style::InlineSegment`).
923    fn flatten_lines(lines: &[Vec<InlineSegment>]) -> Vec<String> {
924        lines.iter().map(|l| line_text(l)).collect()
925    }
926
927    #[test]
928    fn cached_prefix_reuses_lines() {
929        // First render populates the cache (cold).
930        let mut cache = MdRenderCache::default();
931        let _ = render_markdown_cached("hello", 80, &mut cache);
932        assert_eq!(
933            cache.debug_hits(),
934            0,
935            "first call is a cold render (no cache hit)"
936        );
937
938        // Identical (text, width) pair → fast-path hit; the hit counter
939        // increments and the returned lines equal the cold render.
940        let again = render_markdown_cached("hello", 80, &mut cache);
941        assert_eq!(
942            cache.debug_hits(),
943            1,
944            "identical input must register a cache hit"
945        );
946        assert_eq!(
947            flatten_lines(&again),
948            flatten_lines(&render_markdown("hello", 80)),
949            "fast-path output equals fresh render"
950        );
951
952        // Append tokens (streaming assistant flow) → cache updates, no
953        // hit; a third identical call again hits the cache.
954        let _ = render_markdown_cached("hello world", 80, &mut cache);
955        assert_eq!(
956            cache.debug_hits(),
957            1,
958            "different text does not register a hit"
959        );
960        let _ = render_markdown_cached("hello world", 80, &mut cache);
961        assert_eq!(
962            cache.debug_hits(),
963            2,
964            "second identical call after a miss must hit again"
965        );
966    }
967
968    #[test]
969    fn cached_result_equals_fresh_render() {
970        // Property: for a streaming assistant flow that appends tokens
971        // across 5 frames, every cached output equals the fresh render
972        // for the same (text, width). The cache must never produce
973        // divergent lines from the source renderer.
974        let mut cache = MdRenderCache::default();
975        let base = "The quick brown fox jumps over the lazy dog.";
976        let appends = ["", " Stream chunk one.", " More.", " Even more."];
977        let mut text = base.to_string();
978        let width = 40usize;
979        for (i, suffix) in std::iter::once("")
980            .chain(appends.iter().copied())
981            .enumerate()
982        {
983            if i > 0 {
984                text.push_str(suffix);
985            }
986            let cached = render_markdown_cached(&text, width, &mut cache);
987            let fresh = render_markdown(&text, width);
988            assert_eq!(
989                flatten_lines(&cached),
990                flatten_lines(&fresh),
991                "cached output diverges from fresh render at step {i}: text={text:?}"
992            );
993        }
994    }
995
996    #[test]
997    fn width_change_busts_cache() {
998        // First render at width 80.
999        let mut cache = MdRenderCache::default();
1000        let _baseline = render_markdown_cached("# title\n\nbody", 80, &mut cache);
1001        assert_eq!(cache.debug_hits(), 0);
1002
1003        // Same text at width 40 must NOT hit — the wrapped output
1004        // differs and the cache must invalidate.
1005        let narrowed = render_markdown_cached("# title\n\nbody", 40, &mut cache);
1006        assert_eq!(
1007            cache.debug_hits(),
1008            0,
1009            "width change must NOT register a fast-path hit"
1010        );
1011        assert_eq!(
1012            flatten_lines(&narrowed),
1013            flatten_lines(&render_markdown("# title\n\nbody", 40)),
1014            "narrowed output equals fresh render"
1015        );
1016
1017        // Same text at the original width 80 — first time after the
1018        // bust, this is again a miss; second identical call hits.
1019        let _ = render_markdown_cached("# title\n\nbody", 80, &mut cache);
1020        assert_eq!(cache.debug_hits(), 0, "miss after width bust");
1021        let _ = render_markdown_cached("# title\n\nbody", 80, &mut cache);
1022        assert_eq!(
1023            cache.debug_hits(),
1024            1,
1025            "subsequent identical input must hit again"
1026        );
1027    }
1028}