1use 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#[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#[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
125pub 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 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 !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 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
323pub 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 }
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 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 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 if !table.headers.is_empty() {
656 lines.push(render_row(&table.headers, &col_widths, true));
657
658 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 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 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
890fn 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
897fn extract_hidden_location_suffix(dest_url: &str) -> Option<String> {
899 if !is_local_path_like_link(dest_url) {
900 return None;
901 }
902
903 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 COLON_LOCATION_SUFFIX_RE
912 .find(dest_url)
913 .map(|m| m.as_str().to_string())
914}
915
916fn 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 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 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
1263fn code_block_contains_table(content: &str, language: Option<&str>) -> bool {
1270 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 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 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
1343fn 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 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 if !has_language_hint && language.is_some() {
1388 return code.to_string();
1390 }
1391
1392 let lines: Vec<&str> = code.lines().collect();
1393
1394 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 let normalized = lines
1415 .iter()
1416 .map(|line| {
1417 if line.trim().is_empty() {
1418 line } else if line.len() >= min_indent {
1420 &line[min_indent..] } else {
1422 line }
1424 })
1425 .collect::<Vec<_>>()
1426 .join("\n");
1427
1428 if code.ends_with('\n') {
1430 format!("{normalized}\n")
1431 } else {
1432 normalized
1433 }
1434}
1435
1436pub 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 segments
1448 .into_iter()
1449 .map(|(style, text)| {
1450 let fg = style.get_fg_color().map(|c| {
1452 match c {
1453 anstyle::Color::Rgb(rgb) => {
1454 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 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 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 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#[derive(Clone, Debug)]
1547pub struct HighlightedSegment {
1548 pub style: Style,
1549 pub text: String,
1550}
1551
1552pub 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
1572pub 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 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 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 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 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 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 let code = " line1\n\tline2";
2001 let result = normalize_code_indentation(code, None, false);
2002 assert_eq!(result, code);
2004 }
2005
2006 #[test]
2007 fn test_code_indentation_common_prefix_mixed() {
2008 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 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 let summary_line = lines
2043 .iter()
2044 .find(|l| l.starts_with("• Diff "))
2045 .expect("should have summary line");
2046
2047 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 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 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 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 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 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 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 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 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 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 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 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 assert!(combined.contains("markdown_render.rs:74"));
2205 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 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 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 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 assert!(combined.contains("markdown_render.rs:74:3-76:9"));
2253 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 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 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}