vtcode_core/ui/
markdown.rs

1//! Markdown rendering utilities for terminal output with syntax highlighting support.
2
3use crate::config::loader::SyntaxHighlightingConfig;
4use crate::ui::theme::ThemeStyles;
5use anstyle::Style;
6use anstyle_syntect::to_anstyle;
7use once_cell::sync::Lazy;
8use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag};
9use std::cmp::max;
10use std::collections::HashMap;
11use syntect::easy::HighlightLines;
12use syntect::highlighting::{Theme, ThemeSet};
13use syntect::parsing::{SyntaxReference, SyntaxSet};
14use syntect::util::LinesWithEndings;
15use tracing::warn;
16
17const LIST_INDENT_WIDTH: usize = 2;
18const CODE_EXTRA_INDENT: &str = "    ";
19const MAX_THEME_CACHE_SIZE: usize = 32;
20
21static SYNTAX_SET: Lazy<SyntaxSet> = Lazy::new(SyntaxSet::load_defaults_newlines);
22static THEME_CACHE: Lazy<parking_lot::RwLock<HashMap<String, Theme>>> = Lazy::new(|| {
23    let defaults = ThemeSet::load_defaults();
24    let mut entries: Vec<(String, Theme)> = defaults.themes.into_iter().collect();
25    if entries.len() > MAX_THEME_CACHE_SIZE {
26        entries.truncate(MAX_THEME_CACHE_SIZE);
27    }
28    let themes: HashMap<_, _> = entries.into_iter().collect();
29    parking_lot::RwLock::new(themes)
30});
31
32/// A styled text segment.
33#[derive(Clone, Debug)]
34pub struct MarkdownSegment {
35    pub style: Style,
36    pub text: String,
37}
38
39impl MarkdownSegment {
40    pub(crate) fn new(style: Style, text: impl Into<String>) -> Self {
41        Self {
42            style,
43            text: text.into(),
44        }
45    }
46}
47
48/// A rendered line composed of styled segments.
49#[derive(Clone, Debug, Default)]
50pub struct MarkdownLine {
51    pub segments: Vec<MarkdownSegment>,
52}
53
54impl MarkdownLine {
55    fn push_segment(&mut self, style: Style, text: &str) {
56        if text.is_empty() {
57            return;
58        }
59        if let Some(last) = self.segments.last_mut() {
60            if last.style == style {
61                last.text.push_str(text);
62                return;
63            }
64        }
65        self.segments.push(MarkdownSegment::new(style, text));
66    }
67
68    fn prepend_segments(&mut self, segments: &[PrefixSegment]) {
69        if segments.is_empty() {
70            return;
71        }
72        let mut prefixed = Vec::with_capacity(segments.len() + self.segments.len());
73        for segment in segments {
74            prefixed.push(MarkdownSegment::new(segment.style, segment.text.clone()));
75        }
76        prefixed.append(&mut self.segments);
77        self.segments = prefixed;
78    }
79
80    pub(crate) fn is_empty(&self) -> bool {
81        self.segments
82            .iter()
83            .all(|segment| segment.text.trim().is_empty())
84    }
85}
86
87#[derive(Clone, Debug)]
88struct PrefixSegment {
89    style: Style,
90    text: String,
91}
92
93impl PrefixSegment {
94    fn new(style: Style, text: impl Into<String>) -> Self {
95        Self {
96            style,
97            text: text.into(),
98        }
99    }
100}
101
102#[derive(Clone, Debug)]
103struct CodeBlockState {
104    language: Option<String>,
105    buffer: String,
106}
107
108#[derive(Clone, Debug)]
109struct ListState {
110    kind: ListKind,
111    depth: usize,
112    continuation: String,
113}
114
115#[derive(Clone, Debug)]
116enum ListKind {
117    Unordered,
118    Ordered { next: usize },
119}
120
121/// Render markdown text to styled lines that can be written to the terminal renderer.
122pub fn render_markdown_to_lines(
123    source: &str,
124    base_style: Style,
125    theme_styles: &ThemeStyles,
126    highlight_config: Option<&SyntaxHighlightingConfig>,
127) -> Vec<MarkdownLine> {
128    let options = Options::ENABLE_STRIKETHROUGH
129        | Options::ENABLE_TABLES
130        | Options::ENABLE_TASKLISTS
131        | Options::ENABLE_FOOTNOTES;
132    let parser = Parser::new_ext(source, options);
133
134    let mut lines = Vec::new();
135    let mut current_line = MarkdownLine::default();
136    let mut style_stack = vec![base_style];
137    let mut blockquote_depth = 0usize;
138    let mut list_stack: Vec<ListState> = Vec::new();
139    let mut pending_list_prefix: Option<String> = None;
140    let mut code_block: Option<CodeBlockState> = None;
141
142    for event in parser {
143        if let Some(state) = code_block.as_mut() {
144            match event {
145                Event::Text(text) => {
146                    state.buffer.push_str(&text);
147                    continue;
148                }
149                Event::End(Tag::CodeBlock(_)) => {
150                    flush_current_line(
151                        &mut lines,
152                        &mut current_line,
153                        blockquote_depth,
154                        &list_stack,
155                        &mut pending_list_prefix,
156                        theme_styles,
157                        base_style,
158                    );
159                    let prefix = build_prefix_segments(
160                        blockquote_depth,
161                        &list_stack,
162                        theme_styles,
163                        base_style,
164                    );
165                    let highlighted = highlight_code_block(
166                        &state.buffer,
167                        state.language.as_deref(),
168                        highlight_config,
169                        theme_styles,
170                        base_style,
171                        &prefix,
172                    );
173                    lines.extend(highlighted);
174                    push_blank_line(&mut lines);
175                    code_block = None;
176                    continue;
177                }
178                _ => {}
179            }
180        }
181
182        match event {
183            Event::Start(tag) => handle_start_tag(
184                tag,
185                &mut style_stack,
186                &mut blockquote_depth,
187                &mut list_stack,
188                &mut pending_list_prefix,
189                theme_styles,
190                base_style,
191                &mut code_block,
192            ),
193            Event::End(tag) => handle_end_tag(
194                tag,
195                &mut style_stack,
196                &mut blockquote_depth,
197                &mut list_stack,
198                &mut pending_list_prefix,
199                &mut lines,
200                &mut current_line,
201            ),
202            Event::Text(text) => append_text(
203                &text,
204                &mut current_line,
205                &mut lines,
206                &style_stack,
207                blockquote_depth,
208                &list_stack,
209                &mut pending_list_prefix,
210                theme_styles,
211                base_style,
212            ),
213            Event::Code(code_text) => {
214                ensure_prefix(
215                    &mut current_line,
216                    blockquote_depth,
217                    &list_stack,
218                    &mut pending_list_prefix,
219                    theme_styles,
220                    base_style,
221                );
222                current_line.push_segment(inline_code_style(theme_styles, base_style), &code_text);
223            }
224            Event::SoftBreak => {
225                append_text(
226                    " ",
227                    &mut current_line,
228                    &mut lines,
229                    &style_stack,
230                    blockquote_depth,
231                    &list_stack,
232                    &mut pending_list_prefix,
233                    theme_styles,
234                    base_style,
235                );
236            }
237            Event::HardBreak => {
238                flush_current_line(
239                    &mut lines,
240                    &mut current_line,
241                    blockquote_depth,
242                    &list_stack,
243                    &mut pending_list_prefix,
244                    theme_styles,
245                    base_style,
246                );
247            }
248            Event::Rule => {
249                flush_current_line(
250                    &mut lines,
251                    &mut current_line,
252                    blockquote_depth,
253                    &list_stack,
254                    &mut pending_list_prefix,
255                    theme_styles,
256                    base_style,
257                );
258                let mut line = MarkdownLine::default();
259                let rule_style = theme_styles.secondary.bold();
260                line.push_segment(rule_style, "―".repeat(32).as_str());
261                lines.push(line);
262                push_blank_line(&mut lines);
263            }
264            Event::TaskListMarker(checked) => {
265                ensure_prefix(
266                    &mut current_line,
267                    blockquote_depth,
268                    &list_stack,
269                    &mut pending_list_prefix,
270                    theme_styles,
271                    base_style,
272                );
273                let marker = if checked { "[x] " } else { "[ ] " };
274                current_line.push_segment(base_style, marker);
275            }
276            Event::Html(html) => append_text(
277                &html,
278                &mut current_line,
279                &mut lines,
280                &style_stack,
281                blockquote_depth,
282                &list_stack,
283                &mut pending_list_prefix,
284                theme_styles,
285                base_style,
286            ),
287            Event::FootnoteReference(reference) => append_text(
288                &format!("[^{}]", reference),
289                &mut current_line,
290                &mut lines,
291                &style_stack,
292                blockquote_depth,
293                &list_stack,
294                &mut pending_list_prefix,
295                theme_styles,
296                base_style,
297            ),
298        }
299    }
300
301    if let Some(state) = code_block {
302        flush_current_line(
303            &mut lines,
304            &mut current_line,
305            blockquote_depth,
306            &list_stack,
307            &mut pending_list_prefix,
308            theme_styles,
309            base_style,
310        );
311        let prefix = build_prefix_segments(blockquote_depth, &list_stack, theme_styles, base_style);
312        let highlighted = highlight_code_block(
313            &state.buffer,
314            state.language.as_deref(),
315            highlight_config,
316            theme_styles,
317            base_style,
318            &prefix,
319        );
320        lines.extend(highlighted);
321    }
322
323    if !current_line.segments.is_empty() {
324        lines.push(current_line);
325    }
326
327    trim_trailing_blank_lines(&mut lines);
328    lines
329}
330
331fn handle_start_tag(
332    tag: Tag,
333    style_stack: &mut Vec<Style>,
334    blockquote_depth: &mut usize,
335    list_stack: &mut Vec<ListState>,
336    pending_list_prefix: &mut Option<String>,
337    theme_styles: &ThemeStyles,
338    base_style: Style,
339    code_block: &mut Option<CodeBlockState>,
340) {
341    match tag {
342        Tag::Paragraph => {}
343        Tag::Heading(level, ..) => {
344            style_stack.push(heading_style(level, theme_styles, base_style));
345        }
346        Tag::BlockQuote => {
347            *blockquote_depth += 1;
348        }
349        Tag::List(start) => {
350            let depth = list_stack.len();
351            let kind = start
352                .map(|value| ListKind::Ordered {
353                    next: max(1, value as usize),
354                })
355                .unwrap_or(ListKind::Unordered);
356            list_stack.push(ListState {
357                kind,
358                depth,
359                continuation: String::new(),
360            });
361        }
362        Tag::Item => {
363            if let Some(state) = list_stack.last_mut() {
364                let indent = " ".repeat(state.depth * LIST_INDENT_WIDTH);
365                match &mut state.kind {
366                    ListKind::Unordered => {
367                        let bullet = format!("{}- ", indent);
368                        state.continuation = format!("{}  ", indent);
369                        *pending_list_prefix = Some(bullet);
370                    }
371                    ListKind::Ordered { next } => {
372                        let bullet = format!("{}{}. ", indent, *next);
373                        let width = bullet.len().saturating_sub(indent.len());
374                        state.continuation = format!("{}{}", indent, " ".repeat(width));
375                        *pending_list_prefix = Some(bullet);
376                        *next += 1;
377                    }
378                }
379            }
380        }
381        Tag::Emphasis => {
382            let style = style_stack.last().copied().unwrap_or(base_style).italic();
383            style_stack.push(style);
384        }
385        Tag::Strong => {
386            let style = style_stack.last().copied().unwrap_or(base_style).bold();
387            style_stack.push(style);
388        }
389        Tag::Strikethrough => {
390            let style = style_stack
391                .last()
392                .copied()
393                .unwrap_or(base_style)
394                .strikethrough();
395            style_stack.push(style);
396        }
397        Tag::Link { .. } | Tag::Image { .. } => {
398            let style = style_stack
399                .last()
400                .copied()
401                .unwrap_or(base_style)
402                .underline();
403            style_stack.push(style);
404        }
405        Tag::CodeBlock(kind) => {
406            let language = match kind {
407                CodeBlockKind::Fenced(info) => info
408                    .split_whitespace()
409                    .next()
410                    .filter(|lang| !lang.is_empty())
411                    .map(|lang| lang.to_string()),
412                CodeBlockKind::Indented => None,
413            };
414            *code_block = Some(CodeBlockState {
415                language,
416                buffer: String::new(),
417            });
418        }
419        _ => {}
420    }
421}
422
423fn handle_end_tag(
424    tag: Tag,
425    style_stack: &mut Vec<Style>,
426    blockquote_depth: &mut usize,
427    list_stack: &mut Vec<ListState>,
428    pending_list_prefix: &mut Option<String>,
429    lines: &mut Vec<MarkdownLine>,
430    current_line: &mut MarkdownLine,
431) {
432    match tag {
433        Tag::Paragraph => {
434            if !current_line.segments.is_empty() {
435                lines.push(std::mem::take(current_line));
436            }
437            push_blank_line(lines);
438        }
439        Tag::Heading(..) => {
440            if !current_line.segments.is_empty() {
441                lines.push(std::mem::take(current_line));
442            }
443            push_blank_line(lines);
444            style_stack.pop();
445        }
446        Tag::BlockQuote => {
447            if *blockquote_depth > 0 {
448                *blockquote_depth -= 1;
449            }
450        }
451        Tag::List(_) => {
452            list_stack.pop();
453            *pending_list_prefix = None;
454            if !current_line.segments.is_empty() {
455                lines.push(std::mem::take(current_line));
456            }
457            push_blank_line(lines);
458        }
459        Tag::Item => {
460            if !current_line.segments.is_empty() {
461                lines.push(std::mem::take(current_line));
462            }
463            *pending_list_prefix = None;
464        }
465        Tag::Emphasis | Tag::Strong | Tag::Strikethrough | Tag::Link { .. } | Tag::Image { .. } => {
466            style_stack.pop();
467        }
468        Tag::CodeBlock(_) => {}
469        Tag::Table(_)
470        | Tag::TableHead
471        | Tag::TableRow
472        | Tag::TableCell
473        | Tag::FootnoteDefinition(_) => {}
474    }
475}
476
477fn append_text(
478    text: &str,
479    current_line: &mut MarkdownLine,
480    lines: &mut Vec<MarkdownLine>,
481    style_stack: &[Style],
482    blockquote_depth: usize,
483    list_stack: &[ListState],
484    pending_list_prefix: &mut Option<String>,
485    theme_styles: &ThemeStyles,
486    base_style: Style,
487) {
488    let style = style_stack.last().copied().unwrap_or(base_style);
489
490    let mut start = 0usize;
491    let mut chars = text.char_indices().peekable();
492    while let Some((idx, ch)) = chars.next() {
493        if ch == '\n' {
494            let segment = &text[start..idx];
495            if !segment.is_empty() {
496                ensure_prefix(
497                    current_line,
498                    blockquote_depth,
499                    list_stack,
500                    pending_list_prefix,
501                    theme_styles,
502                    base_style,
503                );
504                current_line.push_segment(style, segment);
505            }
506            lines.push(std::mem::take(current_line));
507            start = idx + ch.len_utf8();
508        }
509    }
510
511    if start < text.len() {
512        let remaining = &text[start..];
513        ensure_prefix(
514            current_line,
515            blockquote_depth,
516            list_stack,
517            pending_list_prefix,
518            theme_styles,
519            base_style,
520        );
521        current_line.push_segment(style, remaining);
522    }
523}
524
525fn ensure_prefix(
526    current_line: &mut MarkdownLine,
527    blockquote_depth: usize,
528    list_stack: &[ListState],
529    pending_list_prefix: &mut Option<String>,
530    theme_styles: &ThemeStyles,
531    base_style: Style,
532) {
533    if !current_line.segments.is_empty() {
534        return;
535    }
536
537    for _ in 0..blockquote_depth {
538        current_line.push_segment(theme_styles.secondary.italic(), "│ ");
539    }
540
541    if let Some(prefix) = pending_list_prefix.take() {
542        current_line.push_segment(base_style, &prefix);
543    } else if !list_stack.is_empty() {
544        let mut continuation = String::new();
545        for state in list_stack {
546            continuation.push_str(&state.continuation);
547        }
548        if !continuation.is_empty() {
549            current_line.push_segment(base_style, &continuation);
550        }
551    }
552}
553
554fn flush_current_line(
555    lines: &mut Vec<MarkdownLine>,
556    current_line: &mut MarkdownLine,
557    blockquote_depth: usize,
558    list_stack: &[ListState],
559    pending_list_prefix: &mut Option<String>,
560    theme_styles: &ThemeStyles,
561    base_style: Style,
562) {
563    if current_line.segments.is_empty() {
564        if pending_list_prefix.is_some() {
565            ensure_prefix(
566                current_line,
567                blockquote_depth,
568                list_stack,
569                pending_list_prefix,
570                theme_styles,
571                base_style,
572            );
573        }
574    }
575
576    if !current_line.segments.is_empty() {
577        lines.push(std::mem::take(current_line));
578    }
579}
580
581fn push_blank_line(lines: &mut Vec<MarkdownLine>) {
582    if lines
583        .last()
584        .map(|line| line.segments.is_empty())
585        .unwrap_or(false)
586    {
587        return;
588    }
589    lines.push(MarkdownLine::default());
590}
591
592fn trim_trailing_blank_lines(lines: &mut Vec<MarkdownLine>) {
593    while lines
594        .last()
595        .map(|line| line.segments.is_empty())
596        .unwrap_or(false)
597    {
598        lines.pop();
599    }
600}
601
602fn inline_code_style(theme_styles: &ThemeStyles, base_style: Style) -> Style {
603    let fg = theme_styles
604        .secondary
605        .get_fg_color()
606        .or_else(|| base_style.get_fg_color());
607    let bg = Some(theme_styles.background.into());
608    let mut style = base_style;
609    if let Some(fg_color) = fg {
610        style = style.fg_color(Some(fg_color));
611    }
612    style.bg_color(bg).bold()
613}
614
615fn heading_style(level: HeadingLevel, theme_styles: &ThemeStyles, base_style: Style) -> Style {
616    match level {
617        HeadingLevel::H1 => theme_styles.primary.bold().underline(),
618        HeadingLevel::H2 => theme_styles.primary.bold(),
619        HeadingLevel::H3 => theme_styles.secondary.bold(),
620        _ => base_style.bold(),
621    }
622}
623
624fn build_prefix_segments(
625    blockquote_depth: usize,
626    list_stack: &[ListState],
627    theme_styles: &ThemeStyles,
628    base_style: Style,
629) -> Vec<PrefixSegment> {
630    let mut segments = Vec::new();
631    for _ in 0..blockquote_depth {
632        segments.push(PrefixSegment::new(theme_styles.secondary.italic(), "│ "));
633    }
634    if !list_stack.is_empty() {
635        let mut continuation = String::new();
636        for state in list_stack {
637            continuation.push_str(&state.continuation);
638        }
639        if !continuation.is_empty() {
640            segments.push(PrefixSegment::new(base_style, continuation));
641        }
642    }
643    segments
644}
645
646fn highlight_code_block(
647    code: &str,
648    language: Option<&str>,
649    highlight_config: Option<&SyntaxHighlightingConfig>,
650    theme_styles: &ThemeStyles,
651    base_style: Style,
652    prefix_segments: &[PrefixSegment],
653) -> Vec<MarkdownLine> {
654    let mut lines = Vec::new();
655    let mut augmented_prefix = prefix_segments.to_vec();
656    augmented_prefix.push(PrefixSegment::new(base_style, CODE_EXTRA_INDENT));
657
658    if let Some(config) = highlight_config.filter(|cfg| cfg.enabled) {
659        if let Some(highlighted) = try_highlight(code, language, config) {
660            for segments in highlighted {
661                let mut line = MarkdownLine::default();
662                line.prepend_segments(&augmented_prefix);
663                for (style, text) in segments {
664                    line.push_segment(style, &text);
665                }
666                lines.push(line);
667            }
668            return lines;
669        }
670    }
671
672    for raw_line in LinesWithEndings::from(code) {
673        let trimmed = raw_line.trim_end_matches('\n');
674        let mut line = MarkdownLine::default();
675        line.prepend_segments(&augmented_prefix);
676        if !trimmed.is_empty() {
677            line.push_segment(code_block_style(theme_styles, base_style), trimmed);
678        }
679        lines.push(line);
680    }
681
682    if code.ends_with('\n') {
683        let mut line = MarkdownLine::default();
684        line.prepend_segments(&augmented_prefix);
685        lines.push(line);
686    }
687
688    lines
689}
690
691fn code_block_style(theme_styles: &ThemeStyles, base_style: Style) -> Style {
692    let fg = theme_styles
693        .output
694        .get_fg_color()
695        .or_else(|| base_style.get_fg_color());
696    let mut style = base_style;
697    if let Some(color) = fg {
698        style = style.fg_color(Some(color));
699    }
700    style
701}
702
703fn try_highlight(
704    code: &str,
705    language: Option<&str>,
706    config: &SyntaxHighlightingConfig,
707) -> Option<Vec<Vec<(Style, String)>>> {
708    let max_bytes = config.max_file_size_mb.saturating_mul(1024 * 1024);
709    if max_bytes > 0 && code.len() > max_bytes {
710        return None;
711    }
712
713    if let Some(lang) = language {
714        let enabled = config
715            .enabled_languages
716            .iter()
717            .any(|entry| entry.eq_ignore_ascii_case(lang));
718        if !enabled {
719            return None;
720        }
721    }
722
723    let syntax = select_syntax(language);
724    let theme = load_theme(&config.theme, config.cache_themes);
725    let mut highlighter = HighlightLines::new(syntax, &theme);
726    let mut rendered = Vec::new();
727
728    let mut ends_with_newline = false;
729    for line in LinesWithEndings::from(code) {
730        ends_with_newline = line.ends_with('\n');
731        let trimmed = line.trim_end_matches('\n');
732        let ranges = highlighter.highlight_line(trimmed, &SYNTAX_SET).ok()?;
733        let mut segments = Vec::new();
734        for (style, part) in ranges {
735            if part.is_empty() {
736                continue;
737            }
738            segments.push((to_anstyle(style), part.to_string()));
739        }
740        rendered.push(segments);
741    }
742
743    if ends_with_newline {
744        rendered.push(Vec::new());
745    }
746
747    Some(rendered)
748}
749
750fn select_syntax(language: Option<&str>) -> &'static SyntaxReference {
751    language
752        .and_then(|lang| SYNTAX_SET.find_syntax_by_token(lang))
753        .unwrap_or_else(|| SYNTAX_SET.find_syntax_plain_text())
754}
755
756fn load_theme(theme_name: &str, cache: bool) -> Theme {
757    if let Some(theme) = THEME_CACHE.read().get(theme_name).cloned() {
758        return theme;
759    }
760
761    let defaults = ThemeSet::load_defaults();
762    if let Some(theme) = defaults.themes.get(theme_name).cloned() {
763        if cache {
764            let mut guard = THEME_CACHE.write();
765            if guard.len() >= MAX_THEME_CACHE_SIZE {
766                if let Some(first_key) = guard.keys().next().cloned() {
767                    guard.remove(&first_key);
768                }
769            }
770            guard.insert(theme_name.to_string(), theme.clone());
771        }
772        theme
773    } else {
774        warn!(
775            "theme" = theme_name,
776            "Falling back to default syntax highlighting theme"
777        );
778        defaults
779            .themes
780            .into_iter()
781            .next()
782            .map(|(_, theme)| theme)
783            .unwrap_or_default()
784    }
785}