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 mut col_widths: Vec<usize> = Vec::new();
616
617 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 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 if !table.headers.is_empty() {
660 lines.push(render_row(&table.headers, &col_widths, true));
661
662 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 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 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
892fn 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
899fn extract_hidden_location_suffix(dest_url: &str) -> Option<String> {
901 if !is_local_path_like_link(dest_url) {
902 return None;
903 }
904
905 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 COLON_LOCATION_SUFFIX_RE
914 .find(dest_url)
915 .map(|m| m.as_str().to_string())
916}
917
918fn 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 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 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
1265fn code_block_contains_table(content: &str, language: Option<&str>) -> bool {
1272 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 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 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
1345fn 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 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 if !has_language_hint && language.is_some() {
1390 return code.to_string();
1392 }
1393
1394 let lines: Vec<&str> = code.lines().collect();
1395
1396 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 let normalized = lines
1417 .iter()
1418 .map(|line| {
1419 if line.trim().is_empty() {
1420 line } else if line.len() >= min_indent {
1422 &line[min_indent..] } else {
1424 line }
1426 })
1427 .collect::<Vec<_>>()
1428 .join("\n");
1429
1430 if code.ends_with('\n') {
1432 format!("{normalized}\n")
1433 } else {
1434 normalized
1435 }
1436}
1437
1438pub 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 segments
1450 .into_iter()
1451 .map(|(style, text)| {
1452 let fg = style.get_fg_color().map(|c| {
1454 match c {
1455 anstyle::Color::Rgb(rgb) => {
1456 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 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 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 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#[derive(Clone, Debug)]
1549pub struct HighlightedSegment {
1550 pub style: Style,
1551 pub text: String,
1552}
1553
1554pub 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
1574pub 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 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 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 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 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 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 let code = " line1\n\tline2";
2003 let result = normalize_code_indentation(code, None, false);
2004 assert_eq!(result, code);
2006 }
2007
2008 #[test]
2009 fn test_code_indentation_common_prefix_mixed() {
2010 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 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 let summary_line = lines
2045 .iter()
2046 .find(|l| l.starts_with("• Diff "))
2047 .expect("should have summary line");
2048
2049 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 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 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 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 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 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 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 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 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 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 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 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 assert!(combined.contains("markdown_render.rs:74"));
2207 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 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 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 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 assert!(combined.contains("markdown_render.rs:74:3-76:9"));
2255 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 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 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}