Skip to main content

vtcode_tui/ui/markdown/
mod.rs

1//! Markdown rendering utilities for terminal output with syntax highlighting support.
2
3mod code_blocks;
4mod links;
5mod parsing;
6mod tables;
7
8use crate::config::loader::SyntaxHighlightingConfig;
9use crate::ui::theme::{self, ThemeStyles};
10use anstyle::Style;
11use code_blocks::{
12    CodeBlockRenderEnv, CodeBlockState, finalize_unclosed_code_block, handle_code_block_event,
13};
14use parsing::{
15    LinkState, ListState, MarkdownContext, append_text, handle_end_tag, handle_start_tag,
16    inline_code_style, push_blank_line, trim_trailing_blank_lines,
17};
18use pulldown_cmark::{Event, Options, Parser};
19use tables::TableBuffer;
20use unicode_width::UnicodeWidthStr;
21
22pub use code_blocks::{
23    HighlightedSegment, highlight_code_to_ansi, highlight_code_to_segments, highlight_line_for_diff,
24};
25
26pub(crate) const LIST_INDENT_WIDTH: usize = 2;
27pub(crate) const CODE_LINE_NUMBER_MIN_WIDTH: usize = 3;
28
29/// A styled text segment.
30#[derive(Clone, Debug)]
31pub struct MarkdownSegment {
32    pub style: Style,
33    pub text: String,
34}
35
36impl MarkdownSegment {
37    pub(crate) fn new(style: Style, text: impl Into<String>) -> Self {
38        Self {
39            style,
40            text: text.into(),
41        }
42    }
43}
44
45/// A rendered line composed of styled segments.
46#[derive(Clone, Debug, Default)]
47pub struct MarkdownLine {
48    pub segments: Vec<MarkdownSegment>,
49}
50
51impl MarkdownLine {
52    pub(crate) fn push_segment(&mut self, style: Style, text: &str) {
53        if text.is_empty() {
54            return;
55        }
56        if let Some(last) = self.segments.last_mut()
57            && last.style == style
58        {
59            last.text.push_str(text);
60            return;
61        }
62        self.segments.push(MarkdownSegment::new(style, text));
63    }
64
65    pub fn is_empty(&self) -> bool {
66        self.segments
67            .iter()
68            .all(|segment| segment.text.trim().is_empty())
69    }
70
71    pub(crate) fn width(&self) -> usize {
72        self.segments
73            .iter()
74            .map(|seg| UnicodeWidthStr::width(seg.text.as_str()))
75            .sum()
76    }
77}
78
79#[derive(Debug, Clone, Copy, Default)]
80pub struct RenderMarkdownOptions {
81    pub preserve_code_indentation: bool,
82    pub disable_code_block_table_reparse: bool,
83}
84
85/// Render markdown text to styled lines that can be written to the terminal renderer.
86pub fn render_markdown_to_lines(
87    source: &str,
88    base_style: Style,
89    theme_styles: &ThemeStyles,
90    highlight_config: Option<&SyntaxHighlightingConfig>,
91) -> Vec<MarkdownLine> {
92    render_markdown_to_lines_with_options(
93        source,
94        base_style,
95        theme_styles,
96        highlight_config,
97        RenderMarkdownOptions::default(),
98    )
99}
100
101pub fn render_markdown_to_lines_with_options(
102    source: &str,
103    base_style: Style,
104    theme_styles: &ThemeStyles,
105    highlight_config: Option<&SyntaxHighlightingConfig>,
106    render_options: RenderMarkdownOptions,
107) -> Vec<MarkdownLine> {
108    let parser_options = Options::ENABLE_STRIKETHROUGH
109        | Options::ENABLE_TABLES
110        | Options::ENABLE_TASKLISTS
111        | Options::ENABLE_FOOTNOTES;
112
113    let parser = Parser::new_ext(source, parser_options);
114
115    let mut lines = Vec::new();
116    let mut current_line = MarkdownLine::default();
117    let mut style_stack = vec![base_style];
118    let mut blockquote_depth = 0usize;
119    let mut list_stack: Vec<ListState> = Vec::new();
120    let mut list_continuation_prefix = String::new();
121    let mut pending_list_prefix: Option<String> = None;
122    let mut code_block: Option<CodeBlockState> = None;
123    let mut active_table: Option<TableBuffer> = None;
124    let mut link_state: Option<LinkState> = None;
125
126    for event in parser {
127        let mut code_block_env = code_block_render_env(
128            &mut lines,
129            &mut current_line,
130            blockquote_depth,
131            &list_continuation_prefix,
132            &mut pending_list_prefix,
133            base_style,
134            theme_styles,
135            highlight_config,
136            render_options,
137        );
138        if handle_code_block_event(&event, &mut code_block, &mut code_block_env) {
139            continue;
140        }
141
142        let mut ctx = MarkdownContext {
143            style_stack: &mut style_stack,
144            blockquote_depth: &mut blockquote_depth,
145            list_stack: &mut list_stack,
146            pending_list_prefix: &mut pending_list_prefix,
147            list_continuation_prefix: &mut list_continuation_prefix,
148            lines: &mut lines,
149            current_line: &mut current_line,
150            theme_styles,
151            base_style,
152            code_block: &mut code_block,
153            active_table: &mut active_table,
154            link_state: &mut link_state,
155        };
156
157        match event {
158            Event::Start(ref tag) => handle_start_tag(tag, &mut ctx),
159            Event::End(tag) => handle_end_tag(tag, &mut ctx),
160            Event::Text(text) => append_text(&text, &mut ctx),
161            Event::Code(code) => {
162                ctx.ensure_prefix();
163                ctx.current_line
164                    .push_segment(inline_code_style(theme_styles, base_style), &code);
165            }
166            Event::SoftBreak | Event::HardBreak => ctx.flush_line(),
167            Event::Rule => {
168                ctx.flush_line();
169                let mut line = MarkdownLine::default();
170                line.push_segment(base_style.dimmed(), &"―".repeat(32));
171                ctx.lines.push(line);
172                push_blank_line(ctx.lines);
173            }
174            Event::TaskListMarker(checked) => {
175                ctx.ensure_prefix();
176                ctx.current_line
177                    .push_segment(base_style, if checked { "[x] " } else { "[ ] " });
178            }
179            Event::Html(html) | Event::InlineHtml(html) => append_text(&html, &mut ctx),
180            Event::FootnoteReference(r) => append_text(&format!("[^{}]", r), &mut ctx),
181            Event::InlineMath(m) => append_text(&format!("${}$", m), &mut ctx),
182            Event::DisplayMath(m) => append_text(&format!("$$\n{}\n$$", m), &mut ctx),
183        }
184    }
185
186    let mut code_block_env = code_block_render_env(
187        &mut lines,
188        &mut current_line,
189        blockquote_depth,
190        &list_continuation_prefix,
191        &mut pending_list_prefix,
192        base_style,
193        theme_styles,
194        highlight_config,
195        render_options,
196    );
197    finalize_unclosed_code_block(&mut code_block, &mut code_block_env);
198
199    if !current_line.segments.is_empty() {
200        lines.push(current_line);
201    }
202
203    trim_trailing_blank_lines(&mut lines);
204    lines
205}
206
207/// Convenience helper that renders markdown using the active theme without emitting output.
208pub fn render_markdown(source: &str) -> Vec<MarkdownLine> {
209    let styles = theme::active_styles();
210    render_markdown_to_lines(source, Style::default(), &styles, None)
211}
212
213fn code_block_render_env<'a>(
214    lines: &'a mut Vec<MarkdownLine>,
215    current_line: &'a mut MarkdownLine,
216    blockquote_depth: usize,
217    list_continuation_prefix: &'a str,
218    pending_list_prefix: &'a mut Option<String>,
219    base_style: Style,
220    theme_styles: &'a ThemeStyles,
221    highlight_config: Option<&'a SyntaxHighlightingConfig>,
222    render_options: RenderMarkdownOptions,
223) -> CodeBlockRenderEnv<'a> {
224    CodeBlockRenderEnv {
225        lines,
226        current_line,
227        blockquote_depth,
228        list_continuation_prefix,
229        pending_list_prefix,
230        base_style,
231        theme_styles,
232        highlight_config,
233        render_options,
234    }
235}
236
237#[cfg(test)]
238mod tests;