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