Skip to main content

vtcode_ui/tui/ui/markdown/
code_blocks.rs

1use super::parsing::{flush_current_line, push_blank_line};
2use super::{CODE_LINE_NUMBER_MIN_WIDTH, MarkdownLine, MarkdownSegment, RenderMarkdownOptions};
3use crate::tui::config::loader::SyntaxHighlightingConfig;
4use crate::tui::ui::syntax_highlight;
5use crate::tui::ui::theme::ThemeStyles;
6use crate::tui::utils::diff_styles::{
7    DiffLineType, current_diff_render_style_context, diff_add_fg, diff_del_fg, style_content_ansi,
8    style_file_header_new_ansi, style_file_header_old_ansi, style_hunk_header_ansi, style_sign_ansi,
9};
10use anstyle::{Color as AnstyleColor, Effects, Style};
11use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
12use std::fmt::Write;
13use syntect::util::LinesWithEndings;
14use vtcode_commons::diff_paths::{
15    is_diff_addition_line, is_diff_deletion_line, is_diff_header_line, is_diff_new_file_marker_line,
16    is_prose_language_hint, language_hint_from_path, looks_like_diff_content, parse_diff_git_path,
17    parse_diff_marker_path,
18};
19use vtcode_commons::diff_preview::{DiffDisplayKind, DiffDisplayLine, annotate_word_level_diffs};
20
21const DIFF_SUMMARY_PREFIX: &str = "• Diff ";
22
23#[derive(Clone, Debug)]
24pub(crate) struct CodeBlockState {
25    pub(crate) language: Option<String>,
26    pub(crate) buffer: String,
27}
28
29pub(crate) struct CodeBlockRenderEnv<'a> {
30    pub(crate) lines: &'a mut Vec<MarkdownLine>,
31    pub(crate) current_line: &'a mut MarkdownLine,
32    pub(crate) blockquote_depth: usize,
33    pub(crate) list_continuation_prefix: &'a str,
34    pub(crate) pending_list_prefix: &'a mut Option<String>,
35    pub(crate) base_style: Style,
36    pub(crate) theme_styles: &'a ThemeStyles,
37    pub(crate) highlight_config: Option<&'a SyntaxHighlightingConfig>,
38    pub(crate) render_options: RenderMarkdownOptions,
39}
40
41pub(crate) fn handle_code_block_event(
42    event: &Event<'_>,
43    code_block: &mut Option<CodeBlockState>,
44    env: &mut CodeBlockRenderEnv<'_>,
45) -> bool {
46    if code_block.is_none() {
47        return false;
48    }
49
50    match event {
51        Event::Text(text) => {
52            if let Some(state) = code_block.as_mut() {
53                state.buffer.push_str(text);
54            }
55            true
56        }
57        Event::End(TagEnd::CodeBlock) => {
58            finalize_code_block(env, code_block, true, true);
59            true
60        }
61        _ => false,
62    }
63}
64
65pub(crate) fn finalize_unclosed_code_block(code_block: &mut Option<CodeBlockState>, env: &mut CodeBlockRenderEnv<'_>) {
66    finalize_code_block(env, code_block, false, false);
67}
68
69fn finalize_code_block(
70    env: &mut CodeBlockRenderEnv<'_>,
71    code_block: &mut Option<CodeBlockState>,
72    allow_table_reparse: bool,
73    append_trailing_blank_line: bool,
74) {
75    flush_current_line(
76        env.lines,
77        env.current_line,
78        env.blockquote_depth,
79        env.list_continuation_prefix,
80        env.pending_list_prefix,
81        env.base_style,
82    );
83    if let Some(state) = code_block.take() {
84        let rendered = render_code_block_state(&state, env, allow_table_reparse);
85        env.lines.extend(rendered);
86        if append_trailing_blank_line {
87            push_blank_line(env.lines);
88        }
89    }
90}
91
92fn render_code_block_state(
93    state: &CodeBlockState,
94    env: &CodeBlockRenderEnv<'_>,
95    allow_table_reparse: bool,
96) -> Vec<MarkdownLine> {
97    if allow_table_reparse
98        && !env.render_options.disable_code_block_table_reparse
99        && code_block_contains_table(&state.buffer, state.language.as_deref())
100    {
101        return render_markdown_code_block_table(
102            &state.buffer,
103            env.base_style,
104            env.theme_styles,
105            env.highlight_config,
106            env.render_options,
107        );
108    }
109
110    let prefix = build_prefix_segments(env.blockquote_depth, env.list_continuation_prefix, env.base_style);
111    highlight_code_block(
112        &state.buffer,
113        state.language.as_deref(),
114        env.highlight_config,
115        env.theme_styles,
116        env.base_style,
117        &prefix,
118        env.render_options.preserve_code_indentation,
119    )
120}
121
122fn render_markdown_code_block_table(
123    source: &str,
124    base_style: Style,
125    theme_styles: &ThemeStyles,
126    highlight_config: Option<&SyntaxHighlightingConfig>,
127    render_options: RenderMarkdownOptions,
128) -> Vec<MarkdownLine> {
129    let mut nested_options = render_options;
130    nested_options.disable_code_block_table_reparse = true;
131    super::render_markdown_to_lines_with_options(source, base_style, theme_styles, highlight_config, nested_options)
132}
133
134fn build_prefix_segments(
135    _blockquote_depth: usize,
136    list_continuation_prefix: &str,
137    base_style: Style,
138) -> Vec<MarkdownSegment> {
139    let mut segments = Vec::with_capacity(usize::from(!list_continuation_prefix.is_empty()));
140    if !list_continuation_prefix.is_empty() {
141        segments.push(MarkdownSegment::new(base_style, list_continuation_prefix));
142    }
143    segments
144}
145
146fn highlight_code_block(
147    code: &str,
148    language: Option<&str>,
149    highlight_config: Option<&SyntaxHighlightingConfig>,
150    theme_styles: &ThemeStyles,
151    base_style: Style,
152    prefix_segments: &[MarkdownSegment],
153    preserve_code_indentation: bool,
154) -> Vec<MarkdownLine> {
155    let mut lines = Vec::new();
156
157    let normalized_code = normalize_code_indentation(code, language, preserve_code_indentation);
158    let code_to_display = &normalized_code;
159    if is_diff_language(language) || (language.is_none() && looks_like_diff_content(code_to_display)) {
160        return render_diff_code_block(code_to_display, theme_styles, base_style, prefix_segments);
161    }
162    let use_line_numbers = language.is_some_and(|lang| !lang.trim().is_empty()) && !is_diff_language(language);
163
164    if let Some(config) = highlight_config.filter(|cfg| cfg.enabled)
165        && let Some(highlighted) = try_highlight(code_to_display, language, config)
166    {
167        let source_lines: Vec<&str> = code_to_display.lines().collect();
168        let line_count = source_line_count(code_to_display);
169        let number_width = line_number_width(line_count);
170        let gutter_style = line_number_style(theme_styles, base_style);
171        let code_style = code_block_style(theme_styles, base_style);
172        let mut line_number = 1usize;
173        for (index, src) in source_lines.iter().enumerate() {
174            let Some(segments) = highlighted.get(index) else {
175                break;
176            };
177            let is_omitted = parse_omitted_line_count(src).is_some();
178            let (gutter_text, omitted) = if use_line_numbers {
179                let (text, om) = format_gutter_text(line_number, number_width, src);
180                (Some(text), om)
181            } else {
182                (None, 1)
183            };
184            let mut line = code_line_with_prefix(prefix_segments, gutter_text.as_deref(), gutter_style);
185            if is_omitted {
186                line.push_segment(gutter_style, src);
187            } else {
188                let mut in_leading_whitespace = true;
189                for (style, text) in segments {
190                    let is_leading_whitespace = in_leading_whitespace && text.trim().is_empty();
191                    if is_leading_whitespace {
192                        line.push_segment(code_style, text);
193                    } else {
194                        in_leading_whitespace = false;
195                        line.push_segment(*style, text);
196                    }
197                }
198            }
199            line_number = line_number.saturating_add(omitted);
200            lines.push(line);
201        }
202        return lines;
203    }
204
205    let mut line_number = 1usize;
206    let line_count = source_line_count(code_to_display);
207    let number_width = line_number_width(line_count);
208    let gutter_style = line_number_style(theme_styles, base_style);
209
210    for raw_line in LinesWithEndings::from(code_to_display) {
211        let trimmed = raw_line.trim_end_matches('\n');
212        let is_omitted = parse_omitted_line_count(trimmed).is_some();
213        let (gutter_text, omitted) = if use_line_numbers {
214            let (text, om) = format_gutter_text(line_number, number_width, trimmed);
215            (Some(text), om)
216        } else {
217            (None, 1)
218        };
219        let mut line = code_line_with_prefix(prefix_segments, gutter_text.as_deref(), gutter_style);
220        if !trimmed.is_empty() {
221            if is_omitted {
222                line.push_segment(gutter_style, trimmed);
223            } else {
224                line.push_segment(code_block_style(theme_styles, base_style), trimmed);
225            }
226        }
227        lines.push(line);
228        line_number = line_number.saturating_add(omitted);
229    }
230
231    lines
232}
233
234pub(crate) fn normalize_diff_lines(code: &str) -> Vec<String> {
235    #[derive(Default)]
236    struct DiffBlock {
237        header: String,
238        path: String,
239        lines: Vec<String>,
240        additions: usize,
241        deletions: usize,
242    }
243
244    let mut preface = Vec::new();
245    let mut blocks = Vec::new();
246    let mut current: Option<DiffBlock> = None;
247    let mut fallback_additions = 0usize;
248    let mut fallback_deletions = 0usize;
249    let mut fallback_path: Option<String> = None;
250    let mut summary_insert_index: Option<usize> = None;
251
252    for line in code.lines() {
253        if fallback_path.is_none() {
254            fallback_path = parse_diff_marker_path(line);
255        }
256        if summary_insert_index.is_none() && is_diff_new_file_marker_line(line.trim_start()) {
257            summary_insert_index = Some(preface.len());
258        }
259        bump_diff_counters(line, &mut fallback_additions, &mut fallback_deletions);
260
261        if let Some(path) = parse_diff_git_path(line) {
262            if let Some(block) = current.take() {
263                blocks.push(block);
264            }
265            current = Some(DiffBlock {
266                header: line.to_string(),
267                path,
268                lines: Vec::new(),
269                additions: 0,
270                deletions: 0,
271            });
272            continue;
273        }
274
275        let rewritten = rewrite_diff_line(line);
276        if let Some(block) = current.as_mut() {
277            bump_diff_counters(line, &mut block.additions, &mut block.deletions);
278            block.lines.push(rewritten);
279        } else {
280            preface.push(rewritten);
281        }
282    }
283
284    if let Some(block) = current {
285        blocks.push(block);
286    }
287
288    if blocks.is_empty() {
289        let path = fallback_path.unwrap_or_else(|| "file".to_string());
290        let summary = format_diff_summary(path.as_str(), fallback_additions, fallback_deletions);
291
292        let mut output = Vec::with_capacity(preface.len() + 1);
293        if let Some(idx) = summary_insert_index {
294            output.extend(preface[..=idx].iter().cloned());
295            output.push(summary);
296            output.extend(preface[idx + 1..].iter().cloned());
297        } else {
298            output.push(summary);
299            output.extend(preface);
300        }
301        return output;
302    }
303
304    let mut output = Vec::new();
305    output.extend(preface);
306    for block in blocks {
307        output.push(block.header);
308        output.push(format_diff_summary(block.path.as_str(), block.additions, block.deletions));
309        output.extend(block.lines);
310    }
311    output
312}
313
314fn render_diff_code_block(
315    code: &str,
316    theme_styles: &ThemeStyles,
317    base_style: Style,
318    prefix_segments: &[MarkdownSegment],
319) -> Vec<MarkdownLine> {
320    render_diff_code_block_with_context(
321        code,
322        theme_styles,
323        base_style,
324        prefix_segments,
325        current_diff_render_style_context(),
326    )
327}
328
329pub(crate) fn render_diff_code_block_with_context(
330    code: &str,
331    theme_styles: &ThemeStyles,
332    base_style: Style,
333    prefix_segments: &[MarkdownSegment],
334    style_context: crate::tui::utils::diff_styles::DiffRenderStyleContext,
335) -> Vec<MarkdownLine> {
336    let mut lines = Vec::new();
337    let context_style = code_block_style(theme_styles, base_style);
338    let metadata_style = Style::new()
339        .fg_color(Some(anstyle::Color::Ansi(anstyle::AnsiColor::BrightBlack)))
340        .effects(Effects::DIMMED);
341    let hunk_style = style_hunk_header_ansi(style_context);
342    let added_style = style_content_ansi(DiffLineType::Insert, style_context);
343    let removed_style = style_content_ansi(DiffLineType::Delete, style_context);
344    let added_marker_style = style_sign_ansi(DiffLineType::Insert, style_context);
345    let removed_marker_style = style_sign_ansi(DiffLineType::Delete, style_context);
346    let file_old_style = style_file_header_old_ansi(style_context);
347    let file_new_style = style_file_header_new_ansi(style_context);
348    let added_background = added_style.get_bg_color();
349    let removed_background = removed_style.get_bg_color();
350    let added_word = style_context.add_word_bg();
351    let removed_word = style_context.del_word_bg();
352    let mut current_language: Option<String> = None;
353    let normalized = normalize_diff_lines(code);
354    let word_ranges_by_line = word_level_ranges_for_normalized(&normalized);
355
356    for (line_index, line) in normalized.iter().enumerate() {
357        let trimmed = line.trim_end_matches('\n');
358        let trimmed_start = trimmed.trim_start();
359        if let Some(path) = diff_path_for_language(trimmed_start) {
360            current_language = language_hint_from_path(&path);
361        }
362        if let Some((path, additions, deletions)) = parse_diff_summary_line(trimmed_start) {
363            let leading_len = trimmed.len().saturating_sub(trimmed_start.len());
364            let leading = &trimmed[..leading_len];
365            let mut line = prefixed_line(prefix_segments);
366            if !leading.is_empty() {
367                line.push_segment(context_style, leading);
368            }
369            // Summary counts keep accessible red/green foregrounds without
370            // the body tint.
371            let count_style = |kind: DiffLineType| {
372                let fg = match kind {
373                    DiffLineType::Insert => Some(diff_add_fg(style_context.theme(), style_context.level())),
374                    DiffLineType::Delete => Some(diff_del_fg(style_context.theme(), style_context.level())),
375                    DiffLineType::Context => None,
376                };
377                Style::new().fg_color(fg)
378            };
379            line.push_segment(context_style, &format!("{DIFF_SUMMARY_PREFIX}{path} ("));
380            line.push_segment(count_style(DiffLineType::Insert), &format!("+{additions}"));
381            line.push_segment(context_style, " ");
382            line.push_segment(count_style(DiffLineType::Delete), &format!("-{deletions}"));
383            line.push_segment(context_style, ")");
384            lines.push(line);
385            continue;
386        }
387
388        let mut line = prefixed_line(prefix_segments);
389        if !trimmed.is_empty() {
390            if trimmed_start.starts_with("diff --git ") || trimmed_start.starts_with("index ") {
391                line.push_segment(metadata_style, trimmed);
392            } else if trimmed_start.starts_with("--- ") || trimmed_start.starts_with("*** Delete File:") {
393                line.push_segment(file_old_style, trimmed);
394            } else if trimmed_start.starts_with("+++ ") || trimmed_start.starts_with("*** Add File:") {
395                line.push_segment(file_new_style, trimmed);
396            } else if is_diff_header_line(trimmed_start) {
397                line.push_segment(hunk_style, trimmed);
398            } else if is_diff_addition_line(trimmed_start) {
399                line.push_segment(added_marker_style, "+");
400                let body = &trimmed_start[1..];
401                if body.is_empty() {
402                    line.push_segment(added_style, " ");
403                } else {
404                    let word_ranges = word_ranges_by_line.get(line_index).map(Vec::as_slice).unwrap_or(&[]);
405                    push_highlighted_diff_body(
406                        &mut line,
407                        body,
408                        current_language.as_deref(),
409                        added_style,
410                        added_background,
411                        word_ranges,
412                        added_word,
413                    );
414                }
415                paint_line_background(&mut line, added_background);
416                line.set_line_background(added_background);
417            } else if trimmed_start.starts_with("*** Update File:") {
418                line.push_segment(added_marker_style, "+");
419                line.push_segment(added_style, trimmed_start);
420                paint_line_background(&mut line, added_background);
421                line.set_line_background(added_background);
422            } else if is_diff_deletion_line(trimmed_start) {
423                line.push_segment(removed_marker_style, "-");
424                let body = &trimmed_start[1..];
425                if body.is_empty() {
426                    line.push_segment(removed_style, " ");
427                } else {
428                    let word_ranges = word_ranges_by_line.get(line_index).map(Vec::as_slice).unwrap_or(&[]);
429                    push_highlighted_diff_body(
430                        &mut line,
431                        body,
432                        current_language.as_deref(),
433                        removed_style,
434                        removed_background,
435                        word_ranges,
436                        removed_word,
437                    );
438                }
439                paint_line_background(&mut line, removed_background);
440                line.set_line_background(removed_background);
441            } else if let Some(context_body) = trimmed.strip_prefix(' ') {
442                line.push_segment(context_style, " ");
443                line.push_segment(context_style, context_body);
444            } else {
445                line.push_segment(context_style, trimmed);
446            }
447        }
448        lines.push(line);
449    }
450
451    lines
452}
453
454/// Fill unpainted holes on a tinted diff row, including any blockquote/list
455/// prefix that was added before the diff marker.
456fn paint_line_background(line: &mut MarkdownLine, background: Option<AnstyleColor>) {
457    let Some(background) = background else {
458        return;
459    };
460    for segment in &mut line.segments {
461        if segment.style.get_bg_color().is_none() {
462            segment.style = segment.style.bg_color(Some(background));
463        }
464    }
465}
466
467/// Push a `+`/`-` diff body while preserving syntax foregrounds and applying
468/// the stronger background only to changed intraline ranges.
469fn push_highlighted_diff_body(
470    line: &mut MarkdownLine,
471    body: &str,
472    language: Option<&str>,
473    fallback: Style,
474    row_background: Option<AnstyleColor>,
475    word_ranges: &[(usize, usize)],
476    word_background: Option<AnstyleColor>,
477) {
478    let segments = render_diff_content_segments(body, language, fallback);
479    if segments.is_empty() {
480        line.push_segment(fallback.bg_color(row_background), " ");
481        return;
482    }
483
484    let mut cursor = 0usize;
485    for segment in segments {
486        let segment_len = segment.text.len();
487        let segment_end = cursor.saturating_add(segment_len);
488        let mut boundaries = vec![0, segment_len];
489        for &(start, end) in word_ranges {
490            if start < segment_end && end > cursor {
491                boundaries.push(start.max(cursor).saturating_sub(cursor).min(segment_len));
492                boundaries.push(end.min(segment_end).saturating_sub(cursor).min(segment_len));
493            }
494        }
495        boundaries.sort_unstable();
496        boundaries.dedup();
497
498        for pair in boundaries.windows(2) {
499            let start = pair[0];
500            let end = pair[1];
501            if start >= end {
502                continue;
503            }
504            let global_start = cursor.saturating_add(start);
505            let global_end = cursor.saturating_add(end);
506            let changed = word_background.is_some()
507                && word_ranges
508                    .iter()
509                    .any(|&(range_start, range_end)| global_start < range_end && global_end > range_start);
510            let background = if changed { word_background } else { row_background };
511            let mut style = segment.style.bg_color(background);
512            if style.get_fg_color().is_none() {
513                style = style.fg_color(fallback.get_fg_color());
514            }
515            line.push_segment(style, &segment.text[start..end]);
516        }
517        cursor = segment_end;
518    }
519}
520
521/// Map each normalized body line to byte-safe word-level ranges against its
522/// consecutive opposite-side line.
523fn word_level_ranges_for_normalized(normalized: &[String]) -> Vec<Vec<(usize, usize)>> {
524    let mut display = Vec::with_capacity(normalized.len());
525    for line in normalized {
526        let trimmed = line.trim_end_matches('\n');
527        let trimmed_start = trimmed.trim_start();
528        let kind = if is_diff_addition_line(trimmed_start) {
529            DiffDisplayKind::Addition
530        } else if is_diff_deletion_line(trimmed_start) {
531            DiffDisplayKind::Deletion
532        } else {
533            DiffDisplayKind::Context
534        };
535        let body = match kind {
536            DiffDisplayKind::Addition | DiffDisplayKind::Deletion => {
537                trimmed_start.strip_prefix(['+', '-']).unwrap_or("").to_owned()
538            }
539            DiffDisplayKind::Context | DiffDisplayKind::Metadata | DiffDisplayKind::HunkHeader => trimmed.to_owned(),
540        };
541        display.push(DiffDisplayLine::body(kind, None, None, body));
542    }
543    annotate_word_level_diffs(&mut display);
544    display.into_iter().map(|line| line.changed).collect()
545}
546
547fn diff_path_for_language(line: &str) -> Option<String> {
548    parse_diff_git_path(line).or_else(|| parse_diff_marker_path(line)).or_else(|| {
549        ["*** Update File:", "*** Add File:", "*** Delete File:"]
550            .iter()
551            .find_map(|prefix| line.strip_prefix(prefix).map(str::trim).filter(|path| !path.is_empty()))
552            .map(ToOwned::to_owned)
553    })
554}
555
556fn parse_diff_summary_line(line: &str) -> Option<(&str, usize, usize)> {
557    let summary = line.strip_prefix(DIFF_SUMMARY_PREFIX)?;
558    let (path, counts) = summary.rsplit_once(" (")?;
559    let counts = counts.strip_suffix(')')?;
560    let mut parts = counts.split_whitespace();
561    let additions = parts.next()?.strip_prefix('+')?.parse().ok()?;
562    let deletions = parts.next()?.strip_prefix('-')?.parse().ok()?;
563    Some((path, additions, deletions))
564}
565
566fn format_diff_summary(path: &str, additions: usize, deletions: usize) -> String {
567    format!("{DIFF_SUMMARY_PREFIX}{path} (+{additions} -{deletions})")
568}
569
570fn append_prefix_segments(line: &mut MarkdownLine, prefix_segments: &[MarkdownSegment]) {
571    for segment in prefix_segments {
572        line.push_segment(segment.style, &segment.text);
573    }
574}
575
576fn prefixed_line(prefix_segments: &[MarkdownSegment]) -> MarkdownLine {
577    let mut line = MarkdownLine::default();
578    append_prefix_segments(&mut line, prefix_segments);
579    line
580}
581
582fn line_number_style(theme_styles: &ThemeStyles, base_style: Style) -> Style {
583    let source = if base_style == theme_styles.tool_output {
584        theme_styles.tool_detail
585    } else {
586        base_style
587    };
588    // Blend the foreground with background at 35% for a visible but subdued gutter color.
589    // No DIMMED effect — keep line numbers readable without squinting.
590    let blended_fg = source
591        .get_fg_color()
592        .and_then(|fg| vtcode_commons::colors::blend_colors(&fg, &theme_styles.background, 0.35));
593    let mut style = Style::new();
594    if let Some(color) = blended_fg {
595        style = style.fg_color(Some(color));
596    } else if let Some(fg) = source.get_fg_color() {
597        style = style.fg_color(Some(fg));
598    } else {
599        // Last resort: use the theme foreground color
600        style = style.fg_color(Some(theme_styles.foreground));
601    }
602    style
603}
604
605/// Format the gutter text for a line. Returns `(gutter_text, source_line_advance)`.
606fn format_gutter_text(line_num: usize, width: usize, line_text: &str) -> (String, usize) {
607    if let Some(omitted) = parse_omitted_line_count(line_text) {
608        let range_end = line_num.saturating_add(omitted.saturating_sub(1));
609        let range = format!("{line_num}-{range_end}");
610        (format!("{range:>width$}  "), omitted)
611    } else {
612        (format!("{line_num:>width$}  "), 1)
613    }
614}
615
616fn code_line_with_prefix(
617    prefix_segments: &[MarkdownSegment],
618    gutter_text: Option<&str>,
619    gutter_style: Style,
620) -> MarkdownLine {
621    let mut line = MarkdownLine::default();
622    append_prefix_segments(&mut line, prefix_segments);
623    if let Some(text) = gutter_text {
624        line.push_segment(gutter_style, text);
625    }
626    line
627}
628
629fn line_number_width(line_count: usize) -> usize {
630    let digits = line_count.max(1).to_string().len();
631    digits.max(CODE_LINE_NUMBER_MIN_WIDTH)
632}
633
634fn source_line_count(code: &str) -> usize {
635    code.lines().map(|line| parse_omitted_line_count(line).unwrap_or(1)).sum()
636}
637
638/// Parse the number of omitted lines from a condensed line like
639/// `"… [+220 lines omitted; ...]"`.
640fn parse_omitted_line_count(text: &str) -> Option<usize> {
641    let trimmed = text.trim();
642    let after = trimmed.strip_prefix("… [+")?;
643    let end = after.find(' ')?;
644    let count_str = &after[..end];
645    count_str.parse::<usize>().ok()
646}
647
648fn code_block_contains_table(content: &str, language: Option<&str>) -> bool {
649    let Some(lang) = language else {
650        return false;
651    };
652    let lang_lower = lang.to_ascii_lowercase();
653    if !matches!(lang_lower.as_str(), "markdown" | "md") {
654        return false;
655    }
656
657    let trimmed = content.trim();
658    if trimmed.is_empty() {
659        return false;
660    }
661
662    let mut has_pipe_line = false;
663    let mut has_separator = false;
664    for line in trimmed.lines().take(4) {
665        let line = line.trim();
666        if line.contains('|') {
667            has_pipe_line = true;
668        }
669        if line.starts_with('|') && line.chars().all(|c| matches!(c, '|' | '-' | ':' | ' ')) {
670            has_separator = true;
671        }
672    }
673    if !has_pipe_line || !has_separator {
674        return false;
675    }
676
677    let options = Options::ENABLE_TABLES;
678    let parser = Parser::new_ext(trimmed, options);
679    for event in parser {
680        match event {
681            Event::Start(Tag::Table(_)) => return true,
682            Event::Start(Tag::Paragraph) | Event::Text(_) | Event::SoftBreak => continue,
683            _ => return false,
684        }
685    }
686    false
687}
688
689fn rewrite_diff_line(line: &str) -> String {
690    // Preserve the authored hunk header (including range counts). Rewriting to
691    // start-only form made partial hunks such as `@@ -65,19 +65,0 @@` read as a
692    // one-line change, contradicting the enclosing `• Diff … (+0 -19)` summary.
693    line.to_string()
694}
695
696fn bump_diff_counters(line: &str, additions: &mut usize, deletions: &mut usize) {
697    let trimmed = line.trim_start();
698    if is_diff_addition_line(trimmed) {
699        *additions += 1;
700    } else if is_diff_deletion_line(trimmed) {
701        *deletions += 1;
702    }
703}
704
705fn is_diff_language(language: Option<&str>) -> bool {
706    language.is_some_and(|lang| matches!(lang.to_ascii_lowercase().as_str(), "diff" | "patch" | "udiff" | "git"))
707}
708
709fn code_block_style(theme_styles: &ThemeStyles, base_style: Style) -> Style {
710    let base_fg = base_style.get_fg_color();
711    let theme_fg = theme_styles.output.get_fg_color();
712    let fg = if base_style.get_effects().contains(Effects::DIMMED) {
713        base_fg.or(theme_fg)
714    } else {
715        theme_fg.or(base_fg)
716    };
717    let mut style = base_style;
718    if let Some(color) = fg {
719        style = style.fg_color(Some(color));
720    } else {
721        // Ensure code text always has a visible foreground color
722        style = style.fg_color(Some(theme_styles.foreground));
723    }
724    style
725}
726
727pub(crate) fn normalize_code_indentation(code: &str, language: Option<&str>, preserve_indentation: bool) -> String {
728    if preserve_indentation {
729        return code.to_string();
730    }
731    let has_language_hint = language.is_some_and(|hint| {
732        matches!(
733            hint.to_lowercase().as_str(),
734            "rust"
735                | "rs"
736                | "python"
737                | "py"
738                | "javascript"
739                | "js"
740                | "jsx"
741                | "typescript"
742                | "ts"
743                | "tsx"
744                | "go"
745                | "golang"
746                | "java"
747                | "cpp"
748                | "c"
749                | "php"
750                | "html"
751                | "css"
752                | "sql"
753                | "csharp"
754                | "bash"
755                | "sh"
756                | "swift"
757        )
758    });
759
760    if !has_language_hint && language.is_some() {
761        return code.to_string();
762    }
763
764    let lines: Vec<&str> = code.lines().collect();
765    let min_indent = lines
766        .iter()
767        .filter(|line| !line.trim().is_empty())
768        .map(|line| &line[..line.len() - line.trim_start().len()])
769        .reduce(|acc, p| {
770            let mut len = 0;
771            for (c1, c2) in acc.chars().zip(p.chars()) {
772                if c1 != c2 {
773                    break;
774                }
775                len += c1.len_utf8();
776            }
777            &acc[..len]
778        })
779        .map(|s| s.len())
780        .unwrap_or(0);
781
782    let normalized = lines
783        .iter()
784        .map(|line| {
785            if line.trim().is_empty() {
786                line
787            } else if line.len() >= min_indent {
788                &line[min_indent..]
789            } else {
790                line
791            }
792        })
793        .collect::<Vec<_>>()
794        .join("\n");
795
796    if code.ends_with('\n') {
797        format!("{normalized}\n")
798    } else {
799        normalized
800    }
801}
802
803pub fn highlight_line_for_diff(line: &str, language: Option<&str>) -> Option<Vec<(Style, String)>> {
804    syntax_highlight::highlight_line_to_anstyle_segments(
805        line,
806        language,
807        syntax_highlight::get_active_syntax_theme(),
808        true,
809    )
810    .map(|segments| {
811        segments
812            .into_iter()
813            .map(|(style, text)| {
814                let fg = style.get_fg_color().map(|c| match c {
815                    anstyle::Color::Rgb(rgb) => {
816                        let brighten = |v: u8| (v as u16 * 120 / 100).min(255) as u8;
817                        anstyle::Color::Rgb(anstyle::RgbColor(brighten(rgb.0), brighten(rgb.1), brighten(rgb.2)))
818                    }
819                    anstyle::Color::Ansi(ansi) => match ansi {
820                        anstyle::AnsiColor::Black => anstyle::Color::Ansi(anstyle::AnsiColor::BrightWhite),
821                        anstyle::AnsiColor::Red => anstyle::Color::Ansi(anstyle::AnsiColor::BrightRed),
822                        anstyle::AnsiColor::Green => anstyle::Color::Ansi(anstyle::AnsiColor::BrightGreen),
823                        anstyle::AnsiColor::Yellow => anstyle::Color::Ansi(anstyle::AnsiColor::BrightYellow),
824                        anstyle::AnsiColor::Blue => anstyle::Color::Ansi(anstyle::AnsiColor::BrightBlue),
825                        anstyle::AnsiColor::Magenta => anstyle::Color::Ansi(anstyle::AnsiColor::BrightMagenta),
826                        anstyle::AnsiColor::Cyan => anstyle::Color::Ansi(anstyle::AnsiColor::BrightCyan),
827                        anstyle::AnsiColor::White => anstyle::Color::Ansi(anstyle::AnsiColor::BrightWhite),
828                        other => anstyle::Color::Ansi(other),
829                    },
830                    other => other,
831                });
832                let bg = style.get_bg_color();
833                let new_style = style.fg_color(fg).bg_color(bg);
834                (new_style, text)
835            })
836            .collect()
837    })
838}
839
840pub(crate) fn render_diff_content_segments(
841    content: &str,
842    language: Option<&str>,
843    fallback_style: Style,
844) -> Vec<MarkdownSegment> {
845    let text = content.trim_end_matches('\n');
846    if text.is_empty() {
847        return vec![MarkdownSegment::new(fallback_style, " ")];
848    }
849
850    // Prose diffs render as solid fallback: syntax colors on tinted diff
851    // backgrounds hurt contrast and obscure add/del semantics.
852    if is_prose_language_hint(language) {
853        return vec![MarkdownSegment::new(fallback_style, text)];
854    }
855
856    // Unknown/missing languages must stay solid so the diff add/del color
857    // (green/red) is preserved. Syntect falls back to "Plain Text" with a
858    // single default-style span, which would drop the diff foreground.
859    let hint = language.map(str::trim).filter(|hint| !hint.is_empty());
860    let Some(hint) = hint else {
861        return vec![MarkdownSegment::new(fallback_style, text)];
862    };
863    if std::ptr::eq(syntax_highlight::find_syntax_by_token(hint), syntax_highlight::find_syntax_plain_text()) {
864        return vec![MarkdownSegment::new(fallback_style, text)];
865    }
866
867    if let Some(segments) = highlight_line_for_diff(text, Some(hint))
868        && !segments.is_empty()
869    {
870        return segments
871            .into_iter()
872            .map(|(style, text)| MarkdownSegment::new(style, text))
873            .collect();
874    }
875
876    vec![MarkdownSegment::new(fallback_style, text)]
877}
878
879fn try_highlight(
880    code: &str,
881    language: Option<&str>,
882    config: &SyntaxHighlightingConfig,
883) -> Option<Vec<Vec<(Style, String)>>> {
884    let max_bytes = config.max_file_size_mb.saturating_mul(1024 * 1024);
885    if max_bytes > 0 && code.len() > max_bytes {
886        return None;
887    }
888
889    if let Some(lang) = language
890        && !config.enabled_languages.is_empty()
891    {
892        let direct_match = config.enabled_languages.iter().any(|entry| entry.eq_ignore_ascii_case(lang));
893        if !direct_match {
894            let syntax_ref = syntax_highlight::find_syntax_by_token(lang);
895            let resolved_match = config
896                .enabled_languages
897                .iter()
898                .any(|entry| entry.eq_ignore_ascii_case(&syntax_ref.name));
899            if !resolved_match {
900                return None;
901            }
902        }
903    }
904
905    let rendered = syntax_highlight::highlight_code_to_anstyle_line_segments(code, language, &config.theme, true);
906
907    Some(rendered)
908}
909
910#[derive(Clone, Debug)]
911pub struct HighlightedSegment {
912    style: Style,
913    text: String,
914}
915
916pub fn highlight_code_to_segments(
917    code: &str,
918    language: Option<&str>,
919    theme_name: &str,
920) -> Vec<Vec<HighlightedSegment>> {
921    syntax_highlight::highlight_code_to_anstyle_line_segments(code, language, theme_name, true)
922        .into_iter()
923        .map(|segments| {
924            segments
925                .into_iter()
926                .map(|(style, text)| HighlightedSegment { style, text })
927                .collect()
928        })
929        .collect()
930}
931
932pub fn highlight_code_to_ansi(code: &str, language: Option<&str>, theme_name: &str) -> Vec<String> {
933    let segments = highlight_code_to_segments(code, language, theme_name);
934    segments
935        .into_iter()
936        .map(|line_segments| {
937            let mut ansi_line = String::new();
938            for seg in line_segments {
939                let rendered = seg.style.render();
940                let _ = write!(ansi_line, "{rendered}{}{}", seg.text, anstyle::Reset);
941            }
942            ansi_line
943        })
944        .collect()
945}