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