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