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 mut col_widths: Vec<usize> = Vec::new();
616
617    // Check headers
618    for (i, cell) in table.headers.iter().enumerate() {
619        if i >= col_widths.len() {
620            col_widths.push(0);
621        }
622        col_widths[i] = max(col_widths[i], cell.width());
623    }
624
625    // Check rows
626    for row in &table.rows {
627        for (i, cell) in row.iter().enumerate() {
628            if i >= col_widths.len() {
629                col_widths.push(0);
630            }
631            col_widths[i] = max(col_widths[i], cell.width());
632        }
633    }
634
635    let border_style = base_style.dimmed();
636
637    let render_row = |cells: &[MarkdownLine], col_widths: &[usize], bold: bool| -> MarkdownLine {
638        let mut line = MarkdownLine::default();
639        line.push_segment(border_style, "│ ");
640        for (i, width) in col_widths.iter().enumerate() {
641            if let Some(c) = cells.get(i) {
642                for seg in &c.segments {
643                    let s = if bold { seg.style.bold() } else { seg.style };
644                    line.push_segment(s, &seg.text);
645                }
646                let padding = width.saturating_sub(c.width());
647                if padding > 0 {
648                    line.push_segment(base_style, &" ".repeat(padding));
649                }
650            } else {
651                line.push_segment(base_style, &" ".repeat(*width));
652            }
653            line.push_segment(border_style, " │ ");
654        }
655        line
656    };
657
658    // Render Headers
659    if !table.headers.is_empty() {
660        lines.push(render_row(&table.headers, &col_widths, true));
661
662        // Separator line
663        let mut sep = MarkdownLine::default();
664        sep.push_segment(border_style, "├─");
665        for (i, width) in col_widths.iter().enumerate() {
666            sep.push_segment(border_style, &"─".repeat(*width));
667            sep.push_segment(
668                border_style,
669                if i < col_widths.len() - 1 {
670                    "─┼─"
671                } else {
672                    "─┤"
673                },
674            );
675        }
676        lines.push(sep);
677    }
678
679    // Render Rows
680    for row in &table.rows {
681        lines.push(render_row(row, &col_widths, false));
682    }
683
684    lines
685}
686
687fn append_text(text: &str, ctx: &mut MarkdownContext<'_>) {
688    let style = ctx.current_style();
689    let mut start = 0usize;
690    let mut chars = text.char_indices().peekable();
691
692    while let Some((idx, ch)) = chars.next() {
693        if ch == '\n' {
694            let segment = &text[start..idx];
695            if !segment.is_empty() {
696                ctx.ensure_prefix();
697                ctx.current_line.push_segment(style, segment);
698            }
699            ctx.lines.push(std::mem::take(ctx.current_line));
700            start = idx + 1;
701            // Skip consecutive newlines (one blank line per sequence)
702            while chars.peek().is_some_and(|&(_, c)| c == '\n') {
703                let (_, c) = chars.next().expect("peeked");
704                start += c.len_utf8();
705            }
706        }
707    }
708
709    if start < text.len() {
710        let remaining = &text[start..];
711        if !remaining.is_empty() {
712            ctx.ensure_prefix();
713            ctx.current_line.push_segment(style, remaining);
714        }
715    }
716}
717
718fn ensure_prefix(
719    current_line: &mut MarkdownLine,
720    blockquote_depth: usize,
721    list_stack: &[ListState],
722    pending_list_prefix: &mut Option<String>,
723    _theme_styles: &ThemeStyles,
724    base_style: Style,
725) {
726    if !current_line.segments.is_empty() {
727        return;
728    }
729
730    for _ in 0..blockquote_depth {
731        current_line.push_segment(base_style.dimmed().italic(), "│ ");
732    }
733
734    if let Some(prefix) = pending_list_prefix.take() {
735        current_line.push_segment(base_style, &prefix);
736    } else if !list_stack.is_empty() {
737        let mut continuation = String::new();
738        for state in list_stack {
739            continuation.push_str(&state.continuation);
740        }
741        if !continuation.is_empty() {
742            current_line.push_segment(base_style, &continuation);
743        }
744    }
745}
746
747fn flush_current_line(
748    lines: &mut Vec<MarkdownLine>,
749    current_line: &mut MarkdownLine,
750    blockquote_depth: usize,
751    list_stack: &[ListState],
752    pending_list_prefix: &mut Option<String>,
753    theme_styles: &ThemeStyles,
754    base_style: Style,
755) {
756    if current_line.segments.is_empty() && pending_list_prefix.is_some() {
757        ensure_prefix(
758            current_line,
759            blockquote_depth,
760            list_stack,
761            pending_list_prefix,
762            theme_styles,
763            base_style,
764        );
765    }
766
767    if !current_line.segments.is_empty() {
768        lines.push(std::mem::take(current_line));
769    }
770}
771
772fn push_blank_line(lines: &mut Vec<MarkdownLine>) {
773    if lines
774        .last()
775        .map(|line| line.segments.is_empty())
776        .unwrap_or(false)
777    {
778        return;
779    }
780    lines.push(MarkdownLine::default());
781}
782
783fn trim_trailing_blank_lines(lines: &mut Vec<MarkdownLine>) {
784    while lines
785        .last()
786        .map(|line| line.segments.is_empty())
787        .unwrap_or(false)
788    {
789        lines.pop();
790    }
791}
792
793fn inline_code_style(theme_styles: &ThemeStyles, base_style: Style) -> Style {
794    let mut style = base_style.bold();
795    if should_apply_markdown_accent(base_style, theme_styles)
796        && let Some(color) = choose_markdown_accent(
797            base_style,
798            &[
799                theme_styles.secondary,
800                theme_styles.primary,
801                theme_styles.tool_detail,
802                theme_styles.status,
803            ],
804        )
805    {
806        style = style.fg_color(Some(color));
807    }
808    style
809}
810
811fn heading_style(_level: HeadingLevel, theme_styles: &ThemeStyles, base_style: Style) -> Style {
812    let mut style = base_style.bold();
813    if should_apply_markdown_accent(base_style, theme_styles)
814        && let Some(color) = choose_markdown_accent(
815            base_style,
816            &[
817                theme_styles.primary,
818                theme_styles.secondary,
819                theme_styles.status,
820                theme_styles.tool,
821            ],
822        )
823    {
824        style = style.fg_color(Some(color));
825    }
826    style
827}
828
829fn strong_style(current: Style, theme_styles: &ThemeStyles, base_style: Style) -> Style {
830    let mut style = current.bold();
831    if should_apply_markdown_accent(base_style, theme_styles)
832        && let Some(color) = choose_markdown_accent(
833            base_style,
834            &[
835                theme_styles.primary,
836                theme_styles.secondary,
837                theme_styles.status,
838                theme_styles.tool,
839            ],
840        )
841    {
842        style = style.fg_color(Some(color));
843    }
844    style
845}
846
847fn should_apply_markdown_accent(base_style: Style, theme_styles: &ThemeStyles) -> bool {
848    base_style == theme_styles.response
849}
850
851fn choose_markdown_accent(base_style: Style, candidates: &[Style]) -> Option<anstyle::Color> {
852    let base_fg = base_style.get_fg_color();
853    candidates.iter().find_map(|candidate| {
854        candidate
855            .get_fg_color()
856            .filter(|color| base_fg != Some(*color))
857    })
858}
859
860fn should_render_link_destination(dest_url: &str) -> bool {
861    !is_local_path_like_link(dest_url)
862}
863
864fn is_local_path_like_link(dest_url: &str) -> bool {
865    dest_url.starts_with("file://")
866        || dest_url.starts_with('/')
867        || dest_url.starts_with("~/")
868        || dest_url.starts_with("./")
869        || dest_url.starts_with("../")
870        || dest_url.starts_with("\\\\")
871        || matches!(
872            dest_url.as_bytes(),
873            [drive, b':', separator, ..]
874                if drive.is_ascii_alphabetic() && matches!(separator, b'/' | b'\\')
875        )
876}
877
878static COLON_LOCATION_SUFFIX_RE: LazyLock<Regex> =
879    LazyLock::new(
880        || match Regex::new(r":\d+(?::\d+)?(?:[-–]\d+(?::\d+)?)?$") {
881            Ok(regex) => regex,
882            Err(error) => panic!("invalid location suffix regex: {error}"),
883        },
884    );
885
886static HASH_LOCATION_SUFFIX_RE: LazyLock<Regex> =
887    LazyLock::new(|| match Regex::new(r"^L\d+(?:C\d+)?(?:-L\d+(?:C\d+)?)?$") {
888        Ok(regex) => regex,
889        Err(error) => panic!("invalid hash location regex: {error}"),
890    });
891
892/// Check if text already has a location suffix (colon or hash format)
893fn label_has_location_suffix(text: &str) -> bool {
894    text.rsplit_once('#')
895        .is_some_and(|(_, fragment)| HASH_LOCATION_SUFFIX_RE.is_match(fragment))
896        || COLON_LOCATION_SUFFIX_RE.find(text).is_some()
897}
898
899/// Extract and normalize location suffix from a local file link destination
900fn extract_hidden_location_suffix(dest_url: &str) -> Option<String> {
901    if !is_local_path_like_link(dest_url) {
902        return None;
903    }
904
905    // Check for hash-based location (#L74C3 or #L74C3-L76C9)
906    if let Some((_, fragment)) = dest_url.rsplit_once('#')
907        && HASH_LOCATION_SUFFIX_RE.is_match(fragment)
908    {
909        return normalize_hash_location(fragment);
910    }
911
912    // Check for colon-based location (:74 or :74:3 or :74:3-76:9)
913    COLON_LOCATION_SUFFIX_RE
914        .find(dest_url)
915        .map(|m| m.as_str().to_string())
916}
917
918/// Convert hash location format (L74C3 or L74C3-L76C9) to colon format (:74:3 or :74:3-76:9)
919fn normalize_hash_location(fragment: &str) -> Option<String> {
920    let (start, end) = match fragment.split_once('-') {
921        Some((start, end)) => (start, Some(end)),
922        None => (fragment, None),
923    };
924
925    let (start_line, start_col) = parse_hash_point(start)?;
926    let mut result = format!(":{start_line}");
927    if let Some(col) = start_col {
928        result.push(':');
929        result.push_str(col);
930    }
931
932    if let Some(end) = end {
933        let (end_line, end_col) = parse_hash_point(end)?;
934        result.push('-');
935        result.push_str(end_line);
936        if let Some(col) = end_col {
937            result.push(':');
938            result.push_str(col);
939        }
940    }
941
942    Some(result)
943}
944
945fn parse_hash_point(point: &str) -> Option<(&str, Option<&str>)> {
946    let point = point.strip_prefix('L')?;
947    Some(match point.split_once('C') {
948        Some((line, col)) => (line, Some(col)),
949        None => (point, None),
950    })
951}
952
953fn build_prefix_segments(
954    blockquote_depth: usize,
955    list_stack: &[ListState],
956    _theme_styles: &ThemeStyles,
957    base_style: Style,
958) -> Vec<MarkdownSegment> {
959    let mut segments = Vec::new();
960    for _ in 0..blockquote_depth {
961        segments.push(MarkdownSegment::new(base_style.dimmed().italic(), "│ "));
962    }
963    if !list_stack.is_empty() {
964        let mut continuation = String::new();
965        for state in list_stack {
966            continuation.push_str(&state.continuation);
967        }
968        if !continuation.is_empty() {
969            segments.push(MarkdownSegment::new(base_style, continuation));
970        }
971    }
972    segments
973}
974
975fn highlight_code_block(
976    code: &str,
977    language: Option<&str>,
978    highlight_config: Option<&SyntaxHighlightingConfig>,
979    theme_styles: &ThemeStyles,
980    base_style: Style,
981    prefix_segments: &[MarkdownSegment],
982    preserve_code_indentation: bool,
983) -> Vec<MarkdownLine> {
984    let mut lines = Vec::new();
985
986    // Normalize indentation unless we're preserving raw tool output.
987    let normalized_code = normalize_code_indentation(code, language, preserve_code_indentation);
988    let code_to_display = &normalized_code;
989    if is_diff_language(language)
990        || (language.is_none() && looks_like_diff_content(code_to_display))
991    {
992        return render_diff_code_block(code_to_display, theme_styles, base_style, prefix_segments);
993    }
994    let use_line_numbers =
995        language.is_some_and(|lang| !lang.trim().is_empty()) && !is_diff_language(language);
996
997    if let Some(config) = highlight_config.filter(|cfg| cfg.enabled)
998        && let Some(highlighted) = try_highlight(code_to_display, language, config)
999    {
1000        let line_count = highlighted.len();
1001        let number_width = line_number_width(line_count);
1002        for (index, segments) in highlighted.into_iter().enumerate() {
1003            let mut line = MarkdownLine::default();
1004            let line_prefix = if use_line_numbers {
1005                line_prefix_segments(
1006                    prefix_segments,
1007                    theme_styles,
1008                    base_style,
1009                    index + 1,
1010                    number_width,
1011                )
1012            } else {
1013                prefix_segments.to_vec()
1014            };
1015            line.prepend_segments(&line_prefix);
1016            for (style, text) in segments {
1017                line.push_segment(style, &text);
1018            }
1019            lines.push(line);
1020        }
1021        return lines;
1022    }
1023
1024    // Fallback: render without syntax highlighting, but still with normalized indentation
1025    let mut line_number = 1usize;
1026    let mut line_count = LinesWithEndings::from(code_to_display).count();
1027    if code_to_display.ends_with('\n') {
1028        line_count = line_count.saturating_add(1);
1029    }
1030    let number_width = line_number_width(line_count);
1031
1032    for raw_line in LinesWithEndings::from(code_to_display) {
1033        let trimmed = raw_line.trim_end_matches('\n');
1034        let mut line = MarkdownLine::default();
1035        let line_prefix = if use_line_numbers {
1036            line_prefix_segments(
1037                prefix_segments,
1038                theme_styles,
1039                base_style,
1040                line_number,
1041                number_width,
1042            )
1043        } else {
1044            prefix_segments.to_vec()
1045        };
1046        line.prepend_segments(&line_prefix);
1047        if !trimmed.is_empty() {
1048            line.push_segment(code_block_style(theme_styles, base_style), trimmed);
1049        }
1050        lines.push(line);
1051        line_number = line_number.saturating_add(1);
1052    }
1053
1054    if code_to_display.ends_with('\n') {
1055        let mut line = MarkdownLine::default();
1056        let line_prefix = if use_line_numbers {
1057            line_prefix_segments(
1058                prefix_segments,
1059                theme_styles,
1060                base_style,
1061                line_number,
1062                number_width,
1063            )
1064        } else {
1065            prefix_segments.to_vec()
1066        };
1067        line.prepend_segments(&line_prefix);
1068        lines.push(line);
1069    }
1070
1071    lines
1072}
1073
1074fn normalize_diff_lines(code: &str) -> Vec<String> {
1075    #[derive(Default)]
1076    struct DiffBlock {
1077        header: String,
1078        path: String,
1079        lines: Vec<String>,
1080        additions: usize,
1081        deletions: usize,
1082    }
1083
1084    let mut preface = Vec::new();
1085    let mut blocks = Vec::new();
1086    let mut current: Option<DiffBlock> = None;
1087
1088    for line in code.lines() {
1089        if let Some(path) = parse_diff_git_path(line) {
1090            if let Some(block) = current.take() {
1091                blocks.push(block);
1092            }
1093            current = Some(DiffBlock {
1094                header: line.to_string(),
1095                path,
1096                lines: Vec::new(),
1097                additions: 0,
1098                deletions: 0,
1099            });
1100            continue;
1101        }
1102
1103        let rewritten = format_start_only_hunk_header(line).unwrap_or_else(|| line.to_string());
1104        if let Some(block) = current.as_mut() {
1105            if is_diff_addition_line(line.trim_start()) {
1106                block.additions += 1;
1107            } else if is_diff_deletion_line(line.trim_start()) {
1108                block.deletions += 1;
1109            }
1110            block.lines.push(rewritten);
1111        } else {
1112            preface.push(rewritten);
1113        }
1114    }
1115
1116    if let Some(block) = current {
1117        blocks.push(block);
1118    }
1119
1120    if blocks.is_empty() {
1121        let mut additions = 0usize;
1122        let mut deletions = 0usize;
1123        let mut fallback_path: Option<String> = None;
1124        let mut summary_insert_index: Option<usize> = None;
1125        let mut lines: Vec<String> = Vec::new();
1126
1127        for line in code.lines() {
1128            if fallback_path.is_none() {
1129                fallback_path = parse_diff_marker_path(line);
1130            }
1131            if summary_insert_index.is_none() && is_diff_new_file_marker_line(line.trim_start()) {
1132                summary_insert_index = Some(lines.len());
1133            }
1134            if is_diff_addition_line(line.trim_start()) {
1135                additions += 1;
1136            } else if is_diff_deletion_line(line.trim_start()) {
1137                deletions += 1;
1138            }
1139            let rewritten = format_start_only_hunk_header(line).unwrap_or_else(|| line.to_string());
1140            lines.push(rewritten);
1141        }
1142
1143        let path = fallback_path.unwrap_or_else(|| "file".to_string());
1144        let summary = format!("• Diff {} (+{} -{})", path, additions, deletions);
1145
1146        let mut output = Vec::with_capacity(lines.len() + 1);
1147        if let Some(idx) = summary_insert_index {
1148            output.extend(lines[..=idx].iter().cloned());
1149            output.push(summary);
1150            output.extend(lines[idx + 1..].iter().cloned());
1151        } else {
1152            output.push(summary);
1153            output.extend(lines);
1154        }
1155        return output;
1156    }
1157
1158    let mut output = Vec::new();
1159    output.extend(preface);
1160    for block in blocks {
1161        output.push(block.header);
1162        output.push(format!(
1163            "• Diff {} (+{} -{})",
1164            block.path, block.additions, block.deletions
1165        ));
1166        output.extend(block.lines);
1167    }
1168    output
1169}
1170
1171fn render_diff_code_block(
1172    code: &str,
1173    theme_styles: &ThemeStyles,
1174    base_style: Style,
1175    prefix_segments: &[MarkdownSegment],
1176) -> Vec<MarkdownLine> {
1177    let mut lines = Vec::new();
1178    let palette = DiffColorPalette::default();
1179    let context_style = code_block_style(theme_styles, base_style);
1180    let header_style = palette.header_style();
1181    let added_style = palette.added_style();
1182    let removed_style = palette.removed_style();
1183
1184    for line in normalize_diff_lines(code) {
1185        let trimmed = line.trim_end_matches('\n');
1186        let trimmed_start = trimmed.trim_start();
1187        if let Some((path, additions, deletions)) = parse_diff_summary_line(trimmed_start) {
1188            let leading_len = trimmed.len().saturating_sub(trimmed_start.len());
1189            let leading = &trimmed[..leading_len];
1190            let mut line = MarkdownLine::default();
1191            line.prepend_segments(prefix_segments);
1192            if !leading.is_empty() {
1193                line.push_segment(context_style, leading);
1194            }
1195            line.push_segment(context_style, &format!("• Diff {path} ("));
1196            line.push_segment(added_style, &format!("+{additions}"));
1197            line.push_segment(context_style, " ");
1198            line.push_segment(removed_style, &format!("-{deletions}"));
1199            line.push_segment(context_style, ")");
1200            lines.push(line);
1201            continue;
1202        }
1203        let style = if trimmed.is_empty() {
1204            context_style
1205        } else if is_diff_header_line(trimmed_start) {
1206            header_style
1207        } else if is_diff_addition_line(trimmed_start) {
1208            added_style
1209        } else if is_diff_deletion_line(trimmed_start) {
1210            removed_style
1211        } else {
1212            context_style
1213        };
1214
1215        let mut line = MarkdownLine::default();
1216        line.prepend_segments(prefix_segments);
1217        if !trimmed.is_empty() {
1218            line.push_segment(style, trimmed);
1219        }
1220        lines.push(line);
1221    }
1222
1223    if code.ends_with('\n') {
1224        let mut line = MarkdownLine::default();
1225        line.prepend_segments(prefix_segments);
1226        lines.push(line);
1227    }
1228
1229    lines
1230}
1231
1232fn parse_diff_summary_line(line: &str) -> Option<(&str, usize, usize)> {
1233    let summary = line.strip_prefix("• Diff ")?;
1234    let (path, counts) = summary.rsplit_once(" (")?;
1235    let counts = counts.strip_suffix(')')?;
1236    let mut parts = counts.split_whitespace();
1237    let additions = parts.next()?.strip_prefix('+')?.parse().ok()?;
1238    let deletions = parts.next()?.strip_prefix('-')?.parse().ok()?;
1239    Some((path, additions, deletions))
1240}
1241
1242fn line_prefix_segments(
1243    prefix_segments: &[MarkdownSegment],
1244    theme_styles: &ThemeStyles,
1245    base_style: Style,
1246    line_number: usize,
1247    width: usize,
1248) -> Vec<MarkdownSegment> {
1249    let mut segments = prefix_segments.to_vec();
1250    let number_text = format!("{:>width$}  ", line_number, width = width);
1251    let number_style = if base_style == theme_styles.tool_output {
1252        theme_styles.tool_detail.dimmed()
1253    } else {
1254        base_style.dimmed()
1255    };
1256    segments.push(MarkdownSegment::new(number_style, number_text));
1257    segments
1258}
1259
1260fn line_number_width(line_count: usize) -> usize {
1261    let digits = line_count.max(1).to_string().len();
1262    digits.max(CODE_LINE_NUMBER_MIN_WIDTH)
1263}
1264
1265/// Check if a code block's content is primarily a GFM table.
1266///
1267/// LLMs frequently wrap markdown tables in fenced code blocks (e.g.
1268/// ````markdown` or ` ```text`), which causes them to render as plain code
1269/// with line numbers instead of formatted tables. This function detects that
1270/// pattern so the caller can render the content as markdown instead.
1271fn code_block_contains_table(content: &str, language: Option<&str>) -> bool {
1272    // Only consider code blocks with no language or markup-like languages.
1273    // Code blocks tagged with programming languages (rust, python, etc.)
1274    // should never be reinterpreted.
1275    if let Some(lang) = language {
1276        let lang_lower = lang.to_ascii_lowercase();
1277        if !matches!(
1278            lang_lower.as_str(),
1279            "markdown" | "md" | "text" | "txt" | "plaintext" | "plain"
1280        ) {
1281            return false;
1282        }
1283    }
1284
1285    let trimmed = content.trim();
1286    if trimmed.is_empty() {
1287        return false;
1288    }
1289
1290    // Quick heuristic: a GFM table must have at least a header row and a
1291    // separator row. The separator row contains only `|`, `-`, `:`, and
1292    // whitespace.
1293    let mut has_pipe_line = false;
1294    let mut has_separator = false;
1295    for line in trimmed.lines().take(4) {
1296        let line = line.trim();
1297        if line.contains('|') {
1298            has_pipe_line = true;
1299        }
1300        if line.starts_with('|') && line.chars().all(|c| matches!(c, '|' | '-' | ':' | ' ')) {
1301            has_separator = true;
1302        }
1303    }
1304    if !has_pipe_line || !has_separator {
1305        return false;
1306    }
1307
1308    // Verify with pulldown-cmark: parse the content and check for a Table event.
1309    let options = Options::ENABLE_TABLES;
1310    let parser = Parser::new_ext(trimmed, options);
1311    for event in parser {
1312        match event {
1313            Event::Start(Tag::Table(_)) => return true,
1314            Event::Start(Tag::Paragraph) | Event::Text(_) | Event::SoftBreak => continue,
1315            _ => return false,
1316        }
1317    }
1318    false
1319}
1320
1321fn is_diff_language(language: Option<&str>) -> bool {
1322    language.is_some_and(|lang| {
1323        matches!(
1324            lang.to_ascii_lowercase().as_str(),
1325            "diff" | "patch" | "udiff" | "git"
1326        )
1327    })
1328}
1329
1330fn code_block_style(theme_styles: &ThemeStyles, base_style: Style) -> Style {
1331    let base_fg = base_style.get_fg_color();
1332    let theme_fg = theme_styles.output.get_fg_color();
1333    let fg = if base_style.get_effects().contains(Effects::DIMMED) {
1334        base_fg.or(theme_fg)
1335    } else {
1336        theme_fg.or(base_fg)
1337    };
1338    let mut style = base_style;
1339    if let Some(color) = fg {
1340        style = style.fg_color(Some(color));
1341    }
1342    style
1343}
1344
1345/// Normalize indentation in code blocks.
1346///
1347/// This function strips common leading indentation when ALL non-empty lines have at least
1348/// that much indentation. It preserves the relative indentation structure within the code block.
1349fn normalize_code_indentation(
1350    code: &str,
1351    language: Option<&str>,
1352    preserve_indentation: bool,
1353) -> String {
1354    if preserve_indentation {
1355        return code.to_string();
1356    }
1357    // Check if we should normalize based on language hint
1358    let has_language_hint = language.is_some_and(|hint| {
1359        matches!(
1360            hint.to_lowercase().as_str(),
1361            "rust"
1362                | "rs"
1363                | "python"
1364                | "py"
1365                | "javascript"
1366                | "js"
1367                | "jsx"
1368                | "typescript"
1369                | "ts"
1370                | "tsx"
1371                | "go"
1372                | "golang"
1373                | "java"
1374                | "cpp"
1375                | "c"
1376                | "php"
1377                | "html"
1378                | "css"
1379                | "sql"
1380                | "csharp"
1381                | "bash"
1382                | "sh"
1383                | "swift"
1384        )
1385    });
1386
1387    // Always normalize indentation regardless of language (applies to plain text code too)
1388    // This ensures consistently formatted output
1389    if !has_language_hint && language.is_some() {
1390        // If there's a language hint but it's not supported, don't normalize
1391        return code.to_string();
1392    }
1393
1394    let lines: Vec<&str> = code.lines().collect();
1395
1396    // Get minimum indentation across all non-empty lines
1397    // Find the longest common leading whitespace prefix across all non-empty lines
1398    let min_indent = lines
1399        .iter()
1400        .filter(|line| !line.trim().is_empty())
1401        .map(|line| &line[..line.len() - line.trim_start().len()])
1402        .reduce(|acc, p| {
1403            let mut len = 0;
1404            for (c1, c2) in acc.chars().zip(p.chars()) {
1405                if c1 != c2 {
1406                    break;
1407                }
1408                len += c1.len_utf8();
1409            }
1410            &acc[..len]
1411        })
1412        .map(|s| s.len())
1413        .unwrap_or(0);
1414
1415    // Remove the common leading indentation from all lines, preserving relative indentation
1416    let normalized = lines
1417        .iter()
1418        .map(|line| {
1419            if line.trim().is_empty() {
1420                line // preserve empty lines as-is
1421            } else if line.len() >= min_indent {
1422                &line[min_indent..] // remove common indentation
1423            } else {
1424                line // line is shorter than min_indent, return as-is
1425            }
1426        })
1427        .collect::<Vec<_>>()
1428        .join("\n");
1429
1430    // Preserve trailing newline if original had one
1431    if code.ends_with('\n') {
1432        format!("{normalized}\n")
1433    } else {
1434        normalized
1435    }
1436}
1437
1438/// Highlight a single line of code for diff preview.
1439/// Returns a vector of (style, text) segments, or None if highlighting fails.
1440pub fn highlight_line_for_diff(line: &str, language: Option<&str>) -> Option<Vec<(Style, String)>> {
1441    syntax_highlight::highlight_line_to_anstyle_segments(
1442        line,
1443        language,
1444        syntax_highlight::get_active_syntax_theme(),
1445        true,
1446    )
1447    .map(|segments| {
1448        // Make text brighter for better readability in diff view
1449        segments
1450            .into_iter()
1451            .map(|(style, text)| {
1452                // Get original foreground color and brighten it
1453                let fg = style.get_fg_color().map(|c| {
1454                    match c {
1455                        anstyle::Color::Rgb(rgb) => {
1456                            // Brighten RGB colors by 20%
1457                            let brighten = |v: u8| (v as u16 * 120 / 100).min(255) as u8;
1458                            anstyle::Color::Rgb(anstyle::RgbColor(
1459                                brighten(rgb.0),
1460                                brighten(rgb.1),
1461                                brighten(rgb.2),
1462                            ))
1463                        }
1464                        anstyle::Color::Ansi(ansi) => {
1465                            // Map dark ANSI to bright ANSI
1466                            match ansi {
1467                                anstyle::AnsiColor::Black => {
1468                                    anstyle::Color::Ansi(anstyle::AnsiColor::BrightWhite)
1469                                }
1470                                anstyle::AnsiColor::Red => {
1471                                    anstyle::Color::Ansi(anstyle::AnsiColor::BrightRed)
1472                                }
1473                                anstyle::AnsiColor::Green => {
1474                                    anstyle::Color::Ansi(anstyle::AnsiColor::BrightGreen)
1475                                }
1476                                anstyle::AnsiColor::Yellow => {
1477                                    anstyle::Color::Ansi(anstyle::AnsiColor::BrightYellow)
1478                                }
1479                                anstyle::AnsiColor::Blue => {
1480                                    anstyle::Color::Ansi(anstyle::AnsiColor::BrightBlue)
1481                                }
1482                                anstyle::AnsiColor::Magenta => {
1483                                    anstyle::Color::Ansi(anstyle::AnsiColor::BrightMagenta)
1484                                }
1485                                anstyle::AnsiColor::Cyan => {
1486                                    anstyle::Color::Ansi(anstyle::AnsiColor::BrightCyan)
1487                                }
1488                                anstyle::AnsiColor::White => {
1489                                    anstyle::Color::Ansi(anstyle::AnsiColor::BrightWhite)
1490                                }
1491                                other => anstyle::Color::Ansi(other),
1492                            }
1493                        }
1494                        other => other,
1495                    }
1496                });
1497                let bg = style.get_bg_color();
1498                // Rebuild style with brighter fg, preserve bg, normal weight (no bold)
1499                let new_style = style.fg_color(fg).bg_color(bg);
1500                (new_style, text)
1501            })
1502            .collect()
1503    })
1504}
1505
1506fn try_highlight(
1507    code: &str,
1508    language: Option<&str>,
1509    config: &SyntaxHighlightingConfig,
1510) -> Option<Vec<Vec<(Style, String)>>> {
1511    let max_bytes = config.max_file_size_mb.saturating_mul(1024 * 1024);
1512    if max_bytes > 0 && code.len() > max_bytes {
1513        return None;
1514    }
1515
1516    // When enabled_languages is non-empty, use it as an allowlist;
1517    // otherwise highlight any language syntect recognises (~250 grammars).
1518    if let Some(lang) = language
1519        && !config.enabled_languages.is_empty()
1520    {
1521        let direct_match = config
1522            .enabled_languages
1523            .iter()
1524            .any(|entry| entry.eq_ignore_ascii_case(lang));
1525        if !direct_match {
1526            let syntax_ref = syntax_highlight::find_syntax_by_token(lang);
1527            let resolved_match = config
1528                .enabled_languages
1529                .iter()
1530                .any(|entry| entry.eq_ignore_ascii_case(&syntax_ref.name));
1531            if !resolved_match {
1532                return None;
1533            }
1534        }
1535    }
1536
1537    let rendered = syntax_highlight::highlight_code_to_anstyle_line_segments(
1538        code,
1539        language,
1540        &config.theme,
1541        true,
1542    );
1543
1544    Some(rendered)
1545}
1546
1547/// A highlighted line segment with style and text.
1548#[derive(Clone, Debug)]
1549pub struct HighlightedSegment {
1550    pub style: Style,
1551    pub text: String,
1552}
1553
1554/// Highlight a code string and return styled segments per line.
1555///
1556/// This function applies syntax highlighting to the provided code and returns
1557/// a vector of lines, where each line contains styled segments.
1558pub fn highlight_code_to_segments(
1559    code: &str,
1560    language: Option<&str>,
1561    theme_name: &str,
1562) -> Vec<Vec<HighlightedSegment>> {
1563    syntax_highlight::highlight_code_to_anstyle_line_segments(code, language, theme_name, true)
1564        .into_iter()
1565        .map(|segments| {
1566            segments
1567                .into_iter()
1568                .map(|(style, text)| HighlightedSegment { style, text })
1569                .collect()
1570        })
1571        .collect()
1572}
1573
1574/// Highlight a code string and return ANSI-formatted strings per line.
1575///
1576/// This is a convenience function that renders highlighting directly to
1577/// ANSI escape sequences suitable for terminal output.
1578pub fn highlight_code_to_ansi(code: &str, language: Option<&str>, theme_name: &str) -> Vec<String> {
1579    let segments = highlight_code_to_segments(code, language, theme_name);
1580    segments
1581        .into_iter()
1582        .map(|line_segments| {
1583            let mut ansi_line = String::new();
1584            for seg in line_segments {
1585                let rendered = seg.style.render();
1586                ansi_line.push_str(&format!(
1587                    "{rendered}{text}{reset}",
1588                    text = seg.text,
1589                    reset = anstyle::Reset
1590                ));
1591            }
1592            ansi_line
1593        })
1594        .collect()
1595}
1596
1597#[cfg(test)]
1598mod tests {
1599    use super::*;
1600
1601    fn lines_to_text(lines: &[MarkdownLine]) -> Vec<String> {
1602        lines
1603            .iter()
1604            .map(|line| {
1605                line.segments
1606                    .iter()
1607                    .map(|seg| seg.text.as_str())
1608                    .collect::<String>()
1609            })
1610            .collect()
1611    }
1612
1613    #[test]
1614    fn test_markdown_heading_renders_prefixes() {
1615        let markdown = "# Heading\n\n## Subheading\n";
1616        let lines = render_markdown(markdown);
1617        let text_lines = lines_to_text(&lines);
1618        assert!(text_lines.iter().any(|line| line == "# Heading"));
1619        assert!(text_lines.iter().any(|line| line == "## Subheading"));
1620    }
1621
1622    #[test]
1623    fn test_markdown_blockquote_prefix() {
1624        let markdown = "> Quote line\n> Second line\n";
1625        let lines = render_markdown(markdown);
1626        let text_lines = lines_to_text(&lines);
1627        assert!(
1628            text_lines
1629                .iter()
1630                .any(|line| line.starts_with("│ ") && line.contains("Quote line"))
1631        );
1632        assert!(
1633            text_lines
1634                .iter()
1635                .any(|line| line.starts_with("│ ") && line.contains("Second line"))
1636        );
1637    }
1638
1639    #[test]
1640    fn test_markdown_inline_code_strips_backticks() {
1641        let markdown = "Use `code` here.";
1642        let lines = render_markdown(markdown);
1643        let text_lines = lines_to_text(&lines);
1644        assert!(
1645            text_lines
1646                .iter()
1647                .any(|line| line.contains("Use code here."))
1648        );
1649    }
1650
1651    #[test]
1652    fn test_markdown_soft_break_renders_line_break() {
1653        let markdown = "first line\nsecond line";
1654        let lines = render_markdown(markdown);
1655        let text_lines: Vec<String> = lines_to_text(&lines)
1656            .into_iter()
1657            .filter(|line| !line.is_empty())
1658            .collect();
1659        assert_eq!(
1660            text_lines,
1661            vec!["first line".to_string(), "second line".to_string()]
1662        );
1663    }
1664
1665    #[test]
1666    fn test_markdown_unordered_list_bullets() {
1667        let markdown = r#"
1668- Item 1
1669- Item 2
1670  - Nested 1
1671  - Nested 2
1672- Item 3
1673"#;
1674
1675        let lines = render_markdown(markdown);
1676        let output: String = lines
1677            .iter()
1678            .map(|line| {
1679                line.segments
1680                    .iter()
1681                    .map(|seg| seg.text.as_str())
1682                    .collect::<String>()
1683            })
1684            .collect::<Vec<_>>()
1685            .join("\n");
1686
1687        // Check for bullet characters (• for depth 0, ◦ for depth 1, etc.)
1688        assert!(
1689            output.contains("•") || output.contains("◦") || output.contains("▪"),
1690            "Should use Unicode bullet characters instead of dashes"
1691        );
1692    }
1693
1694    #[test]
1695    fn test_markdown_table_box_drawing() {
1696        let markdown = r#"
1697| Header 1 | Header 2 |
1698|----------|----------|
1699| Cell 1   | Cell 2   |
1700| Cell 3   | Cell 4   |
1701"#;
1702
1703        let lines = render_markdown(markdown);
1704        let output: String = lines
1705            .iter()
1706            .map(|line| {
1707                line.segments
1708                    .iter()
1709                    .map(|seg| seg.text.as_str())
1710                    .collect::<String>()
1711            })
1712            .collect::<Vec<_>>()
1713            .join("\n");
1714
1715        // Check for box-drawing character (│ instead of |)
1716        assert!(
1717            output.contains("│"),
1718            "Should use box-drawing character (│) for table cells instead of pipe"
1719        );
1720    }
1721
1722    #[test]
1723    fn test_table_inside_markdown_code_block_renders_as_table() {
1724        let markdown = "```markdown\n\
1725            | Module | Purpose |\n\
1726            |--------|----------|\n\
1727            | core   | Library  |\n\
1728            ```\n";
1729
1730        let lines = render_markdown(markdown);
1731        let output: String = lines
1732            .iter()
1733            .map(|line| {
1734                line.segments
1735                    .iter()
1736                    .map(|seg| seg.text.as_str())
1737                    .collect::<String>()
1738            })
1739            .collect::<Vec<_>>()
1740            .join("\n");
1741
1742        assert!(
1743            output.contains("│"),
1744            "Table inside ```markdown code block should render with box-drawing characters, got: {output}"
1745        );
1746        // Should NOT contain code-block line numbers
1747        assert!(
1748            !output.contains("  1  "),
1749            "Table inside markdown code block should not have line numbers"
1750        );
1751    }
1752
1753    #[test]
1754    fn test_table_inside_md_code_block_renders_as_table() {
1755        let markdown = "```md\n\
1756            | A | B |\n\
1757            |---|---|\n\
1758            | 1 | 2 |\n\
1759            ```\n";
1760
1761        let lines = render_markdown(markdown);
1762        let output = lines_to_text(&lines).join("\n");
1763
1764        assert!(
1765            output.contains("│"),
1766            "Table inside ```md code block should render as table: {output}"
1767        );
1768    }
1769
1770    #[test]
1771    fn test_table_code_block_reparse_guard_can_disable_table_reparse() {
1772        let markdown = "```markdown\n\
1773            | Module | Purpose |\n\
1774            |--------|----------|\n\
1775            | core   | Library  |\n\
1776            ```\n";
1777        let options = RenderMarkdownOptions {
1778            preserve_code_indentation: false,
1779            disable_code_block_table_reparse: true,
1780        };
1781        let lines = render_markdown_to_lines_with_options(
1782            markdown,
1783            Style::default(),
1784            &theme::active_styles(),
1785            None,
1786            options,
1787        );
1788        let output = lines_to_text(&lines).join("\n");
1789
1790        assert!(
1791            output.contains("| Module | Purpose |"),
1792            "Guarded render should keep code-block content literal: {output}"
1793        );
1794        assert!(
1795            output.contains("  1  "),
1796            "Guarded render should keep code-block line numbers: {output}"
1797        );
1798    }
1799
1800    #[test]
1801    fn test_rust_code_block_with_pipes_not_treated_as_table() {
1802        let markdown = "```rust\n\
1803            | Header | Col |\n\
1804            |--------|-----|\n\
1805            | a      | b   |\n\
1806            ```\n";
1807
1808        let lines = render_markdown(markdown);
1809        let output = lines_to_text(&lines).join("\n");
1810
1811        // Rust code blocks should NOT be reinterpreted as tables
1812        assert!(
1813            output.contains("| Header |"),
1814            "Rust code block should keep raw pipe characters: {output}"
1815        );
1816    }
1817
1818    #[test]
1819    fn test_markdown_code_block_with_language_renders_line_numbers() {
1820        let markdown = "```rust\nfn main() {}\n```\n";
1821        let lines = render_markdown(markdown);
1822        let text_lines = lines_to_text(&lines);
1823        let code_line = text_lines
1824            .iter()
1825            .find(|line| line.contains("fn main() {}"))
1826            .expect("code line exists");
1827        assert!(code_line.contains("  1  "));
1828    }
1829
1830    #[test]
1831    fn test_markdown_code_block_without_language_skips_line_numbers() {
1832        let markdown = "```\nfn main() {}\n```\n";
1833        let lines = render_markdown(markdown);
1834        let text_lines = lines_to_text(&lines);
1835        let code_line = text_lines
1836            .iter()
1837            .find(|line| line.contains("fn main() {}"))
1838            .expect("code line exists");
1839        assert!(!code_line.contains("  1  "));
1840    }
1841
1842    #[test]
1843    fn test_markdown_diff_code_block_strips_backgrounds() {
1844        let markdown = "```diff\n@@ -1 +1 @@\n- old\n+ new\n context\n```\n";
1845        let lines =
1846            render_markdown_to_lines(markdown, Style::default(), &theme::active_styles(), None);
1847
1848        let added_line = lines
1849            .iter()
1850            .find(|line| line.segments.iter().any(|seg| seg.text.contains("+ new")))
1851            .expect("added line exists");
1852        assert!(
1853            added_line
1854                .segments
1855                .iter()
1856                .all(|seg| seg.style.get_bg_color().is_none())
1857        );
1858
1859        let removed_line = lines
1860            .iter()
1861            .find(|line| line.segments.iter().any(|seg| seg.text.contains("- old")))
1862            .expect("removed line exists");
1863        assert!(
1864            removed_line
1865                .segments
1866                .iter()
1867                .all(|seg| seg.style.get_bg_color().is_none())
1868        );
1869
1870        let context_line = lines
1871            .iter()
1872            .find(|line| {
1873                line.segments
1874                    .iter()
1875                    .any(|seg| seg.text.contains(" context"))
1876            })
1877            .expect("context line exists");
1878        assert!(
1879            context_line
1880                .segments
1881                .iter()
1882                .all(|seg| seg.style.get_bg_color().is_none())
1883        );
1884    }
1885
1886    #[test]
1887    fn test_markdown_unlabeled_diff_code_block_detects_diff() {
1888        let markdown = "```\n@@ -1 +1 @@\n- old\n+ new\n```\n";
1889        let lines =
1890            render_markdown_to_lines(markdown, Style::default(), &theme::active_styles(), None);
1891        let expected_added_fg = DiffColorPalette::default().added_style().get_fg_color();
1892        let added_line = lines
1893            .iter()
1894            .find(|line| line.segments.iter().any(|seg| seg.text.contains("+ new")))
1895            .expect("added line exists");
1896        let added_segment = added_line
1897            .segments
1898            .iter()
1899            .find(|seg| seg.text.contains("+ new"))
1900            .expect("added segment exists");
1901        assert_eq!(added_segment.style.get_fg_color(), expected_added_fg);
1902        assert!(
1903            added_line
1904                .segments
1905                .iter()
1906                .all(|seg| seg.style.get_bg_color().is_none())
1907        );
1908    }
1909
1910    #[test]
1911    fn test_markdown_unlabeled_minimal_hunk_detects_diff() {
1912        let markdown = "```\n@@\n pub fn demo() {\n  -    old();\n  +    new();\n }\n```\n";
1913        let lines =
1914            render_markdown_to_lines(markdown, Style::default(), &theme::active_styles(), None);
1915        let palette = DiffColorPalette::default();
1916
1917        let header_segment = lines
1918            .iter()
1919            .flat_map(|line| line.segments.iter())
1920            .find(|seg| seg.text.trim() == "@@")
1921            .expect("hunk header exists");
1922        assert_eq!(
1923            header_segment.style.get_fg_color(),
1924            palette.header_style().get_fg_color()
1925        );
1926
1927        let removed_segment = lines
1928            .iter()
1929            .flat_map(|line| line.segments.iter())
1930            .find(|seg| seg.text.contains("-    old();"))
1931            .expect("removed segment exists");
1932        assert_eq!(
1933            removed_segment.style.get_fg_color(),
1934            palette.removed_style().get_fg_color()
1935        );
1936
1937        let added_segment = lines
1938            .iter()
1939            .flat_map(|line| line.segments.iter())
1940            .find(|seg| seg.text.contains("+    new();"))
1941            .expect("added segment exists");
1942        assert_eq!(
1943            added_segment.style.get_fg_color(),
1944            palette.added_style().get_fg_color()
1945        );
1946    }
1947
1948    #[test]
1949    fn test_highlight_line_for_diff_strips_background_colors() {
1950        let segments = highlight_line_for_diff("let changed = true;", Some("rust"))
1951            .expect("highlighting should return segments");
1952        assert!(
1953            segments
1954                .iter()
1955                .all(|(style, _)| style.get_bg_color().is_none())
1956        );
1957    }
1958
1959    #[test]
1960    fn test_markdown_task_list_markers() {
1961        let markdown = "- [x] Done\n- [ ] Todo\n";
1962        let lines = render_markdown(markdown);
1963        let text_lines = lines_to_text(&lines);
1964        assert!(text_lines.iter().any(|line| line.contains("[x]")));
1965        assert!(text_lines.iter().any(|line| line.contains("[ ]")));
1966    }
1967
1968    #[test]
1969    fn test_code_indentation_normalization_removes_common_indent() {
1970        let code_with_indent = "    fn hello() {\n        println!(\"world\");\n    }";
1971        let expected = "fn hello() {\n    println!(\"world\");\n}";
1972        let result = normalize_code_indentation(code_with_indent, Some("rust"), false);
1973        assert_eq!(result, expected);
1974    }
1975
1976    #[test]
1977    fn test_code_indentation_preserves_already_normalized() {
1978        let code = "fn hello() {\n    println!(\"world\");\n}";
1979        let result = normalize_code_indentation(code, Some("rust"), false);
1980        assert_eq!(result, code);
1981    }
1982
1983    #[test]
1984    fn test_code_indentation_without_language_hint() {
1985        // Without language hint, normalization still happens - common indent is stripped
1986        let code = "    some code";
1987        let result = normalize_code_indentation(code, None, false);
1988        assert_eq!(result, "some code");
1989    }
1990
1991    #[test]
1992    fn test_code_indentation_preserves_relative_indentation() {
1993        let code = "    line1\n        line2\n    line3";
1994        let expected = "line1\n    line2\nline3";
1995        let result = normalize_code_indentation(code, Some("python"), false);
1996        assert_eq!(result, expected);
1997    }
1998
1999    #[test]
2000    fn test_code_indentation_mixed_whitespace_preserves_indent() {
2001        // Mixed tabs and spaces - common prefix should be empty if they differ
2002        let code = "    line1\n\tline2";
2003        let result = normalize_code_indentation(code, None, false);
2004        // Should preserve original content rather than stripping incorrectly
2005        assert_eq!(result, code);
2006    }
2007
2008    #[test]
2009    fn test_code_indentation_common_prefix_mixed() {
2010        // Common prefix is present ("    ")
2011        let code = "    line1\n    \tline2";
2012        let expected = "line1\n\tline2";
2013        let result = normalize_code_indentation(code, None, false);
2014        assert_eq!(result, expected);
2015    }
2016
2017    #[test]
2018    fn test_code_indentation_preserve_when_requested() {
2019        let code = "    line1\n        line2\n    line3\n";
2020        let result = normalize_code_indentation(code, Some("rust"), true);
2021        assert_eq!(result, code);
2022    }
2023
2024    #[test]
2025    fn test_diff_summary_counts_function_signature_change() {
2026        // Test case matching the user's TODO scenario - function signature change
2027        let diff = "diff --git a/ask.rs b/ask.rs\n\
2028index 0000000..1111111 100644\n\
2029--- a/ask.rs\n\
2030+++ b/ask.rs\n\
2031@@ -172,7 +172,7 @@\n\
2032          blocks\n\
2033      }\n\
2034 \n\
2035-    fn select_best_code_block<'a>(blocks: &'a [CodeFenceBlock]) -> Option<&'a CodeFenceBlock> {\n\
2036+    fn select_best_code_block(blocks: &[CodeFenceBlock]) -> Option<&CodeFenceBlock> {\n\
2037          let mut best = None;\n\
2038          let mut best_score = (0usize, 0u8);\n\
2039          for block in blocks {";
2040
2041        let lines = normalize_diff_lines(diff);
2042
2043        // Find the summary line
2044        let summary_line = lines
2045            .iter()
2046            .find(|l| l.starts_with("• Diff "))
2047            .expect("should have summary line");
2048
2049        // Should show (+1 -1) not (+0 -0)
2050        assert_eq!(summary_line, "• Diff ask.rs (+1 -1)");
2051    }
2052
2053    #[test]
2054    fn test_markdown_file_link_hides_destination() {
2055        let markdown = "[markdown_render.rs:74](/Users/example/code/codex/codex-rs/tui/src/markdown_render.rs:74)";
2056        let lines = render_markdown(markdown);
2057        let text_lines = lines_to_text(&lines);
2058
2059        // Should contain the link text but NOT the destination
2060        assert!(
2061            text_lines
2062                .iter()
2063                .any(|line| line.contains("markdown_render.rs:74"))
2064        );
2065        assert!(
2066            !text_lines
2067                .iter()
2068                .any(|line| line.contains("/Users/example"))
2069        );
2070    }
2071
2072    #[test]
2073    fn test_markdown_url_link_shows_destination() {
2074        let markdown = "[docs](https://example.com/docs)";
2075        let lines = render_markdown(markdown);
2076        let text_lines = lines_to_text(&lines);
2077        let combined = text_lines.join("");
2078
2079        // Should contain both the link text and the destination
2080        assert!(combined.contains("docs"));
2081        assert!(combined.contains("https://example.com/docs"));
2082    }
2083
2084    #[test]
2085    fn test_markdown_relative_link_hides_destination() {
2086        let markdown = "[relative](./path/to/file.md)";
2087        let lines = render_markdown(markdown);
2088        let text_lines = lines_to_text(&lines);
2089        let combined = text_lines.join("");
2090
2091        // Should contain the link text but NOT the destination
2092        assert!(combined.contains("relative"));
2093        assert!(!combined.contains("./path/to/file.md"));
2094    }
2095
2096    #[test]
2097    fn test_markdown_home_relative_link_hides_destination() {
2098        let markdown = "[home relative](~/path/to/file.md)";
2099        let lines = render_markdown(markdown);
2100        let text_lines = lines_to_text(&lines);
2101        let combined = text_lines.join("");
2102
2103        // Should contain the link text but NOT the destination
2104        assert!(combined.contains("home relative"));
2105        assert!(!combined.contains("~/path/to/file.md"));
2106    }
2107
2108    #[test]
2109    fn test_markdown_parent_relative_link_hides_destination() {
2110        let markdown = "[parent](../path/to/file.md)";
2111        let lines = render_markdown(markdown);
2112        let text_lines = lines_to_text(&lines);
2113        let combined = text_lines.join("");
2114
2115        // Should contain the link text but NOT the destination
2116        assert!(combined.contains("parent"));
2117        assert!(!combined.contains("../path/to/file.md"));
2118    }
2119
2120    #[test]
2121    fn test_markdown_file_url_link_hides_destination() {
2122        let markdown = "[file url](file:///path/to/file.md)";
2123        let lines = render_markdown(markdown);
2124        let text_lines = lines_to_text(&lines);
2125        let combined = text_lines.join("");
2126
2127        // Should contain the link text but NOT the destination
2128        assert!(combined.contains("file url"));
2129        assert!(!combined.contains("file:///path/to/file.md"));
2130    }
2131
2132    #[test]
2133    fn test_markdown_windows_path_link_hides_destination() {
2134        let markdown = "[windows](C:\\path\\to\\file.md)";
2135        let lines = render_markdown(markdown);
2136        let text_lines = lines_to_text(&lines);
2137        let combined = text_lines.join("");
2138
2139        // Should contain the link text but NOT the destination
2140        assert!(combined.contains("windows"));
2141        assert!(!combined.contains("C:\\path\\to\\file.md"));
2142    }
2143
2144    #[test]
2145    fn test_markdown_https_link_shows_destination() {
2146        let markdown = "[secure](https://secure.example.com)";
2147        let lines = render_markdown(markdown);
2148        let text_lines = lines_to_text(&lines);
2149        let combined = text_lines.join("");
2150
2151        // Should contain both the link text and the destination
2152        assert!(combined.contains("secure"));
2153        assert!(combined.contains("https://secure.example.com"));
2154    }
2155
2156    #[test]
2157    fn test_markdown_http_link_shows_destination() {
2158        let markdown = "[http](http://example.com)";
2159        let lines = render_markdown(markdown);
2160        let text_lines = lines_to_text(&lines);
2161        let combined = text_lines.join("");
2162
2163        // Should contain both the link text and the destination
2164        assert!(combined.contains("http"));
2165        assert!(combined.contains("http://example.com"));
2166    }
2167
2168    #[test]
2169    fn test_load_location_suffix_regexes() {
2170        let _colon = &*COLON_LOCATION_SUFFIX_RE;
2171        let _hash = &*HASH_LOCATION_SUFFIX_RE;
2172    }
2173
2174    #[test]
2175    fn test_file_link_hides_destination() {
2176        let markdown = "[codex-rs/tui/src/markdown_render.rs](/Users/example/code/codex/codex-rs/tui/src/markdown_render.rs)";
2177        let lines = render_markdown(markdown);
2178        let text_lines = lines_to_text(&lines);
2179        let combined = text_lines.join("");
2180
2181        // Should contain the link text but NOT the destination path
2182        assert!(combined.contains("codex-rs/tui/src/markdown_render.rs"));
2183        assert!(!combined.contains("/Users/example"));
2184    }
2185
2186    #[test]
2187    fn test_file_link_appends_line_number_when_label_lacks_it() {
2188        let markdown = "[markdown_render.rs](/Users/example/code/codex/codex-rs/tui/src/markdown_render.rs:74)";
2189        let lines = render_markdown(markdown);
2190        let text_lines = lines_to_text(&lines);
2191        let combined = text_lines.join("");
2192
2193        // Should contain the filename AND the line number
2194        assert!(combined.contains("markdown_render.rs"));
2195        assert!(combined.contains(":74"));
2196    }
2197
2198    #[test]
2199    fn test_file_link_uses_label_for_line_number() {
2200        let markdown = "[markdown_render.rs:74](/Users/example/code/codex/codex-rs/tui/src/markdown_render.rs:74)";
2201        let lines = render_markdown(markdown);
2202        let text_lines = lines_to_text(&lines);
2203        let combined = text_lines.join("");
2204
2205        // Should contain the label with line number, but not duplicate
2206        assert!(combined.contains("markdown_render.rs:74"));
2207        // Should not have duplicate :74
2208        assert!(!combined.contains(":74:74"));
2209    }
2210
2211    #[test]
2212    fn test_file_link_appends_hash_anchor_when_label_lacks_it() {
2213        let markdown = "[markdown_render.rs](file:///Users/example/code/codex/codex-rs/tui/src/markdown_render.rs#L74C3)";
2214        let lines = render_markdown(markdown);
2215        let text_lines = lines_to_text(&lines);
2216        let combined = text_lines.join("");
2217
2218        // Should contain the filename AND the converted location
2219        assert!(combined.contains("markdown_render.rs"));
2220        assert!(combined.contains(":74:3"));
2221    }
2222
2223    #[test]
2224    fn test_file_link_uses_label_for_hash_anchor() {
2225        let markdown = "[markdown_render.rs#L74C3](file:///Users/example/code/codex/codex-rs/tui/src/markdown_render.rs#L74C3)";
2226        let lines = render_markdown(markdown);
2227        let text_lines = lines_to_text(&lines);
2228        let combined = text_lines.join("");
2229
2230        // Should contain the label with location, but not duplicate
2231        assert!(combined.contains("markdown_render.rs#L74C3"));
2232    }
2233
2234    #[test]
2235    fn test_file_link_appends_range_when_label_lacks_it() {
2236        let markdown = "[markdown_render.rs](/Users/example/code/codex/codex-rs/tui/src/markdown_render.rs:74:3-76:9)";
2237        let lines = render_markdown(markdown);
2238        let text_lines = lines_to_text(&lines);
2239        let combined = text_lines.join("");
2240
2241        // Should contain the filename AND the range
2242        assert!(combined.contains("markdown_render.rs"));
2243        assert!(combined.contains(":74:3-76:9"));
2244    }
2245
2246    #[test]
2247    fn test_file_link_uses_label_for_range() {
2248        let markdown = "[markdown_render.rs:74:3-76:9](/Users/example/code/codex/codex-rs/tui/src/markdown_render.rs:74:3-76:9)";
2249        let lines = render_markdown(markdown);
2250        let text_lines = lines_to_text(&lines);
2251        let combined = text_lines.join("");
2252
2253        // Should contain the label with range, but not duplicate
2254        assert!(combined.contains("markdown_render.rs:74:3-76:9"));
2255        // Should not have duplicate range
2256        assert!(!combined.contains(":74:3-76:9:74:3-76:9"));
2257    }
2258
2259    #[test]
2260    fn test_file_link_appends_hash_range_when_label_lacks_it() {
2261        let markdown = "[markdown_render.rs](file:///Users/example/code/codex/codex-rs/tui/src/markdown_render.rs#L74C3-L76C9)";
2262        let lines = render_markdown(markdown);
2263        let text_lines = lines_to_text(&lines);
2264        let combined = text_lines.join("");
2265
2266        // Should contain the filename AND the converted range
2267        assert!(combined.contains("markdown_render.rs"));
2268        assert!(combined.contains(":74:3-76:9"));
2269    }
2270
2271    #[test]
2272    fn test_file_link_uses_label_for_hash_range() {
2273        let markdown = "[markdown_render.rs#L74C3-L76C9](file:///Users/example/code/codex/codex-rs/tui/src/markdown_render.rs#L74C3-L76C9)";
2274        let lines = render_markdown(markdown);
2275        let text_lines = lines_to_text(&lines);
2276        let combined = text_lines.join("");
2277
2278        // Should contain the label with range, but not duplicate
2279        assert!(combined.contains("markdown_render.rs#L74C3-L76C9"));
2280    }
2281
2282    #[test]
2283    fn test_normalize_hash_location_single() {
2284        assert_eq!(normalize_hash_location("L74C3"), Some(":74:3".to_string()));
2285    }
2286
2287    #[test]
2288    fn test_normalize_hash_location_range() {
2289        assert_eq!(
2290            normalize_hash_location("L74C3-L76C9"),
2291            Some(":74:3-76:9".to_string())
2292        );
2293    }
2294
2295    #[test]
2296    fn test_normalize_hash_location_line_only() {
2297        assert_eq!(normalize_hash_location("L74"), Some(":74".to_string()));
2298    }
2299
2300    #[test]
2301    fn test_normalize_hash_location_range_line_only() {
2302        assert_eq!(
2303            normalize_hash_location("L74-L76"),
2304            Some(":74-76".to_string())
2305        );
2306    }
2307
2308    #[test]
2309    fn test_label_has_location_suffix_colon() {
2310        assert!(label_has_location_suffix("file.rs:74"));
2311        assert!(label_has_location_suffix("file.rs:74:3"));
2312        assert!(label_has_location_suffix("file.rs:74:3-76:9"));
2313        assert!(!label_has_location_suffix("file.rs"));
2314    }
2315
2316    #[test]
2317    fn test_label_has_location_suffix_hash() {
2318        assert!(label_has_location_suffix("file.rs#L74C3"));
2319        assert!(label_has_location_suffix("file.rs#L74C3-L76C9"));
2320        assert!(!label_has_location_suffix("file.rs#section"));
2321    }
2322}