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