Skip to main content

vtcode_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::config::loader::SyntaxHighlightingConfig;
4use crate::ui::syntax_highlight;
5use crate::ui::theme::ThemeStyles;
6use crate::utils::diff_styles::DiffColorPalette;
7use anstyle::{Effects, Style};
8use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
9use std::fmt::Write;
10use syntect::util::LinesWithEndings;
11use vtcode_commons::diff_paths::{
12    format_start_only_hunk_header, is_diff_addition_line, is_diff_deletion_line,
13    is_diff_header_line, is_diff_new_file_marker_line, language_hint_from_path,
14    looks_like_diff_content, parse_diff_git_path, parse_diff_marker_path,
15};
16
17const DIFF_SUMMARY_PREFIX: &str = "• Diff ";
18
19#[derive(Clone, Debug)]
20pub(crate) struct CodeBlockState {
21    pub(crate) language: Option<String>,
22    pub(crate) buffer: String,
23}
24
25pub(crate) struct CodeBlockRenderEnv<'a> {
26    pub(crate) lines: &'a mut Vec<MarkdownLine>,
27    pub(crate) current_line: &'a mut MarkdownLine,
28    pub(crate) blockquote_depth: usize,
29    pub(crate) list_continuation_prefix: &'a str,
30    pub(crate) pending_list_prefix: &'a mut Option<String>,
31    pub(crate) base_style: Style,
32    pub(crate) theme_styles: &'a ThemeStyles,
33    pub(crate) highlight_config: Option<&'a SyntaxHighlightingConfig>,
34    pub(crate) render_options: RenderMarkdownOptions,
35}
36
37pub(crate) fn handle_code_block_event(
38    event: &Event<'_>,
39    code_block: &mut Option<CodeBlockState>,
40    env: &mut CodeBlockRenderEnv<'_>,
41) -> bool {
42    if code_block.is_none() {
43        return false;
44    }
45
46    match event {
47        Event::Text(text) => {
48            if let Some(state) = code_block.as_mut() {
49                state.buffer.push_str(text);
50            }
51            true
52        }
53        Event::End(TagEnd::CodeBlock) => {
54            finalize_code_block(env, code_block, true, true);
55            true
56        }
57        _ => false,
58    }
59}
60
61pub(crate) fn finalize_unclosed_code_block(
62    code_block: &mut Option<CodeBlockState>,
63    env: &mut CodeBlockRenderEnv<'_>,
64) {
65    finalize_code_block(env, code_block, false, false);
66}
67
68fn finalize_code_block(
69    env: &mut CodeBlockRenderEnv<'_>,
70    code_block: &mut Option<CodeBlockState>,
71    allow_table_reparse: bool,
72    append_trailing_blank_line: bool,
73) {
74    flush_current_line(
75        env.lines,
76        env.current_line,
77        env.blockquote_depth,
78        env.list_continuation_prefix,
79        env.pending_list_prefix,
80        env.base_style,
81    );
82    if let Some(state) = code_block.take() {
83        let rendered = render_code_block_state(&state, env, allow_table_reparse);
84        env.lines.extend(rendered);
85        if append_trailing_blank_line {
86            push_blank_line(env.lines);
87        }
88    }
89}
90
91fn render_code_block_state(
92    state: &CodeBlockState,
93    env: &CodeBlockRenderEnv<'_>,
94    allow_table_reparse: bool,
95) -> Vec<MarkdownLine> {
96    if allow_table_reparse
97        && !env.render_options.disable_code_block_table_reparse
98        && code_block_contains_table(&state.buffer, state.language.as_deref())
99    {
100        return render_markdown_code_block_table(
101            &state.buffer,
102            env.base_style,
103            env.theme_styles,
104            env.highlight_config,
105            env.render_options,
106        );
107    }
108
109    let prefix = build_prefix_segments(
110        env.blockquote_depth,
111        env.list_continuation_prefix,
112        env.base_style,
113    );
114    highlight_code_block(
115        &state.buffer,
116        state.language.as_deref(),
117        env.highlight_config,
118        env.theme_styles,
119        env.base_style,
120        &prefix,
121        env.render_options.preserve_code_indentation,
122    )
123}
124
125fn render_markdown_code_block_table(
126    source: &str,
127    base_style: Style,
128    theme_styles: &ThemeStyles,
129    highlight_config: Option<&SyntaxHighlightingConfig>,
130    render_options: RenderMarkdownOptions,
131) -> Vec<MarkdownLine> {
132    let mut nested_options = render_options;
133    nested_options.disable_code_block_table_reparse = true;
134    super::render_markdown_to_lines_with_options(
135        source,
136        base_style,
137        theme_styles,
138        highlight_config,
139        nested_options,
140    )
141}
142
143fn build_prefix_segments(
144    blockquote_depth: usize,
145    list_continuation_prefix: &str,
146    base_style: Style,
147) -> Vec<MarkdownSegment> {
148    let mut segments =
149        Vec::with_capacity(blockquote_depth + usize::from(!list_continuation_prefix.is_empty()));
150    for _ in 0..blockquote_depth {
151        segments.push(MarkdownSegment::new(base_style.dimmed().italic(), "│ "));
152    }
153    if !list_continuation_prefix.is_empty() {
154        segments.push(MarkdownSegment::new(base_style, list_continuation_prefix));
155    }
156    segments
157}
158
159fn highlight_code_block(
160    code: &str,
161    language: Option<&str>,
162    highlight_config: Option<&SyntaxHighlightingConfig>,
163    theme_styles: &ThemeStyles,
164    base_style: Style,
165    prefix_segments: &[MarkdownSegment],
166    preserve_code_indentation: bool,
167) -> Vec<MarkdownLine> {
168    let mut lines = Vec::new();
169
170    let normalized_code = normalize_code_indentation(code, language, preserve_code_indentation);
171    let code_to_display = &normalized_code;
172    if is_diff_language(language)
173        || (language.is_none() && looks_like_diff_content(code_to_display))
174    {
175        return render_diff_code_block(code_to_display, theme_styles, base_style, prefix_segments);
176    }
177    let use_line_numbers =
178        language.is_some_and(|lang| !lang.trim().is_empty()) && !is_diff_language(language);
179
180    if let Some(config) = highlight_config.filter(|cfg| cfg.enabled)
181        && let Some(highlighted) = try_highlight(code_to_display, language, config)
182    {
183        let source_lines: Vec<&str> = code_to_display.lines().collect();
184        let line_count = source_line_count(code_to_display);
185        let number_width = line_number_width(line_count);
186        let gutter_style = line_number_style(theme_styles, base_style);
187        let mut line_number = 1usize;
188        for (index, segments) in highlighted.into_iter().enumerate() {
189            let src = source_lines.get(index).copied().unwrap_or("");
190            let is_omitted = parse_omitted_line_count(src).is_some();
191            let (gutter_text, omitted) = if use_line_numbers {
192                let (text, om) = format_gutter_text(line_number, number_width, src);
193                (Some(text), om)
194            } else {
195                (None, 1)
196            };
197            let mut line =
198                code_line_with_prefix(prefix_segments, gutter_text.as_deref(), gutter_style);
199            if is_omitted {
200                line.push_segment(gutter_style, src);
201            } else {
202                for (style, text) in segments {
203                    line.push_segment(style, &text);
204                }
205            }
206            line_number = line_number.saturating_add(omitted);
207            lines.push(line);
208        }
209        return lines;
210    }
211
212    let mut line_number = 1usize;
213    let line_count = source_line_count(code_to_display);
214    let number_width = line_number_width(line_count);
215    let gutter_style = line_number_style(theme_styles, base_style);
216
217    for raw_line in LinesWithEndings::from(code_to_display) {
218        let trimmed = raw_line.trim_end_matches('\n');
219        let is_omitted = parse_omitted_line_count(trimmed).is_some();
220        let (gutter_text, omitted) = if use_line_numbers {
221            let (text, om) = format_gutter_text(line_number, number_width, trimmed);
222            (Some(text), om)
223        } else {
224            (None, 1)
225        };
226        let mut line = code_line_with_prefix(prefix_segments, gutter_text.as_deref(), gutter_style);
227        if !trimmed.is_empty() {
228            if is_omitted {
229                line.push_segment(gutter_style, trimmed);
230            } else {
231                line.push_segment(code_block_style(theme_styles, base_style), trimmed);
232            }
233        }
234        lines.push(line);
235        line_number = line_number.saturating_add(omitted);
236    }
237
238    lines
239}
240
241pub(crate) fn normalize_diff_lines(code: &str) -> Vec<String> {
242    #[derive(Default)]
243    struct DiffBlock {
244        header: String,
245        path: String,
246        lines: Vec<String>,
247        additions: usize,
248        deletions: usize,
249    }
250
251    let mut preface = Vec::new();
252    let mut blocks = Vec::new();
253    let mut current: Option<DiffBlock> = None;
254    let mut fallback_additions = 0usize;
255    let mut fallback_deletions = 0usize;
256    let mut fallback_path: Option<String> = None;
257    let mut summary_insert_index: Option<usize> = None;
258
259    for line in code.lines() {
260        if fallback_path.is_none() {
261            fallback_path = parse_diff_marker_path(line);
262        }
263        if summary_insert_index.is_none() && is_diff_new_file_marker_line(line.trim_start()) {
264            summary_insert_index = Some(preface.len());
265        }
266        bump_diff_counters(line, &mut fallback_additions, &mut fallback_deletions);
267
268        if let Some(path) = parse_diff_git_path(line) {
269            if let Some(block) = current.take() {
270                blocks.push(block);
271            }
272            current = Some(DiffBlock {
273                header: line.to_string(),
274                path,
275                lines: Vec::new(),
276                additions: 0,
277                deletions: 0,
278            });
279            continue;
280        }
281
282        let rewritten = rewrite_diff_line(line);
283        if let Some(block) = current.as_mut() {
284            bump_diff_counters(line, &mut block.additions, &mut block.deletions);
285            block.lines.push(rewritten);
286        } else {
287            preface.push(rewritten);
288        }
289    }
290
291    if let Some(block) = current {
292        blocks.push(block);
293    }
294
295    if blocks.is_empty() {
296        let path = fallback_path.unwrap_or_else(|| "file".to_string());
297        let summary = format_diff_summary(path.as_str(), fallback_additions, fallback_deletions);
298
299        let mut output = Vec::with_capacity(preface.len() + 1);
300        if let Some(idx) = summary_insert_index {
301            output.extend(preface[..=idx].iter().cloned());
302            output.push(summary);
303            output.extend(preface[idx + 1..].iter().cloned());
304        } else {
305            output.push(summary);
306            output.extend(preface);
307        }
308        return output;
309    }
310
311    let mut output = Vec::new();
312    output.extend(preface);
313    for block in blocks {
314        output.push(block.header);
315        output.push(format_diff_summary(
316            block.path.as_str(),
317            block.additions,
318            block.deletions,
319        ));
320        output.extend(block.lines);
321    }
322    output
323}
324
325fn render_diff_code_block(
326    code: &str,
327    theme_styles: &ThemeStyles,
328    base_style: Style,
329    prefix_segments: &[MarkdownSegment],
330) -> Vec<MarkdownLine> {
331    let mut lines = Vec::new();
332    let palette = DiffColorPalette::default();
333    let context_style = code_block_style(theme_styles, base_style);
334    let header_style = palette.header_style();
335    let added_style = palette.added_style();
336    let removed_style = palette.removed_style();
337    let mut current_language_hint: Option<String> = None;
338
339    for line in normalize_diff_lines(code) {
340        let trimmed = line.trim_end_matches('\n');
341        let trimmed_start = trimmed.trim_start();
342        if let Some(path) =
343            parse_diff_git_path(trimmed_start).or_else(|| parse_diff_marker_path(trimmed_start))
344        {
345            current_language_hint = language_hint_from_path(&path);
346        }
347        if let Some((path, additions, deletions)) = parse_diff_summary_line(trimmed_start) {
348            let leading_len = trimmed.len().saturating_sub(trimmed_start.len());
349            let leading = &trimmed[..leading_len];
350            let mut line = prefixed_line(prefix_segments);
351            if !leading.is_empty() {
352                line.push_segment(context_style, leading);
353            }
354            line.push_segment(context_style, &format!("{DIFF_SUMMARY_PREFIX}{path} ("));
355            line.push_segment(added_style, &format!("+{additions}"));
356            line.push_segment(context_style, " ");
357            line.push_segment(removed_style, &format!("-{deletions}"));
358            line.push_segment(context_style, ")");
359            lines.push(line);
360            continue;
361        }
362        let style = if trimmed.is_empty() {
363            context_style
364        } else if is_diff_header_line(trimmed_start) {
365            header_style
366        } else if is_diff_addition_line(trimmed_start) {
367            added_style
368        } else if is_diff_deletion_line(trimmed_start) {
369            removed_style
370        } else {
371            context_style
372        };
373
374        let mut line = prefixed_line(prefix_segments);
375        if !trimmed.is_empty() {
376            if is_diff_header_line(trimmed_start) {
377                line.push_segment(style, trimmed);
378            } else {
379                let marker_len = trimmed
380                    .chars()
381                    .next()
382                    .map(|ch| ch.len_utf8())
383                    .unwrap_or_default();
384                let (marker, content) = trimmed.split_at(marker_len);
385                line.push_segment(style, marker);
386                for segment in
387                    render_diff_content_segments(content, current_language_hint.as_deref(), style)
388                {
389                    line.push_segment(segment.style, &segment.text);
390                }
391            }
392        }
393        lines.push(line);
394    }
395
396    lines
397}
398
399fn parse_diff_summary_line(line: &str) -> Option<(&str, usize, usize)> {
400    let summary = line.strip_prefix(DIFF_SUMMARY_PREFIX)?;
401    let (path, counts) = summary.rsplit_once(" (")?;
402    let counts = counts.strip_suffix(')')?;
403    let mut parts = counts.split_whitespace();
404    let additions = parts.next()?.strip_prefix('+')?.parse().ok()?;
405    let deletions = parts.next()?.strip_prefix('-')?.parse().ok()?;
406    Some((path, additions, deletions))
407}
408
409fn format_diff_summary(path: &str, additions: usize, deletions: usize) -> String {
410    format!("{DIFF_SUMMARY_PREFIX}{path} (+{additions} -{deletions})")
411}
412
413fn append_prefix_segments(line: &mut MarkdownLine, prefix_segments: &[MarkdownSegment]) {
414    for segment in prefix_segments {
415        line.push_segment(segment.style, &segment.text);
416    }
417}
418
419fn prefixed_line(prefix_segments: &[MarkdownSegment]) -> MarkdownLine {
420    let mut line = MarkdownLine::default();
421    append_prefix_segments(&mut line, prefix_segments);
422    line
423}
424
425fn line_number_style(theme_styles: &ThemeStyles, base_style: Style) -> Style {
426    let source = if base_style == theme_styles.tool_output {
427        theme_styles.tool_detail
428    } else {
429        base_style
430    };
431    let dimmed_fg = source
432        .get_fg_color()
433        .and_then(|fg| vtcode_commons::colors::blend_colors(&fg, &theme_styles.background, 0.55));
434    let mut style = Style::new().effects(source.get_effects() | Effects::DIMMED);
435    if let Some(color) = dimmed_fg {
436        style = style.fg_color(Some(color));
437    } else if let Some(fg) = source.get_fg_color() {
438        style = style.fg_color(Some(fg));
439    }
440    style
441}
442
443/// Format the gutter text for a line. Returns `(gutter_text, source_line_advance)`.
444fn format_gutter_text(line_num: usize, width: usize, line_text: &str) -> (String, usize) {
445    if let Some(omitted) = parse_omitted_line_count(line_text) {
446        let range_end = line_num.saturating_add(omitted.saturating_sub(1));
447        let range = format!("{line_num}-{range_end}");
448        (format!("{range:>width$}  "), omitted)
449    } else {
450        (format!("{line_num:>width$}  "), 1)
451    }
452}
453
454fn code_line_with_prefix(
455    prefix_segments: &[MarkdownSegment],
456    gutter_text: Option<&str>,
457    gutter_style: Style,
458) -> MarkdownLine {
459    let mut line = MarkdownLine::default();
460    append_prefix_segments(&mut line, prefix_segments);
461    if let Some(text) = gutter_text {
462        line.push_segment(gutter_style, text);
463    }
464    line
465}
466
467fn line_number_width(line_count: usize) -> usize {
468    let digits = line_count.max(1).to_string().len();
469    digits.max(CODE_LINE_NUMBER_MIN_WIDTH)
470}
471
472fn source_line_count(code: &str) -> usize {
473    let mut count = 0usize;
474    for raw_line in LinesWithEndings::from(code) {
475        let trimmed = raw_line.trim_end_matches('\n');
476        count = count.saturating_add(parse_omitted_line_count(trimmed).unwrap_or(1));
477    }
478    if code.ends_with('\n') {
479        count = count.saturating_add(1);
480    }
481    count
482}
483
484/// Parse the number of omitted lines from a condensed line like
485/// `"… [+220 lines omitted; ...]"`.
486fn parse_omitted_line_count(text: &str) -> Option<usize> {
487    let trimmed = text.trim();
488    let after = trimmed.strip_prefix("… [+")?;
489    let end = after.find(' ')?;
490    let count_str = &after[..end];
491    count_str.parse::<usize>().ok()
492}
493
494fn code_block_contains_table(content: &str, language: Option<&str>) -> bool {
495    if let Some(lang) = language {
496        let lang_lower = lang.to_ascii_lowercase();
497        if !matches!(
498            lang_lower.as_str(),
499            "markdown" | "md" | "text" | "txt" | "plaintext" | "plain"
500        ) {
501            return false;
502        }
503    }
504
505    let trimmed = content.trim();
506    if trimmed.is_empty() {
507        return false;
508    }
509
510    let mut has_pipe_line = false;
511    let mut has_separator = false;
512    for line in trimmed.lines().take(4) {
513        let line = line.trim();
514        if line.contains('|') {
515            has_pipe_line = true;
516        }
517        if line.starts_with('|') && line.chars().all(|c| matches!(c, '|' | '-' | ':' | ' ')) {
518            has_separator = true;
519        }
520    }
521    if !has_pipe_line || !has_separator {
522        return false;
523    }
524
525    let options = Options::ENABLE_TABLES;
526    let parser = Parser::new_ext(trimmed, options);
527    for event in parser {
528        match event {
529            Event::Start(Tag::Table(_)) => return true,
530            Event::Start(Tag::Paragraph) | Event::Text(_) | Event::SoftBreak => continue,
531            _ => return false,
532        }
533    }
534    false
535}
536
537fn rewrite_diff_line(line: &str) -> String {
538    format_start_only_hunk_header(line).unwrap_or_else(|| line.to_string())
539}
540
541fn bump_diff_counters(line: &str, additions: &mut usize, deletions: &mut usize) {
542    let trimmed = line.trim_start();
543    if is_diff_addition_line(trimmed) {
544        *additions += 1;
545    } else if is_diff_deletion_line(trimmed) {
546        *deletions += 1;
547    }
548}
549
550fn is_diff_language(language: Option<&str>) -> bool {
551    language.is_some_and(|lang| {
552        matches!(
553            lang.to_ascii_lowercase().as_str(),
554            "diff" | "patch" | "udiff" | "git"
555        )
556    })
557}
558
559fn code_block_style(theme_styles: &ThemeStyles, base_style: Style) -> Style {
560    let base_fg = base_style.get_fg_color();
561    let theme_fg = theme_styles.output.get_fg_color();
562    let fg = if base_style.get_effects().contains(Effects::DIMMED) {
563        base_fg.or(theme_fg)
564    } else {
565        theme_fg.or(base_fg)
566    };
567    let mut style = base_style;
568    if let Some(color) = fg {
569        style = style.fg_color(Some(color));
570    }
571    style
572}
573
574pub(crate) fn normalize_code_indentation(
575    code: &str,
576    language: Option<&str>,
577    preserve_indentation: bool,
578) -> String {
579    if preserve_indentation {
580        return code.to_string();
581    }
582    let has_language_hint = language.is_some_and(|hint| {
583        matches!(
584            hint.to_lowercase().as_str(),
585            "rust"
586                | "rs"
587                | "python"
588                | "py"
589                | "javascript"
590                | "js"
591                | "jsx"
592                | "typescript"
593                | "ts"
594                | "tsx"
595                | "go"
596                | "golang"
597                | "java"
598                | "cpp"
599                | "c"
600                | "php"
601                | "html"
602                | "css"
603                | "sql"
604                | "csharp"
605                | "bash"
606                | "sh"
607                | "swift"
608        )
609    });
610
611    if !has_language_hint && language.is_some() {
612        return code.to_string();
613    }
614
615    let lines: Vec<&str> = code.lines().collect();
616    let min_indent = lines
617        .iter()
618        .filter(|line| !line.trim().is_empty())
619        .map(|line| &line[..line.len() - line.trim_start().len()])
620        .reduce(|acc, p| {
621            let mut len = 0;
622            for (c1, c2) in acc.chars().zip(p.chars()) {
623                if c1 != c2 {
624                    break;
625                }
626                len += c1.len_utf8();
627            }
628            &acc[..len]
629        })
630        .map(|s| s.len())
631        .unwrap_or(0);
632
633    let normalized = lines
634        .iter()
635        .map(|line| {
636            if line.trim().is_empty() {
637                line
638            } else if line.len() >= min_indent {
639                &line[min_indent..]
640            } else {
641                line
642            }
643        })
644        .collect::<Vec<_>>()
645        .join("\n");
646
647    if code.ends_with('\n') {
648        format!("{normalized}\n")
649    } else {
650        normalized
651    }
652}
653
654pub fn highlight_line_for_diff(line: &str, language: Option<&str>) -> Option<Vec<(Style, String)>> {
655    syntax_highlight::highlight_line_to_anstyle_segments(
656        line,
657        language,
658        syntax_highlight::get_active_syntax_theme(),
659        true,
660    )
661    .map(|segments| {
662        segments
663            .into_iter()
664            .map(|(style, text)| {
665                let fg = style.get_fg_color().map(|c| match c {
666                    anstyle::Color::Rgb(rgb) => {
667                        let brighten = |v: u8| (v as u16 * 120 / 100).min(255) as u8;
668                        anstyle::Color::Rgb(anstyle::RgbColor(
669                            brighten(rgb.0),
670                            brighten(rgb.1),
671                            brighten(rgb.2),
672                        ))
673                    }
674                    anstyle::Color::Ansi(ansi) => match ansi {
675                        anstyle::AnsiColor::Black => {
676                            anstyle::Color::Ansi(anstyle::AnsiColor::BrightWhite)
677                        }
678                        anstyle::AnsiColor::Red => {
679                            anstyle::Color::Ansi(anstyle::AnsiColor::BrightRed)
680                        }
681                        anstyle::AnsiColor::Green => {
682                            anstyle::Color::Ansi(anstyle::AnsiColor::BrightGreen)
683                        }
684                        anstyle::AnsiColor::Yellow => {
685                            anstyle::Color::Ansi(anstyle::AnsiColor::BrightYellow)
686                        }
687                        anstyle::AnsiColor::Blue => {
688                            anstyle::Color::Ansi(anstyle::AnsiColor::BrightBlue)
689                        }
690                        anstyle::AnsiColor::Magenta => {
691                            anstyle::Color::Ansi(anstyle::AnsiColor::BrightMagenta)
692                        }
693                        anstyle::AnsiColor::Cyan => {
694                            anstyle::Color::Ansi(anstyle::AnsiColor::BrightCyan)
695                        }
696                        anstyle::AnsiColor::White => {
697                            anstyle::Color::Ansi(anstyle::AnsiColor::BrightWhite)
698                        }
699                        other => anstyle::Color::Ansi(other),
700                    },
701                    other => other,
702                });
703                let bg = style.get_bg_color();
704                let new_style = style.fg_color(fg).bg_color(bg);
705                (new_style, text)
706            })
707            .collect()
708    })
709}
710
711pub(crate) fn render_diff_content_segments(
712    content: &str,
713    language: Option<&str>,
714    fallback_style: Style,
715) -> Vec<MarkdownSegment> {
716    let text = content.trim_end_matches('\n');
717    if text.is_empty() {
718        return vec![MarkdownSegment::new(fallback_style, " ")];
719    }
720
721    if let Some(segments) = highlight_line_for_diff(text, language)
722        && !segments.is_empty()
723    {
724        return segments
725            .into_iter()
726            .map(|(style, text)| MarkdownSegment::new(style, text))
727            .collect();
728    }
729
730    vec![MarkdownSegment::new(fallback_style, text)]
731}
732
733fn try_highlight(
734    code: &str,
735    language: Option<&str>,
736    config: &SyntaxHighlightingConfig,
737) -> Option<Vec<Vec<(Style, String)>>> {
738    let max_bytes = config.max_file_size_mb.saturating_mul(1024 * 1024);
739    if max_bytes > 0 && code.len() > max_bytes {
740        return None;
741    }
742
743    if let Some(lang) = language
744        && !config.enabled_languages.is_empty()
745    {
746        let direct_match = config
747            .enabled_languages
748            .iter()
749            .any(|entry| entry.eq_ignore_ascii_case(lang));
750        if !direct_match {
751            let syntax_ref = syntax_highlight::find_syntax_by_token(lang);
752            let resolved_match = config
753                .enabled_languages
754                .iter()
755                .any(|entry| entry.eq_ignore_ascii_case(&syntax_ref.name));
756            if !resolved_match {
757                return None;
758            }
759        }
760    }
761
762    let rendered = syntax_highlight::highlight_code_to_anstyle_line_segments(
763        code,
764        language,
765        &config.theme,
766        true,
767    );
768
769    Some(rendered)
770}
771
772#[derive(Clone, Debug)]
773pub struct HighlightedSegment {
774    pub style: Style,
775    pub text: String,
776}
777
778pub fn highlight_code_to_segments(
779    code: &str,
780    language: Option<&str>,
781    theme_name: &str,
782) -> Vec<Vec<HighlightedSegment>> {
783    syntax_highlight::highlight_code_to_anstyle_line_segments(code, language, theme_name, true)
784        .into_iter()
785        .map(|segments| {
786            segments
787                .into_iter()
788                .map(|(style, text)| HighlightedSegment { style, text })
789                .collect()
790        })
791        .collect()
792}
793
794pub fn highlight_code_to_ansi(code: &str, language: Option<&str>, theme_name: &str) -> Vec<String> {
795    let segments = highlight_code_to_segments(code, language, theme_name);
796    segments
797        .into_iter()
798        .map(|line_segments| {
799            let mut ansi_line = String::new();
800            for seg in line_segments {
801                let rendered = seg.style.render();
802                let _ = write!(ansi_line, "{rendered}{}{}", seg.text, anstyle::Reset);
803            }
804            ansi_line
805        })
806        .collect()
807}