vtcode_ui/tui/ui/markdown/
mod.rs1mod code_blocks;
4mod links;
5mod parsing;
6mod tables;
7
8use crate::tui::config::loader::SyntaxHighlightingConfig;
9use crate::tui::ui::theme::{self, ThemeStyles};
10use anstyle::Style;
11use code_blocks::{CodeBlockRenderEnv, CodeBlockState, finalize_unclosed_code_block, handle_code_block_event};
12use parsing::{
13 LinkState, ListState, MarkdownContext, append_text, handle_end_tag, handle_start_tag, inline_code_style,
14 push_blank_line, trim_trailing_blank_lines,
15};
16use pulldown_cmark::{Event, Options, Parser};
17use tables::TableBuffer;
18use unicode_width::UnicodeWidthStr;
19
20pub(crate) use code_blocks::render_diff_content_segments;
21pub use code_blocks::{
22 HighlightedSegment, highlight_code_to_ansi, highlight_code_to_segments, highlight_line_for_diff,
23};
24
25pub(crate) const LIST_INDENT_WIDTH: usize = 2;
26pub(crate) const CODE_LINE_NUMBER_MIN_WIDTH: usize = 3;
27
28#[derive(Clone, Debug)]
34pub struct MarkdownSegment {
35 pub style: Style,
36 pub text: String,
37 pub link_target: Option<Box<str>>,
38}
39
40impl MarkdownSegment {
41 fn new(style: Style, text: impl Into<String>) -> Self {
42 Self { style, text: text.into(), link_target: None }
43 }
44
45 fn with_link(style: Style, text: impl Into<String>, link_target: Option<impl Into<Box<str>>>) -> Self {
46 Self {
47 style,
48 text: text.into(),
49 link_target: link_target.map(Into::into),
50 }
51 }
52}
53
54#[derive(Clone, Debug, Default)]
56pub struct MarkdownLine {
57 pub segments: Vec<MarkdownSegment>,
58 pub line_background: Option<anstyle::Color>,
59}
60
61impl MarkdownLine {
62 fn set_line_background(&mut self, color: Option<anstyle::Color>) {
63 self.line_background = color;
64 }
65
66 fn push_segment(&mut self, style: Style, text: &str) {
67 self.push_segment_with_link(style, text, None::<String>);
68 }
69
70 fn push_segment_with_link(&mut self, style: Style, text: &str, link_target: Option<impl Into<Box<str>>>) {
71 let link_target = link_target.map(Into::into);
72 if text.is_empty() {
73 return;
74 }
75 if let Some(last) = self.segments.last_mut()
76 && last.style == style
77 && last.link_target == link_target
78 {
79 last.text.push_str(text);
80 return;
81 }
82 self.segments.push(MarkdownSegment::with_link(style, text, link_target));
83 }
84
85 pub fn is_empty(&self) -> bool {
86 self.segments.iter().all(|segment| segment.text.trim().is_empty())
87 }
88
89 fn width(&self) -> usize {
90 self.segments.iter().map(|seg| UnicodeWidthStr::width(seg.text.as_str())).sum()
91 }
92}
93
94#[derive(Debug, Clone, Copy, Default)]
95pub struct RenderMarkdownOptions {
96 pub preserve_code_indentation: bool,
97 pub disable_code_block_table_reparse: bool,
98 pub table_max_width: Option<usize>,
103}
104
105fn render_markdown_to_lines(
107 source: &str,
108 base_style: Style,
109 theme_styles: &ThemeStyles,
110 highlight_config: Option<&SyntaxHighlightingConfig>,
111) -> Vec<MarkdownLine> {
112 render_markdown_to_lines_with_options(
113 source,
114 base_style,
115 theme_styles,
116 highlight_config,
117 RenderMarkdownOptions::default(),
118 )
119}
120
121pub fn render_markdown_to_lines_with_options(
122 source: &str,
123 base_style: Style,
124 theme_styles: &ThemeStyles,
125 highlight_config: Option<&SyntaxHighlightingConfig>,
126 render_options: RenderMarkdownOptions,
127) -> Vec<MarkdownLine> {
128 let preprocessed = preprocess_plan_wrappers(source);
132 let parser_options =
133 Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES | Options::ENABLE_TASKLISTS | Options::ENABLE_FOOTNOTES;
134
135 let parser = Parser::new_ext(&preprocessed, parser_options);
136
137 let mut lines = Vec::with_capacity(source.lines().count());
140 let mut current_line = MarkdownLine::default();
141 let mut style_stack = vec![base_style];
142 let mut blockquote_depth = 0usize;
143 let mut list_stack: Vec<ListState> = Vec::with_capacity(4);
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 table_max_width: render_options.table_max_width,
181 };
182
183 match event {
184 Event::Start(ref tag) => handle_start_tag(tag, &mut ctx),
185 Event::End(tag) => handle_end_tag(tag, &mut ctx),
186 Event::Text(text) => append_text(&text, &mut ctx),
187 Event::Code(code) => {
188 ctx.ensure_prefix();
189 ctx.current_line.push_segment_with_link(
190 inline_code_style(theme_styles, base_style),
191 &code,
192 ctx.active_link_target(),
193 );
194 }
195 Event::SoftBreak | Event::HardBreak => ctx.flush_line(),
196 Event::Rule => {
197 ctx.flush_line();
198 let mut line = MarkdownLine::default();
199 line.push_segment(base_style.dimmed(), &"―".repeat(32));
200 ctx.lines.push(line);
201 push_blank_line(ctx.lines);
202 }
203 Event::TaskListMarker(checked) => {
204 ctx.ensure_prefix();
205 ctx.current_line.push_segment(base_style, if checked { "[x] " } else { "[ ] " });
206 }
207 Event::Html(html) | Event::InlineHtml(html) => {
208 if !is_plan_markup_html(&html) {
211 append_text(&html, &mut ctx);
212 }
213 }
214 Event::FootnoteReference(r) => append_text(&format!("[^{r}]"), &mut ctx),
215 Event::InlineMath(m) => append_text(&format!("${m}$"), &mut ctx),
216 Event::DisplayMath(m) => append_text(&format!("$$\n{m}\n$$"), &mut ctx),
217 }
218 }
219
220 let mut code_block_env = code_block_render_env(
221 &mut lines,
222 &mut current_line,
223 blockquote_depth,
224 &list_continuation_prefix,
225 &mut pending_list_prefix,
226 base_style,
227 theme_styles,
228 highlight_config,
229 render_options,
230 );
231 finalize_unclosed_code_block(&mut code_block, &mut code_block_env);
232
233 if !current_line.segments.is_empty() {
234 lines.push(current_line);
235 }
236
237 trim_trailing_blank_lines(&mut lines);
238 lines
239}
240
241pub(crate) fn render_markdown(source: &str) -> Vec<MarkdownLine> {
243 let styles = theme::active_styles();
244 render_markdown_to_lines(source, Style::default(), &styles, None)
245}
246
247fn code_block_render_env<'a>(
248 lines: &'a mut Vec<MarkdownLine>,
249 current_line: &'a mut MarkdownLine,
250 blockquote_depth: usize,
251 list_continuation_prefix: &'a str,
252 pending_list_prefix: &'a mut Option<String>,
253 base_style: Style,
254 theme_styles: &'a ThemeStyles,
255 highlight_config: Option<&'a SyntaxHighlightingConfig>,
256 render_options: RenderMarkdownOptions,
257) -> CodeBlockRenderEnv<'a> {
258 CodeBlockRenderEnv {
259 lines,
260 current_line,
261 blockquote_depth,
262 list_continuation_prefix,
263 pending_list_prefix,
264 base_style,
265 theme_styles,
266 highlight_config,
267 render_options,
268 }
269}
270
271const PLAN_MARKUP_TAGS: &[&str] = &["<proposed_plan>", "</proposed_plan>", "<plan>", "</plan>"];
273
274fn is_plan_markup_html(html: &str) -> bool {
275 let normalized: String = html.chars().filter(|ch| !ch.is_whitespace()).collect();
276 let lowered = normalized.to_ascii_lowercase();
277 PLAN_MARKUP_TAGS.iter().any(|tag| lowered.contains(tag))
278}
279
280fn strip_plan_markup_tags(text: &str, inline_code_ticks: &mut Option<usize>) -> String {
281 let mut out = String::with_capacity(text.len());
282 let mut cursor = 0;
283
284 while cursor < text.len() {
285 let remainder = &text[cursor..];
286 if remainder.starts_with('`') {
287 let run_length = remainder.bytes().take_while(|byte| *byte == b'`').count();
288 out.push_str(&remainder[..run_length]);
289 if inline_code_ticks.is_some_and(|ticks| ticks == run_length) {
290 *inline_code_ticks = None;
291 } else if inline_code_ticks.is_none() {
292 *inline_code_ticks = Some(run_length);
293 }
294 cursor += run_length;
295 continue;
296 }
297
298 if inline_code_ticks.is_none()
299 && let Some(tag) = PLAN_MARKUP_TAGS.iter().find(|tag| {
300 remainder
301 .get(..tag.len())
302 .is_some_and(|prefix| prefix.eq_ignore_ascii_case(tag))
303 })
304 {
305 cursor += tag.len();
306 continue;
307 }
308
309 let character = remainder.chars().next().expect("cursor is on a character boundary");
310 out.push(character);
311 cursor += character.len_utf8();
312 }
313
314 out
315}
316
317fn preprocess_plan_wrappers(source: &str) -> String {
318 let mut out = String::with_capacity(source.len());
319 let mut may_have_plan_tags = None;
320 let mut in_fenced_code = false;
321 let mut inline_code_ticks = None;
322
323 for (index, line) in source.lines().enumerate() {
324 if index > 0 {
325 out.push('\n');
326 }
327
328 let is_fence = is_fence_delimiter(line);
329 if in_fenced_code || is_fence || !*may_have_plan_tags.get_or_insert_with(|| source.contains('<')) {
330 out.push_str(line);
331 } else {
332 out.push_str(&strip_plan_markup_tags(line, &mut inline_code_ticks));
333 }
334
335 if is_fence {
336 in_fenced_code = !in_fenced_code;
337 inline_code_ticks = None;
338 }
339 }
340
341 if source.ends_with('\n') && !out.ends_with('\n') {
342 out.push('\n');
343 }
344 out
345}
346
347fn is_fence_delimiter(line: &str) -> bool {
348 vtcode_commons::formatting::is_markdown_fence_delimiter(line)
349}
350
351#[cfg(test)]
352mod tests;