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