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