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::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/// Parse markdown text into styled InlineSegment lines.
16pub fn render_markdown(text: &str) -> Vec<Vec<InlineSegment>> {
17    let mut opts = Options::empty();
18    opts.insert(Options::ENABLE_TABLES);
19    opts.insert(Options::ENABLE_STRIKETHROUGH);
20    opts.insert(Options::ENABLE_HEADING_ATTRIBUTES);
21
22    let mut lines: Vec<Vec<InlineSegment>> = Vec::new();
23    let mut cur: Vec<InlineSegment> = Vec::new();
24    let mut effects: Effects = Effects::default();
25
26    let mut code_buf: Option<CodeBlockState> = None;
27    let mut table_buf: Option<TableState> = None;
28    let mut list_stack: Vec<ListLevel> = Vec::new();
29    let ss = &*SYNTAX_SET;
30
31    for event in Parser::new_ext(text, opts) {
32        if let Some(tb) = &mut table_buf {
33            match event {
34                Event::Text(t) | Event::Html(t) | Event::Code(t) => tb.current_cell.push_str(&t),
35                Event::End(TagEnd::TableCell) => {
36                    tb.current_row.push(std::mem::take(&mut tb.current_cell));
37                }
38                Event::End(TagEnd::TableRow) => {
39                    tb.rows.push(std::mem::take(&mut tb.current_row));
40                }
41                Event::End(TagEnd::TableHead) => {
42                    // Header row was already pushed into `rows` on End(TableRow);
43                    // move it out into `header` and clear rows for the body.
44                    if !tb.rows.is_empty() && tb.header.is_empty() {
45                        tb.header = tb.rows.remove(0);
46                    }
47                }
48                Event::End(TagEnd::Table) => {
49                    let tb = table_buf.take().unwrap();
50                    let table_lines = render_table(&tb.header, &tb.rows);
51                    lines.extend(table_lines);
52                    lines.push(Vec::new());
53                }
54                _ => {}
55            }
56            continue;
57        }
58
59        // Code block capture mode
60        if let Some(cb) = &mut code_buf {
61            match event {
62                Event::Text(t) => cb.code.push_str(&t),
63                Event::End(TagEnd::CodeBlock) => {
64                    let cb = code_buf.take().unwrap();
65                    flush_line(&mut cur, &mut lines);
66                    let block_lines = render_code_block(&cb.code, cb.lang.as_deref(), ss);
67                    lines.extend(block_lines);
68                    lines.push(Vec::new());
69                }
70                _ => {}
71            }
72            continue;
73        }
74
75        // Flat main match
76        match event {
77            // ── Code block ─────────────────────────────────────────────
78            Event::Start(Tag::CodeBlock(kind)) => {
79                flush_line(&mut cur, &mut lines);
80                let lang = match kind {
81                    CodeBlockKind::Fenced(l) => Some(l.to_string()),
82                    CodeBlockKind::Indented => None,
83                };
84                code_buf = Some(CodeBlockState {
85                    code: String::new(),
86                    lang,
87                });
88            }
89
90            // ── Tables ─────────────────────────────────────────────────
91            Event::Start(Tag::Table(_)) => {
92                flush_line(&mut cur, &mut lines);
93                table_buf = Some(TableState::default());
94            }
95
96            // ── Lists ──────────────────────────────────────────────────
97            Event::Start(Tag::List(start)) => {
98                list_stack.push(ListLevel {
99                    is_ordered: start.is_some(),
100                    index: start.map(|n| n.saturating_sub(1)).unwrap_or(0),
101                });
102            }
103            Event::End(TagEnd::List(_)) => {
104                list_stack.pop();
105            }
106            Event::Start(Tag::Item) => {
107                flush_line(&mut cur, &mut lines);
108                let depth = list_stack.len();
109                if let Some(top) = list_stack.last_mut() {
110                    let indent = " ".repeat((depth.saturating_sub(1)) * 2);
111                    let marker = if top.is_ordered {
112                        let n = top.index + 1;
113                        top.index += 1;
114                        format!("{}{}. ", indent, n)
115                    } else {
116                        format!("{}• ", indent)
117                    };
118                    let seg = InlineSegment {
119                        text: marker,
120                        style: Arc::new(InlineTextStyle::default()),
121                    };
122                    merge_or_push(&mut cur, seg);
123                }
124            }
125
126            // ── Block-level ────────────────────────────────────────────
127            Event::Start(Tag::Paragraph) => {}
128
129            Event::Start(Tag::Heading { level, .. }) => {
130                flush_line(&mut cur, &mut lines);
131                {
132                    effects = effects.insert(Effects::BOLD);
133                };
134                {
135                    effects = effects.insert(if level == HeadingLevel::H1 {
136                        Effects::UNDERLINE
137                    } else {
138                        Effects::default()
139                    });
140                }
141            }
142            Event::End(TagEnd::Heading(_)) => {
143                {
144                    effects = effects.remove(Effects::BOLD | Effects::UNDERLINE);
145                };
146                flush_line(&mut cur, &mut lines);
147            }
148
149            Event::Start(Tag::BlockQuote(_)) => {
150                {
151                    effects = effects.insert(Effects::DIMMED);
152                };
153            }
154            Event::End(TagEnd::BlockQuote(_)) => {
155                {
156                    effects = effects.remove(Effects::DIMMED);
157                };
158            }
159
160            Event::End(TagEnd::Paragraph) | Event::End(TagEnd::Item) => {
161                flush_line(&mut cur, &mut lines);
162            }
163
164            Event::Rule => {
165                flush_line(&mut cur, &mut lines);
166                lines.push(vec![InlineSegment {
167                    text: "\u{2500}".repeat(40),
168                    style: Arc::new(InlineTextStyle::default().dim()),
169                }]);
170            }
171
172            // ── Inline formatting ──────────────────────────────────────
173            Event::Start(Tag::Emphasis) => {
174                {
175                    effects = effects.insert(Effects::ITALIC);
176                };
177            }
178            Event::End(TagEnd::Emphasis) => {
179                {
180                    effects = effects.remove(Effects::ITALIC);
181                };
182            }
183
184            Event::Start(Tag::Strong) => {
185                {
186                    effects = effects.insert(Effects::BOLD);
187                };
188            }
189            Event::End(TagEnd::Strong) => {
190                {
191                    effects = effects.remove(Effects::BOLD);
192                };
193            }
194
195            Event::Start(Tag::Strikethrough) => {
196                {
197                    effects = effects.insert(Effects::STRIKETHROUGH);
198                };
199            }
200            Event::End(TagEnd::Strikethrough) => {
201                {
202                    effects = effects.remove(Effects::STRIKETHROUGH);
203                };
204            }
205
206            Event::Start(Tag::Link { .. }) => {
207                {
208                    effects = effects.insert(Effects::UNDERLINE);
209                };
210            }
211            Event::End(TagEnd::Link) => {
212                {
213                    effects = effects.remove(Effects::UNDERLINE);
214                };
215            }
216
217            // ── Text content ───────────────────────────────────────────
218            Event::Text(t) | Event::Html(t) => {
219                let style = apply_effects(InlineTextStyle::default(), effects);
220                let seg = InlineSegment {
221                    text: t.to_string(),
222                    style: Arc::new(style),
223                };
224                merge_or_push(&mut cur, seg);
225            }
226
227            Event::Code(t) => {
228                let seg = InlineSegment {
229                    text: t.to_string(),
230                    style: Arc::new(InlineTextStyle::default().bold()),
231                };
232                merge_or_push(&mut cur, seg);
233            }
234
235            Event::SoftBreak | Event::HardBreak => {
236                flush_line(&mut cur, &mut lines);
237            }
238
239            Event::FootnoteReference(t) => {
240                let seg = InlineSegment {
241                    text: format!("[^{}]", t),
242                    style: Arc::new(InlineTextStyle::default().dim()),
243                };
244                merge_or_push(&mut cur, seg);
245            }
246
247            _ => {}
248        }
249    }
250
251    flush_line(&mut cur, &mut lines);
252    lines
253}
254
255/// Render a code block with syntect syntax highlighting.
256pub fn render_code_block(
257    code: &str,
258    lang: Option<&str>,
259    ss: &SyntaxSet,
260) -> Vec<Vec<InlineSegment>> {
261    let syntax = lang
262        .and_then(|l| ss.find_syntax_by_token(l))
263        .unwrap_or_else(|| ss.find_syntax_plain_text());
264    // syntect's bundled `ThemeSet` ships only 7 themes (base16-*, Solarized,
265    // InspiredGitHub). Many UI themes map to names outside that set; fall back
266    // to a real bundled dark theme rather than the plain `Theme::default()` so
267    // code is always colored (see `theme::syntax::get_active_syntax_theme`).
268    let theme = THEME_SET
269        .themes
270        .get(crate::get_active_syntax_theme())
271        .or_else(|| THEME_SET.themes.get("base16-ocean.dark"))
272        .cloned()
273        .unwrap_or_default();
274    #[allow(unused_mut)]
275    let mut h = HighlightLines::new(syntax, &theme);
276    let mut lines = Vec::new();
277
278    for line in syntect::util::LinesWithEndings::from(code) {
279        if let Ok(ranges) = h.highlight_line(line, ss) {
280            let segs: Vec<InlineSegment> = ranges
281                .into_iter()
282                .map(|(s, t)| {
283                    let fg = s.foreground;
284                    InlineSegment {
285                        text: t.to_string(),
286                        style: Arc::new(
287                            InlineTextStyle::default()
288                                .with_color(Some(AnsiColorEnum::Rgb(RgbColor(fg.r, fg.g, fg.b)))),
289                        ),
290                    }
291                })
292                .collect();
293            lines.push(segs);
294        } else {
295            lines.push(vec![InlineSegment {
296                text: line.to_string(),
297                style: Arc::new(InlineTextStyle::default()),
298            }]);
299        }
300    }
301    lines
302}
303
304/// Render a GFM table with box-drawing borders and natural column widths.
305fn render_table(header: &[String], rows: &[Vec<String>]) -> Vec<Vec<InlineSegment>> {
306    let num_cols = std::cmp::max(
307        header.len(),
308        rows.iter().map(|r| r.len()).max().unwrap_or(0),
309    );
310    if num_cols == 0 {
311        return Vec::new();
312    }
313
314    // Compute natural column widths from display width.
315    let mut col_width: Vec<usize> = vec![0; num_cols];
316    for (c, cell) in header.iter().enumerate() {
317        col_width[c] = std::cmp::max(col_width[c], cell.width());
318    }
319    for row in rows {
320        for (c, cell) in row.iter().enumerate() {
321            col_width[c] = std::cmp::max(col_width[c], cell.width());
322        }
323    }
324
325    let mut out: Vec<Vec<InlineSegment>> = Vec::new();
326
327    // Border builders
328    let top = format!(
329        "┌{}┐",
330        col_width
331            .iter()
332            .map(|w| "─".repeat(w + 2))
333            .collect::<Vec<_>>()
334            .join("┬")
335    );
336    let mid = format!(
337        "├{}┤",
338        col_width
339            .iter()
340            .map(|w| "─".repeat(w + 2))
341            .collect::<Vec<_>>()
342            .join("┼")
343    );
344    let bot = format!(
345        "└{}┘",
346        col_width
347            .iter()
348            .map(|w| "─".repeat(w + 2))
349            .collect::<Vec<_>>()
350            .join("┴")
351    );
352
353    let plain = |s: String| {
354        vec![InlineSegment {
355            text: s,
356            style: Arc::new(InlineTextStyle::default()),
357        }]
358    };
359    let bold = |s: String| {
360        vec![InlineSegment {
361            text: s,
362            style: Arc::new(InlineTextStyle::default().bold()),
363        }]
364    };
365
366    out.push(plain(top));
367
368    // Header row
369    let header_line = format_row(header, &col_width, num_cols);
370    out.push(bold(header_line));
371    out.push(plain(mid));
372
373    // Body rows
374    for row in rows {
375        out.push(plain(format_row(row, &col_width, num_cols)));
376    }
377
378    out.push(plain(bot));
379    out
380}
381
382fn format_row(cells: &[String], col_width: &[usize], num_cols: usize) -> String {
383    let mut parts: Vec<String> = Vec::with_capacity(num_cols);
384    for (c, &w) in col_width.iter().enumerate() {
385        let text = cells.get(c).map(String::as_str).unwrap_or("");
386        // Pad to display width `w` (not scalar count) so CJK / wide chars keep
387        // columns aligned — `{:<width$}` pads by char count and misaligns them.
388        let pad = w.saturating_sub(text.width());
389        parts.push(format!(" {}{} ", text, " ".repeat(pad)));
390    }
391    format!("│{}│", parts.join("│"))
392}
393// ── Helpers ─────────────────────────────────────────────────────────────────
394
395struct CodeBlockState {
396    code: String,
397    lang: Option<String>,
398}
399
400#[derive(Default)]
401struct TableState {
402    header: Vec<String>,
403    rows: Vec<Vec<String>>,
404    current_cell: String,
405    current_row: Vec<String>,
406}
407
408struct ListLevel {
409    is_ordered: bool,
410    index: u64,
411}
412
413fn flush_line(cur: &mut Vec<InlineSegment>, lines: &mut Vec<Vec<InlineSegment>>) {
414    if !cur.is_empty() {
415        lines.push(std::mem::take(cur));
416    }
417}
418
419fn merge_or_push(cur: &mut Vec<InlineSegment>, seg: InlineSegment) {
420    if let Some(last) = cur.last_mut() {
421        if last.style == seg.style {
422            last.text.push_str(&seg.text);
423            return;
424        }
425    }
426    cur.push(seg);
427}
428
429fn apply_effects(mut style: InlineTextStyle, effects: Effects) -> InlineTextStyle {
430    if effects.contains(Effects::BOLD) {
431        style = style.bold();
432    }
433    if effects.contains(Effects::ITALIC) {
434        style = style.italic();
435    }
436    if effects.contains(Effects::UNDERLINE) {
437        style = style.underline();
438    }
439    if effects.contains(Effects::DIMMED) {
440        style = style.dim();
441    }
442    if effects.contains(Effects::STRIKETHROUGH) {
443        style.effects |= Effects::STRIKETHROUGH;
444    }
445    style
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451
452    fn line_text(line: &[InlineSegment]) -> String {
453        line.iter().map(|s| s.text.as_str()).collect()
454    }
455
456    #[test]
457    fn unordered_list_has_markers() {
458        let out = render_markdown("- a\n- b\n");
459        // Find the lines that contain "a" and "b".
460        let combined: Vec<String> = out.iter().map(|l| line_text(l)).collect();
461        let line_a = combined
462            .iter()
463            .find(|l| l.contains('a'))
464            .expect("line with 'a'");
465        let line_b = combined
466            .iter()
467            .find(|l| l.contains('b'))
468            .expect("line with 'b'");
469        assert!(line_a.contains('\u{2022}'), "missing bullet in: {line_a:?}");
470        assert!(line_b.contains('\u{2022}'), "missing bullet in: {line_b:?}");
471    }
472
473    #[test]
474    fn ordered_list_has_numbers() {
475        let out = render_markdown("1. first\n2. second\n");
476        let combined: Vec<String> = out.iter().map(|l| line_text(l)).collect();
477        let has_one = combined
478            .iter()
479            .any(|l| l.contains("1.") && l.contains("first"));
480        let has_two = combined
481            .iter()
482            .any(|l| l.contains("2.") && l.contains("second"));
483        assert!(has_one, "missing '1.' marker in {combined:?}");
484        assert!(has_two, "missing '2.' marker in {combined:?}");
485    }
486
487    #[test]
488    fn table_renders_borders() {
489        let md = "| h1 | h2 |\n|----|----|\n| a  | b  |\n| c  | d  |\n";
490        let out = render_markdown(md);
491        let combined: Vec<String> = out.iter().map(|l| line_text(l)).collect();
492        let bar_lines = combined.iter().filter(|l| l.contains('\u{2502}')).count();
493        assert!(bar_lines >= 3, "expected ≥3 lines with │, got {combined:?}");
494        let has_top_or_bottom = combined
495            .iter()
496            .any(|l| l.contains('\u{250C}') || l.contains('\u{2514}'));
497        assert!(
498            has_top_or_bottom,
499            "expected ┌ or └ in output, got {combined:?}"
500        );
501    }
502
503    #[test]
504    fn inline_still_works() {
505        let out = render_markdown("**bold**");
506        let bold_found = out.iter().any(|line| {
507            line.iter()
508                .any(|seg| seg.style.effects.contains(anstyle::Effects::BOLD))
509        });
510        assert!(bold_found, "expected BOLD effect in rendered segments");
511    }
512    #[test]
513    fn table_cell_keeps_inline_code() {
514        // Inline code (backticks) arrives as Event::Code, not Event::Text — the
515        // table router must capture it or the cell renders blank.
516        let md = "| type | example |\n|------|----------|\n| foo  | `bar`    |\n";
517        let out = render_markdown(md);
518        let joined: String = out
519            .iter()
520            .map(|l| line_text(l))
521            .collect::<Vec<_>>()
522            .join("\n");
523        assert!(
524            joined.contains("bar"),
525            "inline code `bar` dropped from table cell: {joined:?}"
526        );
527    }
528
529    #[test]
530    fn table_cjk_columns_align() {
531        // Wide chars (CJK) have display width 2; padding must use display width
532        // so every data row has the same width and the │ borders line up.
533        let md = "| a | b  |\n|---|----|\n| 中 | x  |\n| 1 | yy |\n";
534        let out = render_markdown(md);
535        let rows: Vec<String> = out
536            .iter()
537            .map(|l| line_text(l))
538            .filter(|l| l.starts_with('\u{2502}'))
539            .collect();
540        let widths: Vec<usize> = rows
541            .iter()
542            .map(|l| unicode_width::UnicodeWidthStr::width(l.as_str()))
543            .collect();
544        let first = widths[0];
545        assert!(
546            widths.iter().all(|&w| w == first),
547            "CJK column misalignment — row display widths differ: {widths:?}\n{rows:?}"
548        );
549    }
550}