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