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::{self, 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
331/// Convenience helper that renders markdown using the active theme without emitting output.
332///
333/// Returns the styled lines so callers can perform custom handling or assertions in tests.
334pub fn render_markdown(source: &str) -> Vec<MarkdownLine> {
335    let styles = theme::active_styles();
336    render_markdown_to_lines(source, Style::default(), &styles, None)
337}
338
339fn handle_start_tag(
340    tag: Tag,
341    style_stack: &mut Vec<Style>,
342    blockquote_depth: &mut usize,
343    list_stack: &mut Vec<ListState>,
344    pending_list_prefix: &mut Option<String>,
345    theme_styles: &ThemeStyles,
346    base_style: Style,
347    code_block: &mut Option<CodeBlockState>,
348) {
349    match tag {
350        Tag::Paragraph => {}
351        Tag::Heading(level, ..) => {
352            style_stack.push(heading_style(level, theme_styles, base_style));
353        }
354        Tag::BlockQuote => {
355            *blockquote_depth += 1;
356        }
357        Tag::List(start) => {
358            let depth = list_stack.len();
359            let kind = start
360                .map(|value| ListKind::Ordered {
361                    next: max(1, value as usize),
362                })
363                .unwrap_or(ListKind::Unordered);
364            list_stack.push(ListState {
365                kind,
366                depth,
367                continuation: String::new(),
368            });
369        }
370        Tag::Item => {
371            if let Some(state) = list_stack.last_mut() {
372                let indent = " ".repeat(state.depth * LIST_INDENT_WIDTH);
373                match &mut state.kind {
374                    ListKind::Unordered => {
375                        let bullet = format!("{}- ", indent);
376                        state.continuation = format!("{}  ", indent);
377                        *pending_list_prefix = Some(bullet);
378                    }
379                    ListKind::Ordered { next } => {
380                        let bullet = format!("{}{}. ", indent, *next);
381                        let width = bullet.len().saturating_sub(indent.len());
382                        state.continuation = format!("{}{}", indent, " ".repeat(width));
383                        *pending_list_prefix = Some(bullet);
384                        *next += 1;
385                    }
386                }
387            }
388        }
389        Tag::Emphasis => {
390            let style = style_stack.last().copied().unwrap_or(base_style).italic();
391            style_stack.push(style);
392        }
393        Tag::Strong => {
394            let style = style_stack.last().copied().unwrap_or(base_style).bold();
395            style_stack.push(style);
396        }
397        Tag::Strikethrough => {
398            let style = style_stack
399                .last()
400                .copied()
401                .unwrap_or(base_style)
402                .strikethrough();
403            style_stack.push(style);
404        }
405        Tag::Link { .. } | Tag::Image { .. } => {
406            let style = style_stack
407                .last()
408                .copied()
409                .unwrap_or(base_style)
410                .underline();
411            style_stack.push(style);
412        }
413        Tag::CodeBlock(kind) => {
414            let language = match kind {
415                CodeBlockKind::Fenced(info) => info
416                    .split_whitespace()
417                    .next()
418                    .filter(|lang| !lang.is_empty())
419                    .map(|lang| lang.to_string()),
420                CodeBlockKind::Indented => None,
421            };
422            *code_block = Some(CodeBlockState {
423                language,
424                buffer: String::new(),
425            });
426        }
427        _ => {}
428    }
429}
430
431fn handle_end_tag(
432    tag: Tag,
433    style_stack: &mut Vec<Style>,
434    blockquote_depth: &mut usize,
435    list_stack: &mut Vec<ListState>,
436    pending_list_prefix: &mut Option<String>,
437    lines: &mut Vec<MarkdownLine>,
438    current_line: &mut MarkdownLine,
439) {
440    match tag {
441        Tag::Paragraph => {
442            if !current_line.segments.is_empty() {
443                lines.push(std::mem::take(current_line));
444            }
445            push_blank_line(lines);
446        }
447        Tag::Heading(..) => {
448            if !current_line.segments.is_empty() {
449                lines.push(std::mem::take(current_line));
450            }
451            push_blank_line(lines);
452            style_stack.pop();
453        }
454        Tag::BlockQuote => {
455            if *blockquote_depth > 0 {
456                *blockquote_depth -= 1;
457            }
458        }
459        Tag::List(_) => {
460            list_stack.pop();
461            *pending_list_prefix = None;
462            if !current_line.segments.is_empty() {
463                lines.push(std::mem::take(current_line));
464            }
465            push_blank_line(lines);
466        }
467        Tag::Item => {
468            if !current_line.segments.is_empty() {
469                lines.push(std::mem::take(current_line));
470            }
471            *pending_list_prefix = None;
472        }
473        Tag::Emphasis | Tag::Strong | Tag::Strikethrough | Tag::Link { .. } | Tag::Image { .. } => {
474            style_stack.pop();
475        }
476        Tag::CodeBlock(_) => {}
477        Tag::Table(_)
478        | Tag::TableHead
479        | Tag::TableRow
480        | Tag::TableCell
481        | Tag::FootnoteDefinition(_) => {}
482    }
483}
484
485fn append_text(
486    text: &str,
487    current_line: &mut MarkdownLine,
488    lines: &mut Vec<MarkdownLine>,
489    style_stack: &[Style],
490    blockquote_depth: usize,
491    list_stack: &[ListState],
492    pending_list_prefix: &mut Option<String>,
493    theme_styles: &ThemeStyles,
494    base_style: Style,
495) {
496    let style = style_stack.last().copied().unwrap_or(base_style);
497
498    let mut start = 0usize;
499    let mut chars = text.char_indices().peekable();
500    while let Some((idx, ch)) = chars.next() {
501        if ch == '\n' {
502            let segment = &text[start..idx];
503            if !segment.is_empty() {
504                ensure_prefix(
505                    current_line,
506                    blockquote_depth,
507                    list_stack,
508                    pending_list_prefix,
509                    theme_styles,
510                    base_style,
511                );
512                current_line.push_segment(style, segment);
513            }
514            lines.push(std::mem::take(current_line));
515            start = idx + ch.len_utf8();
516        }
517    }
518
519    if start < text.len() {
520        let remaining = &text[start..];
521        ensure_prefix(
522            current_line,
523            blockquote_depth,
524            list_stack,
525            pending_list_prefix,
526            theme_styles,
527            base_style,
528        );
529        current_line.push_segment(style, remaining);
530    }
531}
532
533fn ensure_prefix(
534    current_line: &mut MarkdownLine,
535    blockquote_depth: usize,
536    list_stack: &[ListState],
537    pending_list_prefix: &mut Option<String>,
538    theme_styles: &ThemeStyles,
539    base_style: Style,
540) {
541    if !current_line.segments.is_empty() {
542        return;
543    }
544
545    for _ in 0..blockquote_depth {
546        current_line.push_segment(theme_styles.secondary.italic(), "│ ");
547    }
548
549    if let Some(prefix) = pending_list_prefix.take() {
550        current_line.push_segment(base_style, &prefix);
551    } else if !list_stack.is_empty() {
552        let mut continuation = String::new();
553        for state in list_stack {
554            continuation.push_str(&state.continuation);
555        }
556        if !continuation.is_empty() {
557            current_line.push_segment(base_style, &continuation);
558        }
559    }
560}
561
562fn flush_current_line(
563    lines: &mut Vec<MarkdownLine>,
564    current_line: &mut MarkdownLine,
565    blockquote_depth: usize,
566    list_stack: &[ListState],
567    pending_list_prefix: &mut Option<String>,
568    theme_styles: &ThemeStyles,
569    base_style: Style,
570) {
571    if current_line.segments.is_empty() {
572        if pending_list_prefix.is_some() {
573            ensure_prefix(
574                current_line,
575                blockquote_depth,
576                list_stack,
577                pending_list_prefix,
578                theme_styles,
579                base_style,
580            );
581        }
582    }
583
584    if !current_line.segments.is_empty() {
585        lines.push(std::mem::take(current_line));
586    }
587}
588
589fn push_blank_line(lines: &mut Vec<MarkdownLine>) {
590    if lines
591        .last()
592        .map(|line| line.segments.is_empty())
593        .unwrap_or(false)
594    {
595        return;
596    }
597    lines.push(MarkdownLine::default());
598}
599
600fn trim_trailing_blank_lines(lines: &mut Vec<MarkdownLine>) {
601    while lines
602        .last()
603        .map(|line| line.segments.is_empty())
604        .unwrap_or(false)
605    {
606        lines.pop();
607    }
608}
609
610fn inline_code_style(theme_styles: &ThemeStyles, base_style: Style) -> Style {
611    let fg = theme_styles
612        .secondary
613        .get_fg_color()
614        .or_else(|| base_style.get_fg_color());
615    let bg = Some(theme_styles.background.into());
616    let mut style = base_style;
617    if let Some(fg_color) = fg {
618        style = style.fg_color(Some(fg_color));
619    }
620    style.bg_color(bg).bold()
621}
622
623fn heading_style(level: HeadingLevel, theme_styles: &ThemeStyles, base_style: Style) -> Style {
624    match level {
625        HeadingLevel::H1 => theme_styles.primary.bold().underline(),
626        HeadingLevel::H2 => theme_styles.primary.bold(),
627        HeadingLevel::H3 => theme_styles.secondary.bold(),
628        _ => base_style.bold(),
629    }
630}
631
632fn build_prefix_segments(
633    blockquote_depth: usize,
634    list_stack: &[ListState],
635    theme_styles: &ThemeStyles,
636    base_style: Style,
637) -> Vec<PrefixSegment> {
638    let mut segments = Vec::new();
639    for _ in 0..blockquote_depth {
640        segments.push(PrefixSegment::new(theme_styles.secondary.italic(), "│ "));
641    }
642    if !list_stack.is_empty() {
643        let mut continuation = String::new();
644        for state in list_stack {
645            continuation.push_str(&state.continuation);
646        }
647        if !continuation.is_empty() {
648            segments.push(PrefixSegment::new(base_style, continuation));
649        }
650    }
651    segments
652}
653
654fn highlight_code_block(
655    code: &str,
656    language: Option<&str>,
657    highlight_config: Option<&SyntaxHighlightingConfig>,
658    theme_styles: &ThemeStyles,
659    base_style: Style,
660    prefix_segments: &[PrefixSegment],
661) -> Vec<MarkdownLine> {
662    let mut lines = Vec::new();
663    let mut augmented_prefix = prefix_segments.to_vec();
664    augmented_prefix.push(PrefixSegment::new(base_style, CODE_EXTRA_INDENT));
665
666    if let Some(config) = highlight_config.filter(|cfg| cfg.enabled) {
667        if let Some(highlighted) = try_highlight(code, language, config) {
668            for segments in highlighted {
669                let mut line = MarkdownLine::default();
670                line.prepend_segments(&augmented_prefix);
671                for (style, text) in segments {
672                    line.push_segment(style, &text);
673                }
674                lines.push(line);
675            }
676            return lines;
677        }
678    }
679
680    for raw_line in LinesWithEndings::from(code) {
681        let trimmed = raw_line.trim_end_matches('\n');
682        let mut line = MarkdownLine::default();
683        line.prepend_segments(&augmented_prefix);
684        if !trimmed.is_empty() {
685            line.push_segment(code_block_style(theme_styles, base_style), trimmed);
686        }
687        lines.push(line);
688    }
689
690    if code.ends_with('\n') {
691        let mut line = MarkdownLine::default();
692        line.prepend_segments(&augmented_prefix);
693        lines.push(line);
694    }
695
696    lines
697}
698
699fn code_block_style(theme_styles: &ThemeStyles, base_style: Style) -> Style {
700    let fg = theme_styles
701        .output
702        .get_fg_color()
703        .or_else(|| base_style.get_fg_color());
704    let mut style = base_style;
705    if let Some(color) = fg {
706        style = style.fg_color(Some(color));
707    }
708    style
709}
710
711fn try_highlight(
712    code: &str,
713    language: Option<&str>,
714    config: &SyntaxHighlightingConfig,
715) -> Option<Vec<Vec<(Style, String)>>> {
716    let max_bytes = config.max_file_size_mb.saturating_mul(1024 * 1024);
717    if max_bytes > 0 && code.len() > max_bytes {
718        return None;
719    }
720
721    if let Some(lang) = language {
722        let enabled = config
723            .enabled_languages
724            .iter()
725            .any(|entry| entry.eq_ignore_ascii_case(lang));
726        if !enabled {
727            return None;
728        }
729    }
730
731    let syntax = select_syntax(language);
732    let theme = load_theme(&config.theme, config.cache_themes);
733    let mut highlighter = HighlightLines::new(syntax, &theme);
734    let mut rendered = Vec::new();
735
736    let mut ends_with_newline = false;
737    for line in LinesWithEndings::from(code) {
738        ends_with_newline = line.ends_with('\n');
739        let trimmed = line.trim_end_matches('\n');
740        let ranges = highlighter.highlight_line(trimmed, &SYNTAX_SET).ok()?;
741        let mut segments = Vec::new();
742        for (style, part) in ranges {
743            if part.is_empty() {
744                continue;
745            }
746            segments.push((to_anstyle(style), part.to_string()));
747        }
748        rendered.push(segments);
749    }
750
751    if ends_with_newline {
752        rendered.push(Vec::new());
753    }
754
755    Some(rendered)
756}
757
758fn select_syntax(language: Option<&str>) -> &'static SyntaxReference {
759    language
760        .and_then(|lang| SYNTAX_SET.find_syntax_by_token(lang))
761        .unwrap_or_else(|| SYNTAX_SET.find_syntax_plain_text())
762}
763
764fn load_theme(theme_name: &str, cache: bool) -> Theme {
765    if let Some(theme) = THEME_CACHE.read().get(theme_name).cloned() {
766        return theme;
767    }
768
769    let defaults = ThemeSet::load_defaults();
770    if let Some(theme) = defaults.themes.get(theme_name).cloned() {
771        if cache {
772            let mut guard = THEME_CACHE.write();
773            if guard.len() >= MAX_THEME_CACHE_SIZE {
774                if let Some(first_key) = guard.keys().next().cloned() {
775                    guard.remove(&first_key);
776                }
777            }
778            guard.insert(theme_name.to_string(), theme.clone());
779        }
780        theme
781    } else {
782        warn!(
783            "theme" = theme_name,
784            "Falling back to default syntax highlighting theme"
785        );
786        defaults
787            .themes
788            .into_iter()
789            .next()
790            .map(|(_, theme)| theme)
791            .unwrap_or_default()
792    }
793}