Skip to main content

vtcode_tui/ui/
markdown.rs

1//! Markdown rendering utilities for terminal output with syntax highlighting support.
2
3use crate::config::loader::SyntaxHighlightingConfig;
4use crate::ui::syntax_highlight::{find_syntax_by_token, load_theme, syntax_set};
5use crate::ui::theme::{self, ThemeStyles};
6use anstyle::{Effects, Style};
7use anstyle_syntect::to_anstyle;
8use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
9use std::cmp::max;
10use syntect::easy::HighlightLines;
11use syntect::highlighting::Theme;
12use syntect::parsing::SyntaxReference;
13use syntect::util::LinesWithEndings;
14use unicode_width::UnicodeWidthStr;
15
16use crate::utils::diff_styles::DiffColorPalette;
17
18const LIST_INDENT_WIDTH: usize = 2;
19const CODE_LINE_NUMBER_MIN_WIDTH: usize = 3;
20
21/// A styled text segment.
22#[derive(Clone, Debug)]
23pub struct MarkdownSegment {
24    pub style: Style,
25    pub text: String,
26}
27
28impl MarkdownSegment {
29    pub(crate) fn new(style: Style, text: impl Into<String>) -> Self {
30        Self {
31            style,
32            text: text.into(),
33        }
34    }
35}
36
37/// A rendered line composed of styled segments.
38#[derive(Clone, Debug, Default)]
39pub struct MarkdownLine {
40    pub segments: Vec<MarkdownSegment>,
41}
42
43impl MarkdownLine {
44    fn push_segment(&mut self, style: Style, text: &str) {
45        if text.is_empty() {
46            return;
47        }
48        if let Some(last) = self.segments.last_mut()
49            && last.style == style
50        {
51            last.text.push_str(text);
52            return;
53        }
54        self.segments.push(MarkdownSegment::new(style, text));
55    }
56
57    fn prepend_segments(&mut self, segments: &[MarkdownSegment]) {
58        if segments.is_empty() {
59            return;
60        }
61        let mut prefixed = Vec::with_capacity(segments.len() + self.segments.len());
62        prefixed.extend(segments.iter().cloned());
63        prefixed.append(&mut self.segments);
64        self.segments = prefixed;
65    }
66
67    fn width(&self) -> usize {
68        self.segments
69            .iter()
70            .map(|seg| UnicodeWidthStr::width(seg.text.as_str()))
71            .sum()
72    }
73}
74
75#[derive(Debug, Default)]
76struct TableBuffer {
77    headers: Vec<MarkdownLine>,
78    rows: Vec<Vec<MarkdownLine>>,
79    current_row: Vec<MarkdownLine>,
80    in_head: bool,
81}
82
83#[derive(Clone, Debug)]
84struct CodeBlockState {
85    language: Option<String>,
86    buffer: String,
87}
88
89#[derive(Clone, Debug)]
90struct ListState {
91    kind: ListKind,
92    depth: usize,
93    continuation: String,
94}
95
96#[derive(Clone, Debug)]
97enum ListKind {
98    Unordered,
99    Ordered { next: usize },
100}
101
102#[derive(Debug, Clone, Copy, Default)]
103pub struct RenderMarkdownOptions {
104    pub preserve_code_indentation: bool,
105    pub disable_code_block_table_reparse: bool,
106}
107
108/// Render markdown text to styled lines that can be written to the terminal renderer.
109pub fn render_markdown_to_lines(
110    source: &str,
111    base_style: Style,
112    theme_styles: &ThemeStyles,
113    highlight_config: Option<&SyntaxHighlightingConfig>,
114) -> Vec<MarkdownLine> {
115    render_markdown_to_lines_with_options(
116        source,
117        base_style,
118        theme_styles,
119        highlight_config,
120        RenderMarkdownOptions::default(),
121    )
122}
123
124pub fn render_markdown_to_lines_with_options(
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 parser_options = Options::ENABLE_STRIKETHROUGH
132        | Options::ENABLE_TABLES
133        | Options::ENABLE_TASKLISTS
134        | Options::ENABLE_FOOTNOTES;
135
136    let parser = Parser::new_ext(source, parser_options);
137
138    let mut lines = Vec::new();
139    let mut current_line = MarkdownLine::default();
140    let mut style_stack = vec![base_style];
141    let mut blockquote_depth = 0usize;
142    let mut list_stack: Vec<ListState> = Vec::new();
143    let mut pending_list_prefix: Option<String> = None;
144    let mut code_block: Option<CodeBlockState> = None;
145    let mut active_table: Option<TableBuffer> = None;
146    let mut table_cell_index: usize = 0;
147
148    for event in parser {
149        // Handle code block accumulation separately to avoid borrow conflicts
150        if code_block.is_some() {
151            match &event {
152                Event::Text(text) => {
153                    if let Some(state) = code_block.as_mut() {
154                        state.buffer.push_str(text);
155                    }
156                    continue;
157                }
158                Event::End(TagEnd::CodeBlock) => {
159                    flush_current_line(
160                        &mut lines,
161                        &mut current_line,
162                        blockquote_depth,
163                        &list_stack,
164                        &mut pending_list_prefix,
165                        theme_styles,
166                        base_style,
167                    );
168                    if let Some(state) = code_block.take() {
169                        // If the code block contains a GFM table (common when
170                        // LLMs wrap tables in ```markdown fences), render the
171                        // content as markdown so the table gets box-drawing
172                        // treatment instead of plain code-block line numbers.
173                        if !render_options.disable_code_block_table_reparse
174                            && code_block_contains_table(&state.buffer, state.language.as_deref())
175                        {
176                            let table_lines = render_markdown_code_block_table(
177                                &state.buffer,
178                                base_style,
179                                theme_styles,
180                                highlight_config,
181                                render_options,
182                            );
183                            lines.extend(table_lines);
184                        } else {
185                            let prefix = build_prefix_segments(
186                                blockquote_depth,
187                                &list_stack,
188                                theme_styles,
189                                base_style,
190                            );
191                            let highlighted = highlight_code_block(
192                                &state.buffer,
193                                state.language.as_deref(),
194                                highlight_config,
195                                theme_styles,
196                                base_style,
197                                &prefix,
198                                render_options.preserve_code_indentation,
199                            );
200                            lines.extend(highlighted);
201                        }
202                        push_blank_line(&mut lines);
203                    }
204                    continue;
205                }
206                _ => {}
207            }
208        }
209
210        let mut ctx = MarkdownContext {
211            style_stack: &mut style_stack,
212            blockquote_depth: &mut blockquote_depth,
213            list_stack: &mut list_stack,
214            pending_list_prefix: &mut pending_list_prefix,
215            lines: &mut lines,
216            current_line: &mut current_line,
217            theme_styles,
218            base_style,
219            code_block: &mut code_block,
220            active_table: &mut active_table,
221            table_cell_index: &mut table_cell_index,
222        };
223
224        match event {
225            Event::Start(ref tag) => handle_start_tag(tag, &mut ctx),
226            Event::End(tag) => handle_end_tag(tag, &mut ctx),
227            Event::Text(text) => append_text(&text, &mut ctx),
228            Event::Code(code) => {
229                ctx.ensure_prefix();
230                ctx.current_line
231                    .push_segment(inline_code_style(theme_styles, base_style), &code);
232            }
233            Event::SoftBreak => ctx.flush_line(),
234            Event::HardBreak => ctx.flush_line(),
235            Event::Rule => {
236                ctx.flush_line();
237                let mut line = MarkdownLine::default();
238                line.push_segment(base_style.dimmed(), &"―".repeat(32));
239                ctx.lines.push(line);
240                push_blank_line(ctx.lines);
241            }
242            Event::TaskListMarker(checked) => {
243                ctx.ensure_prefix();
244                ctx.current_line
245                    .push_segment(base_style, if checked { "[x] " } else { "[ ] " });
246            }
247            Event::Html(html) | Event::InlineHtml(html) => append_text(&html, &mut ctx),
248            Event::FootnoteReference(r) => append_text(&format!("[^{}]", r), &mut ctx),
249            Event::InlineMath(m) => append_text(&format!("${}$", m), &mut ctx),
250            Event::DisplayMath(m) => append_text(&format!("$$\n{}\n$$", m), &mut ctx),
251        }
252    }
253
254    // Handle unclosed code block at end of input
255    if let Some(state) = code_block.take() {
256        flush_current_line(
257            &mut lines,
258            &mut current_line,
259            blockquote_depth,
260            &list_stack,
261            &mut pending_list_prefix,
262            theme_styles,
263            base_style,
264        );
265        let prefix = build_prefix_segments(blockquote_depth, &list_stack, theme_styles, base_style);
266        let highlighted = highlight_code_block(
267            &state.buffer,
268            state.language.as_deref(),
269            highlight_config,
270            theme_styles,
271            base_style,
272            &prefix,
273            render_options.preserve_code_indentation,
274        );
275        lines.extend(highlighted);
276    }
277
278    if !current_line.segments.is_empty() {
279        lines.push(current_line);
280    }
281
282    trim_trailing_blank_lines(&mut lines);
283    lines
284}
285
286fn render_markdown_code_block_table(
287    source: &str,
288    base_style: Style,
289    theme_styles: &ThemeStyles,
290    highlight_config: Option<&SyntaxHighlightingConfig>,
291    render_options: RenderMarkdownOptions,
292) -> Vec<MarkdownLine> {
293    let mut nested_options = render_options;
294    nested_options.disable_code_block_table_reparse = true;
295    render_markdown_to_lines_with_options(
296        source,
297        base_style,
298        theme_styles,
299        highlight_config,
300        nested_options,
301    )
302}
303
304/// Convenience helper that renders markdown using the active theme without emitting output.
305pub fn render_markdown(source: &str) -> Vec<MarkdownLine> {
306    let styles = theme::active_styles();
307    render_markdown_to_lines(source, Style::default(), &styles, None)
308}
309
310struct MarkdownContext<'a> {
311    style_stack: &'a mut Vec<Style>,
312    blockquote_depth: &'a mut usize,
313    list_stack: &'a mut Vec<ListState>,
314    pending_list_prefix: &'a mut Option<String>,
315    lines: &'a mut Vec<MarkdownLine>,
316    current_line: &'a mut MarkdownLine,
317    theme_styles: &'a ThemeStyles,
318    base_style: Style,
319    code_block: &'a mut Option<CodeBlockState>,
320    active_table: &'a mut Option<TableBuffer>,
321    table_cell_index: &'a mut usize,
322}
323
324impl MarkdownContext<'_> {
325    fn current_style(&self) -> Style {
326        self.style_stack.last().copied().unwrap_or(self.base_style)
327    }
328
329    fn push_style(&mut self, modifier: impl FnOnce(Style) -> Style) {
330        self.style_stack.push(modifier(self.current_style()));
331    }
332
333    fn pop_style(&mut self) {
334        self.style_stack.pop();
335    }
336
337    fn flush_line(&mut self) {
338        flush_current_line(
339            self.lines,
340            self.current_line,
341            *self.blockquote_depth,
342            self.list_stack,
343            self.pending_list_prefix,
344            self.theme_styles,
345            self.base_style,
346        );
347    }
348
349    fn flush_paragraph(&mut self) {
350        self.flush_line();
351        push_blank_line(self.lines);
352    }
353
354    fn ensure_prefix(&mut self) {
355        ensure_prefix(
356            self.current_line,
357            *self.blockquote_depth,
358            self.list_stack,
359            self.pending_list_prefix,
360            self.theme_styles,
361            self.base_style,
362        );
363    }
364}
365
366fn handle_start_tag(tag: &Tag<'_>, ctx: &mut MarkdownContext<'_>) {
367    match tag {
368        Tag::Paragraph => {}
369        Tag::Heading { level, .. } => {
370            let style = heading_style(*level, ctx.theme_styles, ctx.base_style);
371            ctx.style_stack.push(style);
372            ctx.ensure_prefix();
373            // Don't add the heading marker symbols to the output - just apply the style
374        }
375        Tag::BlockQuote(_) => *ctx.blockquote_depth += 1,
376        Tag::List(start) => {
377            let depth = ctx.list_stack.len();
378            let kind = start
379                .map(|v| ListKind::Ordered {
380                    next: max(1, v as usize),
381                })
382                .unwrap_or(ListKind::Unordered);
383            ctx.list_stack.push(ListState {
384                kind,
385                depth,
386                continuation: String::new(),
387            });
388        }
389        Tag::Item => {
390            if let Some(state) = ctx.list_stack.last_mut() {
391                let indent = " ".repeat(state.depth * LIST_INDENT_WIDTH);
392                match &mut state.kind {
393                    ListKind::Unordered => {
394                        let bullet_char = match state.depth % 3 {
395                            0 => "•",
396                            1 => "◦",
397                            _ => "▪",
398                        };
399                        let bullet = format!("{}{} ", indent, bullet_char);
400                        state.continuation = format!("{}  ", indent);
401                        *ctx.pending_list_prefix = Some(bullet);
402                    }
403                    ListKind::Ordered { next } => {
404                        let bullet = format!("{}{}. ", indent, *next);
405                        let width = bullet.len().saturating_sub(indent.len());
406                        state.continuation = format!("{}{}", indent, " ".repeat(width));
407                        *ctx.pending_list_prefix = Some(bullet);
408                        *next += 1;
409                    }
410                }
411            }
412        }
413        Tag::Emphasis => ctx.push_style(Style::italic),
414        Tag::Strong => {
415            let theme_styles = ctx.theme_styles;
416            let base_style = ctx.base_style;
417            ctx.push_style(|style| strong_style(style, theme_styles, base_style));
418        }
419        Tag::Strikethrough => ctx.push_style(Style::strikethrough),
420        Tag::Superscript | Tag::Subscript => ctx.push_style(Style::italic),
421        Tag::Link { .. } | Tag::Image { .. } => ctx.push_style(Style::underline),
422        Tag::CodeBlock(kind) => {
423            let language = match kind {
424                CodeBlockKind::Fenced(info) => info
425                    .split_whitespace()
426                    .next()
427                    .filter(|lang| !lang.is_empty())
428                    .map(|lang| lang.to_string()),
429                CodeBlockKind::Indented => None,
430            };
431            *ctx.code_block = Some(CodeBlockState {
432                language,
433                buffer: String::new(),
434            });
435        }
436        Tag::Table(_) => {
437            ctx.flush_paragraph();
438            *ctx.active_table = Some(TableBuffer::default());
439            *ctx.table_cell_index = 0;
440        }
441        Tag::TableRow => {
442            if let Some(table) = ctx.active_table.as_mut() {
443                table.current_row.clear();
444            } else {
445                ctx.flush_line();
446            }
447            *ctx.table_cell_index = 0;
448        }
449        Tag::TableHead => {
450            if let Some(table) = ctx.active_table.as_mut() {
451                table.in_head = true;
452            }
453        }
454        Tag::TableCell => {
455            if ctx.active_table.is_none() {
456                ctx.ensure_prefix();
457            } else {
458                ctx.current_line.segments.clear();
459            }
460            *ctx.table_cell_index += 1;
461        }
462        Tag::FootnoteDefinition(_)
463        | Tag::HtmlBlock
464        | Tag::MetadataBlock(_)
465        | Tag::DefinitionList
466        | Tag::DefinitionListTitle
467        | Tag::DefinitionListDefinition => {}
468    }
469}
470
471fn handle_end_tag(tag: TagEnd, ctx: &mut MarkdownContext<'_>) {
472    match tag {
473        TagEnd::Paragraph => ctx.flush_paragraph(),
474        TagEnd::Heading(_) => {
475            ctx.flush_line();
476            ctx.pop_style();
477            push_blank_line(ctx.lines);
478        }
479        TagEnd::BlockQuote(_) => {
480            ctx.flush_line();
481            *ctx.blockquote_depth = ctx.blockquote_depth.saturating_sub(1);
482        }
483        TagEnd::List(_) => {
484            ctx.flush_line();
485            if ctx.list_stack.pop().is_some() {
486                if let Some(state) = ctx.list_stack.last() {
487                    ctx.pending_list_prefix.replace(state.continuation.clone());
488                } else {
489                    ctx.pending_list_prefix.take();
490                }
491            }
492            push_blank_line(ctx.lines);
493        }
494        TagEnd::Item => {
495            ctx.flush_line();
496            if let Some(state) = ctx.list_stack.last() {
497                ctx.pending_list_prefix.replace(state.continuation.clone());
498            }
499        }
500        TagEnd::Emphasis
501        | TagEnd::Strong
502        | TagEnd::Strikethrough
503        | TagEnd::Superscript
504        | TagEnd::Subscript
505        | TagEnd::Link
506        | TagEnd::Image => {
507            ctx.pop_style();
508        }
509        TagEnd::CodeBlock => {}
510        TagEnd::Table => {
511            if let Some(mut table) = ctx.active_table.take() {
512                if !table.current_row.is_empty() {
513                    table.rows.push(std::mem::take(&mut table.current_row));
514                }
515                let rendered = render_table(&table, ctx.theme_styles, ctx.base_style);
516                ctx.lines.extend(rendered);
517            }
518            push_blank_line(ctx.lines);
519            *ctx.table_cell_index = 0;
520        }
521        TagEnd::TableRow => {
522            if let Some(table) = ctx.active_table.as_mut() {
523                if table.in_head {
524                    table.headers = std::mem::take(&mut table.current_row);
525                } else {
526                    table.rows.push(std::mem::take(&mut table.current_row));
527                }
528            } else {
529                ctx.flush_line();
530            }
531            *ctx.table_cell_index = 0;
532        }
533        TagEnd::TableCell => {
534            if let Some(table) = ctx.active_table.as_mut() {
535                table.current_row.push(std::mem::take(ctx.current_line));
536            }
537        }
538        TagEnd::TableHead => {
539            if let Some(table) = ctx.active_table.as_mut() {
540                table.in_head = false;
541            }
542        }
543        TagEnd::FootnoteDefinition
544        | TagEnd::HtmlBlock
545        | TagEnd::MetadataBlock(_)
546        | TagEnd::DefinitionList
547        | TagEnd::DefinitionListTitle
548        | TagEnd::DefinitionListDefinition => {}
549    }
550}
551
552fn render_table(
553    table: &TableBuffer,
554    _theme_styles: &ThemeStyles,
555    base_style: Style,
556) -> Vec<MarkdownLine> {
557    let mut lines = Vec::new();
558    if table.headers.is_empty() && table.rows.is_empty() {
559        return lines;
560    }
561
562    // Calculate column widths
563    let mut col_widths: Vec<usize> = Vec::new();
564
565    // Check headers
566    for (i, cell) in table.headers.iter().enumerate() {
567        if i >= col_widths.len() {
568            col_widths.push(0);
569        }
570        col_widths[i] = max(col_widths[i], cell.width());
571    }
572
573    // Check rows
574    for row in &table.rows {
575        for (i, cell) in row.iter().enumerate() {
576            if i >= col_widths.len() {
577                col_widths.push(0);
578            }
579            col_widths[i] = max(col_widths[i], cell.width());
580        }
581    }
582
583    let border_style = base_style.dimmed();
584
585    let render_row = |cells: &[MarkdownLine], col_widths: &[usize], bold: bool| -> MarkdownLine {
586        let mut line = MarkdownLine::default();
587        line.push_segment(border_style, "│ ");
588        for (i, width) in col_widths.iter().enumerate() {
589            if let Some(c) = cells.get(i) {
590                for seg in &c.segments {
591                    let s = if bold { seg.style.bold() } else { seg.style };
592                    line.push_segment(s, &seg.text);
593                }
594                let padding = width.saturating_sub(c.width());
595                if padding > 0 {
596                    line.push_segment(base_style, &" ".repeat(padding));
597                }
598            } else {
599                line.push_segment(base_style, &" ".repeat(*width));
600            }
601            line.push_segment(border_style, " │ ");
602        }
603        line
604    };
605
606    // Render Headers
607    if !table.headers.is_empty() {
608        lines.push(render_row(&table.headers, &col_widths, true));
609
610        // Separator line
611        let mut sep = MarkdownLine::default();
612        sep.push_segment(border_style, "├─");
613        for (i, width) in col_widths.iter().enumerate() {
614            sep.push_segment(border_style, &"─".repeat(*width));
615            sep.push_segment(
616                border_style,
617                if i < col_widths.len() - 1 {
618                    "─┼─"
619                } else {
620                    "─┤"
621                },
622            );
623        }
624        lines.push(sep);
625    }
626
627    // Render Rows
628    for row in &table.rows {
629        lines.push(render_row(row, &col_widths, false));
630    }
631
632    lines
633}
634
635fn append_text(text: &str, ctx: &mut MarkdownContext<'_>) {
636    let style = ctx.current_style();
637    let mut start = 0usize;
638    let mut chars = text.char_indices().peekable();
639
640    while let Some((idx, ch)) = chars.next() {
641        if ch == '\n' {
642            let segment = &text[start..idx];
643            if !segment.is_empty() {
644                ctx.ensure_prefix();
645                ctx.current_line.push_segment(style, segment);
646            }
647            ctx.lines.push(std::mem::take(ctx.current_line));
648            start = idx + 1;
649            // Skip consecutive newlines (one blank line per sequence)
650            while chars.peek().is_some_and(|&(_, c)| c == '\n') {
651                let (_, c) = chars.next().expect("peeked");
652                start += c.len_utf8();
653            }
654        }
655    }
656
657    if start < text.len() {
658        let remaining = &text[start..];
659        if !remaining.is_empty() {
660            ctx.ensure_prefix();
661            ctx.current_line.push_segment(style, remaining);
662        }
663    }
664}
665
666fn ensure_prefix(
667    current_line: &mut MarkdownLine,
668    blockquote_depth: usize,
669    list_stack: &[ListState],
670    pending_list_prefix: &mut Option<String>,
671    _theme_styles: &ThemeStyles,
672    base_style: Style,
673) {
674    if !current_line.segments.is_empty() {
675        return;
676    }
677
678    for _ in 0..blockquote_depth {
679        current_line.push_segment(base_style.dimmed().italic(), "│ ");
680    }
681
682    if let Some(prefix) = pending_list_prefix.take() {
683        current_line.push_segment(base_style, &prefix);
684    } else if !list_stack.is_empty() {
685        let mut continuation = String::new();
686        for state in list_stack {
687            continuation.push_str(&state.continuation);
688        }
689        if !continuation.is_empty() {
690            current_line.push_segment(base_style, &continuation);
691        }
692    }
693}
694
695fn flush_current_line(
696    lines: &mut Vec<MarkdownLine>,
697    current_line: &mut MarkdownLine,
698    blockquote_depth: usize,
699    list_stack: &[ListState],
700    pending_list_prefix: &mut Option<String>,
701    theme_styles: &ThemeStyles,
702    base_style: Style,
703) {
704    if current_line.segments.is_empty() && pending_list_prefix.is_some() {
705        ensure_prefix(
706            current_line,
707            blockquote_depth,
708            list_stack,
709            pending_list_prefix,
710            theme_styles,
711            base_style,
712        );
713    }
714
715    if !current_line.segments.is_empty() {
716        lines.push(std::mem::take(current_line));
717    }
718}
719
720fn push_blank_line(lines: &mut Vec<MarkdownLine>) {
721    if lines
722        .last()
723        .map(|line| line.segments.is_empty())
724        .unwrap_or(false)
725    {
726        return;
727    }
728    lines.push(MarkdownLine::default());
729}
730
731fn trim_trailing_blank_lines(lines: &mut Vec<MarkdownLine>) {
732    while lines
733        .last()
734        .map(|line| line.segments.is_empty())
735        .unwrap_or(false)
736    {
737        lines.pop();
738    }
739}
740
741fn inline_code_style(theme_styles: &ThemeStyles, base_style: Style) -> Style {
742    let mut style = base_style.bold();
743    if should_apply_markdown_accent(base_style, theme_styles)
744        && let Some(color) = choose_markdown_accent(
745            base_style,
746            &[
747                theme_styles.secondary,
748                theme_styles.primary,
749                theme_styles.tool_detail,
750                theme_styles.status,
751            ],
752        )
753    {
754        style = style.fg_color(Some(color));
755    }
756    style
757}
758
759fn heading_style(_level: HeadingLevel, theme_styles: &ThemeStyles, base_style: Style) -> Style {
760    let mut style = base_style.bold();
761    if should_apply_markdown_accent(base_style, theme_styles)
762        && let Some(color) = choose_markdown_accent(
763            base_style,
764            &[
765                theme_styles.primary,
766                theme_styles.secondary,
767                theme_styles.status,
768                theme_styles.tool,
769            ],
770        )
771    {
772        style = style.fg_color(Some(color));
773    }
774    style
775}
776
777fn strong_style(current: Style, theme_styles: &ThemeStyles, base_style: Style) -> Style {
778    let mut style = current.bold();
779    if should_apply_markdown_accent(base_style, theme_styles)
780        && let Some(color) = choose_markdown_accent(
781            base_style,
782            &[
783                theme_styles.primary,
784                theme_styles.secondary,
785                theme_styles.status,
786                theme_styles.tool,
787            ],
788        )
789    {
790        style = style.fg_color(Some(color));
791    }
792    style
793}
794
795fn should_apply_markdown_accent(base_style: Style, theme_styles: &ThemeStyles) -> bool {
796    base_style == theme_styles.response
797}
798
799fn choose_markdown_accent(base_style: Style, candidates: &[Style]) -> Option<anstyle::Color> {
800    let base_fg = base_style.get_fg_color();
801    candidates.iter().find_map(|candidate| {
802        candidate
803            .get_fg_color()
804            .filter(|color| base_fg != Some(*color))
805    })
806}
807
808fn build_prefix_segments(
809    blockquote_depth: usize,
810    list_stack: &[ListState],
811    _theme_styles: &ThemeStyles,
812    base_style: Style,
813) -> Vec<MarkdownSegment> {
814    let mut segments = Vec::new();
815    for _ in 0..blockquote_depth {
816        segments.push(MarkdownSegment::new(base_style.dimmed().italic(), "│ "));
817    }
818    if !list_stack.is_empty() {
819        let mut continuation = String::new();
820        for state in list_stack {
821            continuation.push_str(&state.continuation);
822        }
823        if !continuation.is_empty() {
824            segments.push(MarkdownSegment::new(base_style, continuation));
825        }
826    }
827    segments
828}
829
830fn highlight_code_block(
831    code: &str,
832    language: Option<&str>,
833    highlight_config: Option<&SyntaxHighlightingConfig>,
834    theme_styles: &ThemeStyles,
835    base_style: Style,
836    prefix_segments: &[MarkdownSegment],
837    preserve_code_indentation: bool,
838) -> Vec<MarkdownLine> {
839    let mut lines = Vec::new();
840
841    // Normalize indentation unless we're preserving raw tool output.
842    let normalized_code = normalize_code_indentation(code, language, preserve_code_indentation);
843    let code_to_display = &normalized_code;
844    if is_diff_language(language)
845        || (language.is_none() && looks_like_diff_content(code_to_display))
846    {
847        return render_diff_code_block(code_to_display, theme_styles, base_style, prefix_segments);
848    }
849    let use_line_numbers =
850        language.is_some_and(|lang| !lang.trim().is_empty()) && !is_diff_language(language);
851
852    if let Some(config) = highlight_config.filter(|cfg| cfg.enabled)
853        && let Some(highlighted) = try_highlight(code_to_display, language, config)
854    {
855        let line_count = highlighted.len();
856        let number_width = line_number_width(line_count);
857        for (index, segments) in highlighted.into_iter().enumerate() {
858            let mut line = MarkdownLine::default();
859            let line_prefix = if use_line_numbers {
860                line_prefix_segments(
861                    prefix_segments,
862                    theme_styles,
863                    base_style,
864                    index + 1,
865                    number_width,
866                )
867            } else {
868                prefix_segments.to_vec()
869            };
870            line.prepend_segments(&line_prefix);
871            for (style, text) in segments {
872                line.push_segment(style, &text);
873            }
874            lines.push(line);
875        }
876        return lines;
877    }
878
879    // Fallback: render without syntax highlighting, but still with normalized indentation
880    let mut line_number = 1usize;
881    let mut line_count = LinesWithEndings::from(code_to_display).count();
882    if code_to_display.ends_with('\n') {
883        line_count = line_count.saturating_add(1);
884    }
885    let number_width = line_number_width(line_count);
886
887    for raw_line in LinesWithEndings::from(code_to_display) {
888        let trimmed = raw_line.trim_end_matches('\n');
889        let mut line = MarkdownLine::default();
890        let line_prefix = if use_line_numbers {
891            line_prefix_segments(
892                prefix_segments,
893                theme_styles,
894                base_style,
895                line_number,
896                number_width,
897            )
898        } else {
899            prefix_segments.to_vec()
900        };
901        line.prepend_segments(&line_prefix);
902        if !trimmed.is_empty() {
903            line.push_segment(code_block_style(theme_styles, base_style), trimmed);
904        }
905        lines.push(line);
906        line_number = line_number.saturating_add(1);
907    }
908
909    if code_to_display.ends_with('\n') {
910        let mut line = MarkdownLine::default();
911        let line_prefix = if use_line_numbers {
912            line_prefix_segments(
913                prefix_segments,
914                theme_styles,
915                base_style,
916                line_number,
917                number_width,
918            )
919        } else {
920            prefix_segments.to_vec()
921        };
922        line.prepend_segments(&line_prefix);
923        lines.push(line);
924    }
925
926    lines
927}
928
929fn format_start_only_hunk_header(line: &str) -> Option<String> {
930    let trimmed = line.trim_end();
931    if !trimmed.starts_with("@@ -") {
932        return None;
933    }
934
935    let rest = trimmed.strip_prefix("@@ -")?;
936    let mut parts = rest.split_whitespace();
937    let old_part = parts.next()?;
938    let new_part = parts.next()?;
939
940    if !new_part.starts_with('+') {
941        return None;
942    }
943
944    let old_start = old_part.split(',').next()?.parse::<usize>().ok()?;
945    let new_start = new_part
946        .trim_start_matches('+')
947        .split(',')
948        .next()?
949        .parse::<usize>()
950        .ok()?;
951
952    Some(format!("@@ -{} +{} @@", old_start, new_start))
953}
954
955fn parse_diff_git_path(line: &str) -> Option<String> {
956    let mut parts = line.split_whitespace();
957    if parts.next()? != "diff" {
958        return None;
959    }
960    if parts.next()? != "--git" {
961        return None;
962    }
963    let _old = parts.next()?;
964    let new_path = parts.next()?;
965    Some(new_path.trim_start_matches("b/").to_string())
966}
967
968fn parse_diff_marker_path(line: &str) -> Option<String> {
969    let trimmed = line.trim_start();
970    if !(trimmed.starts_with("--- ") || trimmed.starts_with("+++ ")) {
971        return None;
972    }
973    let path = trimmed.split_whitespace().nth(1)?;
974    if path == "/dev/null" {
975        return None;
976    }
977    Some(
978        path.trim_start_matches("a/")
979            .trim_start_matches("b/")
980            .to_string(),
981    )
982}
983
984fn is_addition_line(line: &str) -> bool {
985    let trimmed = line.trim_start();
986    trimmed.starts_with('+') && !trimmed.starts_with("+++")
987}
988
989fn is_deletion_line(line: &str) -> bool {
990    let trimmed = line.trim_start();
991    trimmed.starts_with('-') && !trimmed.starts_with("---")
992}
993
994fn normalize_diff_lines(code: &str) -> Vec<String> {
995    #[derive(Default)]
996    struct DiffBlock {
997        header: String,
998        path: String,
999        lines: Vec<String>,
1000        additions: usize,
1001        deletions: usize,
1002    }
1003
1004    let mut preface = Vec::new();
1005    let mut blocks = Vec::new();
1006    let mut current: Option<DiffBlock> = None;
1007
1008    for line in code.lines() {
1009        if let Some(path) = parse_diff_git_path(line) {
1010            if let Some(block) = current.take() {
1011                blocks.push(block);
1012            }
1013            current = Some(DiffBlock {
1014                header: line.to_string(),
1015                path,
1016                lines: Vec::new(),
1017                additions: 0,
1018                deletions: 0,
1019            });
1020            continue;
1021        }
1022
1023        let rewritten = format_start_only_hunk_header(line).unwrap_or_else(|| line.to_string());
1024        if let Some(block) = current.as_mut() {
1025            if is_addition_line(line) {
1026                block.additions += 1;
1027            } else if is_deletion_line(line) {
1028                block.deletions += 1;
1029            }
1030            block.lines.push(rewritten);
1031        } else {
1032            preface.push(rewritten);
1033        }
1034    }
1035
1036    if let Some(block) = current {
1037        blocks.push(block);
1038    }
1039
1040    if blocks.is_empty() {
1041        let mut additions = 0usize;
1042        let mut deletions = 0usize;
1043        let mut fallback_path: Option<String> = None;
1044        let mut summary_insert_index: Option<usize> = None;
1045        let mut lines: Vec<String> = Vec::new();
1046
1047        for line in code.lines() {
1048            if fallback_path.is_none() {
1049                fallback_path = parse_diff_marker_path(line);
1050            }
1051            if summary_insert_index.is_none() && line.trim_start().starts_with("+++ ") {
1052                summary_insert_index = Some(lines.len());
1053            }
1054            if is_addition_line(line) {
1055                additions += 1;
1056            } else if is_deletion_line(line) {
1057                deletions += 1;
1058            }
1059            let rewritten = format_start_only_hunk_header(line).unwrap_or_else(|| line.to_string());
1060            lines.push(rewritten);
1061        }
1062
1063        let path = fallback_path.unwrap_or_else(|| "file".to_string());
1064        let summary = format!("• Diff {} (+{} -{})", path, additions, deletions);
1065
1066        let mut output = Vec::with_capacity(lines.len() + 1);
1067        if let Some(idx) = summary_insert_index {
1068            output.extend(lines[..=idx].iter().cloned());
1069            output.push(summary);
1070            output.extend(lines[idx + 1..].iter().cloned());
1071        } else {
1072            output.push(summary);
1073            output.extend(lines);
1074        }
1075        return output;
1076    }
1077
1078    let mut output = Vec::new();
1079    output.extend(preface);
1080    for block in blocks {
1081        output.push(block.header);
1082        output.push(format!(
1083            "• Diff {} (+{} -{})",
1084            block.path, block.additions, block.deletions
1085        ));
1086        output.extend(block.lines);
1087    }
1088    output
1089}
1090
1091fn render_diff_code_block(
1092    code: &str,
1093    theme_styles: &ThemeStyles,
1094    base_style: Style,
1095    prefix_segments: &[MarkdownSegment],
1096) -> Vec<MarkdownLine> {
1097    let mut lines = Vec::new();
1098    let palette = DiffColorPalette::default();
1099    let context_style = code_block_style(theme_styles, base_style);
1100    let header_style = palette.header_style();
1101    let added_style = palette.added_style();
1102    let removed_style = palette.removed_style();
1103
1104    for line in normalize_diff_lines(code) {
1105        let trimmed = line.trim_end_matches('\n');
1106        let trimmed_start = trimmed.trim_start();
1107        if let Some((path, additions, deletions)) = parse_diff_summary_line(trimmed_start) {
1108            let leading_len = trimmed.len().saturating_sub(trimmed_start.len());
1109            let leading = &trimmed[..leading_len];
1110            let mut line = MarkdownLine::default();
1111            line.prepend_segments(prefix_segments);
1112            if !leading.is_empty() {
1113                line.push_segment(context_style, leading);
1114            }
1115            line.push_segment(context_style, &format!("• Diff {path} ("));
1116            line.push_segment(added_style, &format!("+{additions}"));
1117            line.push_segment(context_style, " ");
1118            line.push_segment(removed_style, &format!("-{deletions}"));
1119            line.push_segment(context_style, ")");
1120            lines.push(line);
1121            continue;
1122        }
1123        let style = if trimmed.is_empty() {
1124            context_style
1125        } else if is_diff_header_line(trimmed_start) {
1126            header_style
1127        } else if trimmed.starts_with('+') && !trimmed.starts_with("+++") {
1128            added_style
1129        } else if trimmed.starts_with('-') && !trimmed.starts_with("---") {
1130            removed_style
1131        } else {
1132            context_style
1133        };
1134
1135        let mut line = MarkdownLine::default();
1136        line.prepend_segments(prefix_segments);
1137        if !trimmed.is_empty() {
1138            line.push_segment(style, trimmed);
1139        }
1140        lines.push(line);
1141    }
1142
1143    if code.ends_with('\n') {
1144        let mut line = MarkdownLine::default();
1145        line.prepend_segments(prefix_segments);
1146        lines.push(line);
1147    }
1148
1149    lines
1150}
1151
1152fn parse_diff_summary_line(line: &str) -> Option<(&str, usize, usize)> {
1153    let summary = line.strip_prefix("• Diff ")?;
1154    let (path, counts) = summary.rsplit_once(" (")?;
1155    let counts = counts.strip_suffix(')')?;
1156    let mut parts = counts.split_whitespace();
1157    let additions = parts.next()?.strip_prefix('+')?.parse().ok()?;
1158    let deletions = parts.next()?.strip_prefix('-')?.parse().ok()?;
1159    Some((path, additions, deletions))
1160}
1161
1162fn is_diff_header_line(trimmed: &str) -> bool {
1163    trimmed.starts_with("diff --git ")
1164        || trimmed.starts_with("@@ ")
1165        || trimmed.starts_with("index ")
1166        || trimmed.starts_with("new file mode ")
1167        || trimmed.starts_with("deleted file mode ")
1168        || trimmed.starts_with("rename from ")
1169        || trimmed.starts_with("rename to ")
1170        || trimmed.starts_with("copy from ")
1171        || trimmed.starts_with("copy to ")
1172        || trimmed.starts_with("similarity index ")
1173        || trimmed.starts_with("dissimilarity index ")
1174        || trimmed.starts_with("old mode ")
1175        || trimmed.starts_with("new mode ")
1176        || trimmed.starts_with("Binary files ")
1177        || trimmed.starts_with("\\ No newline at end of file")
1178        || trimmed.starts_with("+++ ")
1179        || trimmed.starts_with("--- ")
1180}
1181
1182fn looks_like_diff_content(code: &str) -> bool {
1183    code.lines()
1184        .any(|line| is_diff_header_line(line.trim_start()))
1185}
1186
1187fn line_prefix_segments(
1188    prefix_segments: &[MarkdownSegment],
1189    _theme_styles: &ThemeStyles,
1190    base_style: Style,
1191    line_number: usize,
1192    width: usize,
1193) -> Vec<MarkdownSegment> {
1194    let mut segments = prefix_segments.to_vec();
1195    let number_text = format!("{:>width$}  ", line_number, width = width);
1196    segments.push(MarkdownSegment::new(base_style.dimmed(), number_text));
1197    segments
1198}
1199
1200fn line_number_width(line_count: usize) -> usize {
1201    let digits = line_count.max(1).to_string().len();
1202    digits.max(CODE_LINE_NUMBER_MIN_WIDTH)
1203}
1204
1205/// Check if a code block's content is primarily a GFM table.
1206///
1207/// LLMs frequently wrap markdown tables in fenced code blocks (e.g.
1208/// ````markdown` or ` ```text`), which causes them to render as plain code
1209/// with line numbers instead of formatted tables. This function detects that
1210/// pattern so the caller can render the content as markdown instead.
1211fn code_block_contains_table(content: &str, language: Option<&str>) -> bool {
1212    // Only consider code blocks with no language or markup-like languages.
1213    // Code blocks tagged with programming languages (rust, python, etc.)
1214    // should never be reinterpreted.
1215    if let Some(lang) = language {
1216        let lang_lower = lang.to_ascii_lowercase();
1217        if !matches!(
1218            lang_lower.as_str(),
1219            "markdown" | "md" | "text" | "txt" | "plaintext" | "plain"
1220        ) {
1221            return false;
1222        }
1223    }
1224
1225    let trimmed = content.trim();
1226    if trimmed.is_empty() {
1227        return false;
1228    }
1229
1230    // Quick heuristic: a GFM table must have at least a header row and a
1231    // separator row. The separator row contains only `|`, `-`, `:`, and
1232    // whitespace.
1233    let mut has_pipe_line = false;
1234    let mut has_separator = false;
1235    for line in trimmed.lines().take(4) {
1236        let line = line.trim();
1237        if line.contains('|') {
1238            has_pipe_line = true;
1239        }
1240        if line.starts_with('|') && line.chars().all(|c| matches!(c, '|' | '-' | ':' | ' ')) {
1241            has_separator = true;
1242        }
1243    }
1244    if !has_pipe_line || !has_separator {
1245        return false;
1246    }
1247
1248    // Verify with pulldown-cmark: parse the content and check for a Table event.
1249    let options = Options::ENABLE_TABLES;
1250    let parser = Parser::new_ext(trimmed, options);
1251    for event in parser {
1252        match event {
1253            Event::Start(Tag::Table(_)) => return true,
1254            Event::Start(Tag::Paragraph) | Event::Text(_) | Event::SoftBreak => continue,
1255            _ => return false,
1256        }
1257    }
1258    false
1259}
1260
1261fn is_diff_language(language: Option<&str>) -> bool {
1262    language.is_some_and(|lang| {
1263        matches!(
1264            lang.to_ascii_lowercase().as_str(),
1265            "diff" | "patch" | "udiff" | "git"
1266        )
1267    })
1268}
1269
1270fn code_block_style(theme_styles: &ThemeStyles, base_style: Style) -> Style {
1271    let base_fg = base_style.get_fg_color();
1272    let theme_fg = theme_styles.output.get_fg_color();
1273    let fg = if base_style.get_effects().contains(Effects::DIMMED) {
1274        base_fg.or(theme_fg)
1275    } else {
1276        theme_fg.or(base_fg)
1277    };
1278    let mut style = base_style;
1279    if let Some(color) = fg {
1280        style = style.fg_color(Some(color));
1281    }
1282    style
1283}
1284
1285/// Normalize indentation in code blocks.
1286///
1287/// This function strips common leading indentation when ALL non-empty lines have at least
1288/// that much indentation. It preserves the relative indentation structure within the code block.
1289fn normalize_code_indentation(
1290    code: &str,
1291    language: Option<&str>,
1292    preserve_indentation: bool,
1293) -> String {
1294    if preserve_indentation {
1295        return code.to_string();
1296    }
1297    // Check if we should normalize based on language hint
1298    let has_language_hint = language.is_some_and(|hint| {
1299        matches!(
1300            hint.to_lowercase().as_str(),
1301            "rust"
1302                | "rs"
1303                | "python"
1304                | "py"
1305                | "javascript"
1306                | "js"
1307                | "jsx"
1308                | "typescript"
1309                | "ts"
1310                | "tsx"
1311                | "go"
1312                | "golang"
1313                | "java"
1314                | "cpp"
1315                | "c"
1316                | "php"
1317                | "html"
1318                | "css"
1319                | "sql"
1320                | "csharp"
1321                | "bash"
1322                | "sh"
1323                | "swift"
1324        )
1325    });
1326
1327    // Always normalize indentation regardless of language (applies to plain text code too)
1328    // This ensures consistently formatted output
1329    if !has_language_hint && language.is_some() {
1330        // If there's a language hint but it's not supported, don't normalize
1331        return code.to_string();
1332    }
1333
1334    let lines: Vec<&str> = code.lines().collect();
1335
1336    // Get minimum indentation across all non-empty lines
1337    // Find the longest common leading whitespace prefix across all non-empty lines
1338    let min_indent = lines
1339        .iter()
1340        .filter(|line| !line.trim().is_empty())
1341        .map(|line| &line[..line.len() - line.trim_start().len()])
1342        .reduce(|acc, p| {
1343            let mut len = 0;
1344            for (c1, c2) in acc.chars().zip(p.chars()) {
1345                if c1 != c2 {
1346                    break;
1347                }
1348                len += c1.len_utf8();
1349            }
1350            &acc[..len]
1351        })
1352        .map(|s| s.len())
1353        .unwrap_or(0);
1354
1355    // Remove the common leading indentation from all lines, preserving relative indentation
1356    let normalized = lines
1357        .iter()
1358        .map(|line| {
1359            if line.trim().is_empty() {
1360                line // preserve empty lines as-is
1361            } else if line.len() >= min_indent {
1362                &line[min_indent..] // remove common indentation
1363            } else {
1364                line // line is shorter than min_indent, return as-is
1365            }
1366        })
1367        .collect::<Vec<_>>()
1368        .join("\n");
1369
1370    // Preserve trailing newline if original had one
1371    if code.ends_with('\n') {
1372        format!("{normalized}\n")
1373    } else {
1374        normalized
1375    }
1376}
1377
1378/// Highlight a single line of code for diff preview.
1379/// Returns a vector of (style, text) segments, or None if highlighting fails.
1380pub fn highlight_line_for_diff(line: &str, language: Option<&str>) -> Option<Vec<(Style, String)>> {
1381    let syntax = select_syntax(language);
1382    let theme = get_theme("base16-ocean.dark", true);
1383    let mut highlighter = HighlightLines::new(syntax, &theme);
1384
1385    let ranges = highlighter.highlight_line(line, syntax_set()).ok()?;
1386    let mut segments = Vec::new();
1387    for (style, part) in ranges {
1388        if part.is_empty() {
1389            continue;
1390        }
1391        let mut anstyle = to_anstyle(style);
1392        anstyle = anstyle.bg_color(None);
1393        segments.push((anstyle, part.to_owned()));
1394    }
1395    Some(segments)
1396}
1397
1398fn try_highlight(
1399    code: &str,
1400    language: Option<&str>,
1401    config: &SyntaxHighlightingConfig,
1402) -> Option<Vec<Vec<(Style, String)>>> {
1403    let max_bytes = config.max_file_size_mb.saturating_mul(1024 * 1024);
1404    if max_bytes > 0 && code.len() > max_bytes {
1405        return None;
1406    }
1407
1408    if let Some(lang) = language {
1409        let enabled = config
1410            .enabled_languages
1411            .iter()
1412            .any(|entry| entry.eq_ignore_ascii_case(lang));
1413        if !enabled {
1414            return None;
1415        }
1416    }
1417
1418    let syntax = select_syntax(language);
1419    let theme = get_theme(&config.theme, config.cache_themes);
1420    let mut highlighter = HighlightLines::new(syntax, &theme);
1421    let mut rendered = Vec::new();
1422
1423    let mut ends_with_newline = false;
1424    for line in LinesWithEndings::from(code) {
1425        ends_with_newline = line.ends_with('\n');
1426        let trimmed = line.trim_end_matches('\n');
1427        let ranges = highlighter.highlight_line(trimmed, syntax_set()).ok()?;
1428        let mut segments = Vec::new();
1429        for (style, part) in ranges {
1430            if part.is_empty() {
1431                continue;
1432            }
1433            let mut anstyle = to_anstyle(style);
1434            // Strip background color to avoid filled backgrounds in terminal
1435            anstyle = anstyle.bg_color(None);
1436            segments.push((anstyle, part.to_owned()));
1437        }
1438        rendered.push(segments);
1439    }
1440
1441    if ends_with_newline {
1442        rendered.push(Vec::new());
1443    }
1444
1445    Some(rendered)
1446}
1447
1448fn select_syntax(language: Option<&str>) -> &'static SyntaxReference {
1449    language
1450        .map(find_syntax_by_token)
1451        .unwrap_or_else(|| syntax_set().find_syntax_plain_text())
1452}
1453
1454fn get_theme(theme_name: &str, cache: bool) -> Theme {
1455    load_theme(theme_name, cache)
1456}
1457
1458/// A highlighted line segment with style and text.
1459#[derive(Clone, Debug)]
1460pub struct HighlightedSegment {
1461    pub style: Style,
1462    pub text: String,
1463}
1464
1465/// Highlight a code string and return styled segments per line.
1466///
1467/// This function applies syntax highlighting to the provided code and returns
1468/// a vector of lines, where each line contains styled segments.
1469pub fn highlight_code_to_segments(
1470    code: &str,
1471    language: Option<&str>,
1472    theme_name: &str,
1473) -> Vec<Vec<HighlightedSegment>> {
1474    let syntax = select_syntax(language);
1475    let theme = get_theme(theme_name, true);
1476    let mut highlighter = HighlightLines::new(syntax, &theme);
1477    let mut result = Vec::new();
1478
1479    for line in LinesWithEndings::from(code) {
1480        let trimmed = line.trim_end_matches('\n');
1481        let segments = match highlighter.highlight_line(trimmed, syntax_set()) {
1482            Ok(ranges) => ranges
1483                .into_iter()
1484                .filter(|(_, text)| !text.is_empty())
1485                .map(|(style, text)| {
1486                    let mut anstyle = to_anstyle(style);
1487                    anstyle = anstyle.bg_color(None);
1488                    HighlightedSegment {
1489                        style: anstyle,
1490                        text: text.to_owned(),
1491                    }
1492                })
1493                .collect(),
1494            Err(_) => vec![HighlightedSegment {
1495                style: Style::new(),
1496                text: trimmed.to_owned(),
1497            }],
1498        };
1499        result.push(segments);
1500    }
1501
1502    result
1503}
1504
1505/// Highlight a code string and return ANSI-formatted strings per line.
1506///
1507/// This is a convenience function that renders highlighting directly to
1508/// ANSI escape sequences suitable for terminal output.
1509pub fn highlight_code_to_ansi(code: &str, language: Option<&str>, theme_name: &str) -> Vec<String> {
1510    let segments = highlight_code_to_segments(code, language, theme_name);
1511    segments
1512        .into_iter()
1513        .map(|line_segments| {
1514            let mut ansi_line = String::new();
1515            for seg in line_segments {
1516                let rendered = seg.style.render();
1517                ansi_line.push_str(&format!(
1518                    "{rendered}{text}{reset}",
1519                    text = seg.text,
1520                    reset = anstyle::Reset
1521                ));
1522            }
1523            ansi_line
1524        })
1525        .collect()
1526}
1527
1528#[cfg(test)]
1529mod tests {
1530    use super::*;
1531
1532    fn lines_to_text(lines: &[MarkdownLine]) -> Vec<String> {
1533        lines
1534            .iter()
1535            .map(|line| {
1536                line.segments
1537                    .iter()
1538                    .map(|seg| seg.text.as_str())
1539                    .collect::<String>()
1540            })
1541            .collect()
1542    }
1543
1544    #[test]
1545    fn test_markdown_heading_renders_prefixes() {
1546        let markdown = "# Heading\n\n## Subheading\n";
1547        let lines = render_markdown(markdown);
1548        let text_lines = lines_to_text(&lines);
1549        assert!(text_lines.iter().any(|line| line == "# Heading"));
1550        assert!(text_lines.iter().any(|line| line == "## Subheading"));
1551    }
1552
1553    #[test]
1554    fn test_markdown_blockquote_prefix() {
1555        let markdown = "> Quote line\n> Second line\n";
1556        let lines = render_markdown(markdown);
1557        let text_lines = lines_to_text(&lines);
1558        assert!(
1559            text_lines
1560                .iter()
1561                .any(|line| line.starts_with("│ ") && line.contains("Quote line"))
1562        );
1563        assert!(
1564            text_lines
1565                .iter()
1566                .any(|line| line.starts_with("│ ") && line.contains("Second line"))
1567        );
1568    }
1569
1570    #[test]
1571    fn test_markdown_inline_code_strips_backticks() {
1572        let markdown = "Use `code` here.";
1573        let lines = render_markdown(markdown);
1574        let text_lines = lines_to_text(&lines);
1575        assert!(
1576            text_lines
1577                .iter()
1578                .any(|line| line.contains("Use code here."))
1579        );
1580    }
1581
1582    #[test]
1583    fn test_markdown_soft_break_renders_line_break() {
1584        let markdown = "first line\nsecond line";
1585        let lines = render_markdown(markdown);
1586        let text_lines: Vec<String> = lines_to_text(&lines)
1587            .into_iter()
1588            .filter(|line| !line.is_empty())
1589            .collect();
1590        assert_eq!(
1591            text_lines,
1592            vec!["first line".to_string(), "second line".to_string()]
1593        );
1594    }
1595
1596    #[test]
1597    fn test_markdown_unordered_list_bullets() {
1598        let markdown = r#"
1599- Item 1
1600- Item 2
1601  - Nested 1
1602  - Nested 2
1603- Item 3
1604"#;
1605
1606        let lines = render_markdown(markdown);
1607        let output: String = lines
1608            .iter()
1609            .map(|line| {
1610                line.segments
1611                    .iter()
1612                    .map(|seg| seg.text.as_str())
1613                    .collect::<String>()
1614            })
1615            .collect::<Vec<_>>()
1616            .join("\n");
1617
1618        // Check for bullet characters (• for depth 0, ◦ for depth 1, etc.)
1619        assert!(
1620            output.contains("•") || output.contains("◦") || output.contains("▪"),
1621            "Should use Unicode bullet characters instead of dashes"
1622        );
1623    }
1624
1625    #[test]
1626    fn test_markdown_table_box_drawing() {
1627        let markdown = r#"
1628| Header 1 | Header 2 |
1629|----------|----------|
1630| Cell 1   | Cell 2   |
1631| Cell 3   | Cell 4   |
1632"#;
1633
1634        let lines = render_markdown(markdown);
1635        let output: String = lines
1636            .iter()
1637            .map(|line| {
1638                line.segments
1639                    .iter()
1640                    .map(|seg| seg.text.as_str())
1641                    .collect::<String>()
1642            })
1643            .collect::<Vec<_>>()
1644            .join("\n");
1645
1646        // Check for box-drawing character (│ instead of |)
1647        assert!(
1648            output.contains("│"),
1649            "Should use box-drawing character (│) for table cells instead of pipe"
1650        );
1651    }
1652
1653    #[test]
1654    fn test_table_inside_markdown_code_block_renders_as_table() {
1655        let markdown = "```markdown\n\
1656            | Module | Purpose |\n\
1657            |--------|----------|\n\
1658            | core   | Library  |\n\
1659            ```\n";
1660
1661        let lines = render_markdown(markdown);
1662        let output: String = lines
1663            .iter()
1664            .map(|line| {
1665                line.segments
1666                    .iter()
1667                    .map(|seg| seg.text.as_str())
1668                    .collect::<String>()
1669            })
1670            .collect::<Vec<_>>()
1671            .join("\n");
1672
1673        assert!(
1674            output.contains("│"),
1675            "Table inside ```markdown code block should render with box-drawing characters, got: {output}"
1676        );
1677        // Should NOT contain code-block line numbers
1678        assert!(
1679            !output.contains("  1  "),
1680            "Table inside markdown code block should not have line numbers"
1681        );
1682    }
1683
1684    #[test]
1685    fn test_table_inside_md_code_block_renders_as_table() {
1686        let markdown = "```md\n\
1687            | A | B |\n\
1688            |---|---|\n\
1689            | 1 | 2 |\n\
1690            ```\n";
1691
1692        let lines = render_markdown(markdown);
1693        let output = lines_to_text(&lines).join("\n");
1694
1695        assert!(
1696            output.contains("│"),
1697            "Table inside ```md code block should render as table: {output}"
1698        );
1699    }
1700
1701    #[test]
1702    fn test_table_code_block_reparse_guard_can_disable_table_reparse() {
1703        let markdown = "```markdown\n\
1704            | Module | Purpose |\n\
1705            |--------|----------|\n\
1706            | core   | Library  |\n\
1707            ```\n";
1708        let options = RenderMarkdownOptions {
1709            preserve_code_indentation: false,
1710            disable_code_block_table_reparse: true,
1711        };
1712        let lines = render_markdown_to_lines_with_options(
1713            markdown,
1714            Style::default(),
1715            &theme::active_styles(),
1716            None,
1717            options,
1718        );
1719        let output = lines_to_text(&lines).join("\n");
1720
1721        assert!(
1722            output.contains("| Module | Purpose |"),
1723            "Guarded render should keep code-block content literal: {output}"
1724        );
1725        assert!(
1726            output.contains("  1  "),
1727            "Guarded render should keep code-block line numbers: {output}"
1728        );
1729    }
1730
1731    #[test]
1732    fn test_rust_code_block_with_pipes_not_treated_as_table() {
1733        let markdown = "```rust\n\
1734            | Header | Col |\n\
1735            |--------|-----|\n\
1736            | a      | b   |\n\
1737            ```\n";
1738
1739        let lines = render_markdown(markdown);
1740        let output = lines_to_text(&lines).join("\n");
1741
1742        // Rust code blocks should NOT be reinterpreted as tables
1743        assert!(
1744            output.contains("| Header |"),
1745            "Rust code block should keep raw pipe characters: {output}"
1746        );
1747    }
1748
1749    #[test]
1750    fn test_markdown_code_block_with_language_renders_line_numbers() {
1751        let markdown = "```rust\nfn main() {}\n```\n";
1752        let lines = render_markdown(markdown);
1753        let text_lines = lines_to_text(&lines);
1754        let code_line = text_lines
1755            .iter()
1756            .find(|line| line.contains("fn main() {}"))
1757            .expect("code line exists");
1758        assert!(code_line.contains("  1  "));
1759    }
1760
1761    #[test]
1762    fn test_markdown_code_block_without_language_skips_line_numbers() {
1763        let markdown = "```\nfn main() {}\n```\n";
1764        let lines = render_markdown(markdown);
1765        let text_lines = lines_to_text(&lines);
1766        let code_line = text_lines
1767            .iter()
1768            .find(|line| line.contains("fn main() {}"))
1769            .expect("code line exists");
1770        assert!(!code_line.contains("  1  "));
1771    }
1772
1773    #[test]
1774    fn test_markdown_diff_code_block_strips_backgrounds() {
1775        let markdown = "```diff\n@@ -1 +1 @@\n- old\n+ new\n context\n```\n";
1776        let lines =
1777            render_markdown_to_lines(markdown, Style::default(), &theme::active_styles(), None);
1778
1779        let added_line = lines
1780            .iter()
1781            .find(|line| line.segments.iter().any(|seg| seg.text.contains("+ new")))
1782            .expect("added line exists");
1783        assert!(
1784            added_line
1785                .segments
1786                .iter()
1787                .all(|seg| seg.style.get_bg_color().is_none())
1788        );
1789
1790        let removed_line = lines
1791            .iter()
1792            .find(|line| line.segments.iter().any(|seg| seg.text.contains("- old")))
1793            .expect("removed line exists");
1794        assert!(
1795            removed_line
1796                .segments
1797                .iter()
1798                .all(|seg| seg.style.get_bg_color().is_none())
1799        );
1800
1801        let context_line = lines
1802            .iter()
1803            .find(|line| {
1804                line.segments
1805                    .iter()
1806                    .any(|seg| seg.text.contains(" context"))
1807            })
1808            .expect("context line exists");
1809        assert!(
1810            context_line
1811                .segments
1812                .iter()
1813                .all(|seg| seg.style.get_bg_color().is_none())
1814        );
1815    }
1816
1817    #[test]
1818    fn test_markdown_unlabeled_diff_code_block_detects_diff() {
1819        let markdown = "```\n@@ -1 +1 @@\n- old\n+ new\n```\n";
1820        let lines =
1821            render_markdown_to_lines(markdown, Style::default(), &theme::active_styles(), None);
1822        let added_line = lines
1823            .iter()
1824            .find(|line| line.segments.iter().any(|seg| seg.text.contains("+ new")))
1825            .expect("added line exists");
1826        assert!(
1827            added_line
1828                .segments
1829                .iter()
1830                .all(|seg| seg.style.get_bg_color().is_none())
1831        );
1832    }
1833
1834    #[test]
1835    fn test_markdown_task_list_markers() {
1836        let markdown = "- [x] Done\n- [ ] Todo\n";
1837        let lines = render_markdown(markdown);
1838        let text_lines = lines_to_text(&lines);
1839        assert!(text_lines.iter().any(|line| line.contains("[x]")));
1840        assert!(text_lines.iter().any(|line| line.contains("[ ]")));
1841    }
1842
1843    #[test]
1844    fn test_code_indentation_normalization_removes_common_indent() {
1845        let code_with_indent = "    fn hello() {\n        println!(\"world\");\n    }";
1846        let expected = "fn hello() {\n    println!(\"world\");\n}";
1847        let result = normalize_code_indentation(code_with_indent, Some("rust"), false);
1848        assert_eq!(result, expected);
1849    }
1850
1851    #[test]
1852    fn test_code_indentation_preserves_already_normalized() {
1853        let code = "fn hello() {\n    println!(\"world\");\n}";
1854        let result = normalize_code_indentation(code, Some("rust"), false);
1855        assert_eq!(result, code);
1856    }
1857
1858    #[test]
1859    fn test_code_indentation_without_language_hint() {
1860        // Without language hint, normalization still happens - common indent is stripped
1861        let code = "    some code";
1862        let result = normalize_code_indentation(code, None, false);
1863        assert_eq!(result, "some code");
1864    }
1865
1866    #[test]
1867    fn test_code_indentation_preserves_relative_indentation() {
1868        let code = "    line1\n        line2\n    line3";
1869        let expected = "line1\n    line2\nline3";
1870        let result = normalize_code_indentation(code, Some("python"), false);
1871        assert_eq!(result, expected);
1872    }
1873
1874    #[test]
1875    fn test_code_indentation_mixed_whitespace_preserves_indent() {
1876        // Mixed tabs and spaces - common prefix should be empty if they differ
1877        let code = "    line1\n\tline2";
1878        let result = normalize_code_indentation(code, None, false);
1879        // Should preserve original content rather than stripping incorrectly
1880        assert_eq!(result, code);
1881    }
1882
1883    #[test]
1884    fn test_code_indentation_common_prefix_mixed() {
1885        // Common prefix is present ("    ")
1886        let code = "    line1\n    \tline2";
1887        let expected = "line1\n\tline2";
1888        let result = normalize_code_indentation(code, None, false);
1889        assert_eq!(result, expected);
1890    }
1891
1892    #[test]
1893    fn test_code_indentation_preserve_when_requested() {
1894        let code = "    line1\n        line2\n    line3\n";
1895        let result = normalize_code_indentation(code, Some("rust"), true);
1896        assert_eq!(result, code);
1897    }
1898
1899    #[test]
1900    fn test_diff_summary_counts_function_signature_change() {
1901        // Test case matching the user's TODO scenario - function signature change
1902        let diff = "diff --git a/ask.rs b/ask.rs\n\
1903index 0000000..1111111 100644\n\
1904--- a/ask.rs\n\
1905+++ b/ask.rs\n\
1906@@ -172,7 +172,7 @@\n\
1907          blocks\n\
1908      }\n\
1909 \n\
1910-    fn select_best_code_block<'a>(blocks: &'a [CodeFenceBlock]) -> Option<&'a CodeFenceBlock> {\n\
1911+    fn select_best_code_block(blocks: &[CodeFenceBlock]) -> Option<&CodeFenceBlock> {\n\
1912          let mut best = None;\n\
1913          let mut best_score = (0usize, 0u8);\n\
1914          for block in blocks {";
1915
1916        let lines = normalize_diff_lines(diff);
1917
1918        // Find the summary line
1919        let summary_line = lines
1920            .iter()
1921            .find(|l| l.starts_with("• Diff "))
1922            .expect("should have summary line");
1923
1924        // Should show (+1 -1) not (+0 -0)
1925        assert_eq!(summary_line, "• Diff ask.rs (+1 -1)");
1926    }
1927}