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