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