1use std::any::Any;
8use std::sync::Mutex;
9
10use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
11
12use super::component::Component;
13use super::mermaid::Mermaid;
14use crate::ansi::{bold, italic, underline};
15use crate::theme::theme;
16use crate::utils::{
17 apply_background_to_line, truncate_to_width, visible_width, wrap_text_with_ansi,
18};
19
20#[derive(Debug, Clone)]
22pub struct MarkdownOptions {
23 pub code_block_indent: usize,
25 pub max_width: Option<usize>,
27 pub base_style: String,
32}
33
34impl Default for MarkdownOptions {
35 fn default() -> Self {
36 Self {
37 code_block_indent: 2,
38 max_width: None,
39 base_style: String::new(),
40 }
41 }
42}
43
44pub struct Markdown {
46 content: Mutex<String>,
47 options: MarkdownOptions,
48 padding_x: usize,
49 padding_y: usize,
50 cache: Mutex<Option<MarkdownCache>>,
56}
57
58#[derive(Clone)]
59struct MarkdownCache {
60 content: String,
61 width: usize,
62 theme_revision: u64,
63 lines: Vec<String>,
64}
65
66impl Markdown {
67 pub fn new(content: impl Into<String>, padding_x: usize, padding_y: usize) -> Self {
69 Self {
70 content: Mutex::new(content.into()),
71 options: MarkdownOptions::default(),
72 padding_x,
73 padding_y,
74 cache: Mutex::new(None),
75 }
76 }
77
78 pub fn with_options(
80 content: impl Into<String>,
81 options: MarkdownOptions,
82 padding_x: usize,
83 padding_y: usize,
84 ) -> Self {
85 Self {
86 content: Mutex::new(content.into()),
87 options,
88 padding_x,
89 padding_y,
90 cache: Mutex::new(None),
91 }
92 }
93
94 pub fn with_base_style(mut self, base_style: impl Into<String>) -> Self {
99 self.options.base_style = base_style.into();
100 self.clear_cache();
101 self
102 }
103
104 fn apply_base_style(&self, line: &str) -> String {
108 let base = self.options.base_style.as_str();
109 if base.is_empty() {
110 return line.to_string();
111 }
112 let reset = "\x1b[0m";
113 let mut out = String::with_capacity(line.len() + base.len() * 4 + reset.len());
114 out.push_str(base);
115 let mut rest = line;
116 while let Some(idx) = rest.find(reset) {
117 out.push_str(&rest[..idx + reset.len()]);
118 out.push_str(base);
119 rest = &rest[idx + reset.len()..];
120 }
121 out.push_str(rest);
122 out.push_str(reset);
125 out
126 }
127
128 pub fn set_content(&self, content: impl Into<String>) {
130 if let Ok(mut c) = self.content.lock() {
131 *c = content.into();
132 }
133 self.clear_cache();
134 }
135
136 fn clear_cache(&self) {
138 if let Ok(mut cache) = self.cache.lock() {
139 *cache = None;
140 }
141 }
142
143 pub fn get_content(&self) -> String {
145 self.content.lock().map(|c| c.clone()).unwrap_or_default()
146 }
147
148 fn pad(&self) -> String {
150 " ".repeat(self.padding_x)
151 }
152
153 fn content_width(&self, width: usize) -> usize {
155 width.saturating_sub(self.padding_x * 2)
156 }
157
158 fn render_markdown(&self, width: usize) -> Vec<String> {
160 let content = self.content.lock().map(|c| c.clone()).unwrap_or_default();
161 let colors = theme().colors;
162 let mut lines: Vec<String> = Vec::new();
163 let indent = " ".repeat(self.options.code_block_indent);
164 let pad = self.pad();
165 let cwidth = self.content_width(width);
166
167 for _ in 0..self.padding_y {
169 lines.push(String::new());
170 }
171
172 let raw_lines: Vec<&str> = content.lines().collect();
174 let mut i = 0;
175 let mut list_continuation_indent: Option<usize> = None;
179
180 let mut in_code_block = false;
183 let mut code_fence_char = '`';
184 let mut code_language = String::new();
185 let mut code_highlighter: Option<crate::syntax_highlight::Highlighter> = None;
189 let mut mermaid_source: Vec<String> = Vec::new();
190 while i < raw_lines.len() {
191 let line = raw_lines[i];
192
193 if is_partial_fence(line) {
200 i += 1;
201 continue;
202 }
203
204 let fence = fence_info(line);
206 if let Some((fence_char, lang)) = fence {
207 list_continuation_indent = None;
208 if !in_code_block {
211 in_code_block = true;
212 code_fence_char = fence_char;
213 code_language = lang.to_ascii_lowercase();
214 code_highlighter =
215 Some(crate::syntax_highlight::Highlighter::new(&code_language));
216 mermaid_source.clear();
217 } else if fence_char == code_fence_char {
222 if code_language == "mermaid" {
223 let component = Mermaid::new(mermaid_source.join("\n"));
224 let mermaid_lines = component.render(cwidth);
225 lines.extend(mermaid_lines.into_iter().map(|line| format!("{pad}{line}")));
226 }
227 in_code_block = false;
228 code_language.clear();
229 code_highlighter = None;
230 mermaid_source.clear();
231 } else {
234 push_code_line(
236 line,
237 &pad,
238 &indent,
239 cwidth,
240 &mut lines,
241 code_highlighter.as_mut(),
242 );
243 }
244 i += 1;
245 continue;
246 }
247
248 if in_code_block {
249 list_continuation_indent = None;
250 if code_language == "mermaid" {
251 mermaid_source.push(line.to_string());
252 i += 1;
253 continue;
254 }
255 push_code_line(
259 line,
260 &pad,
261 &indent,
262 cwidth,
263 &mut lines,
264 code_highlighter.as_mut(),
265 );
266 i += 1;
267 continue;
268 }
269
270 if is_table_separator(raw_lines.get(i + 1)) && is_table_row(line) {
272 list_continuation_indent = None;
273 let mut table_lines: Vec<&str> = Vec::new();
275 table_lines.push(line);
276 i += 1;
277 table_lines.push(raw_lines[i]);
279 i += 1;
280 while i < raw_lines.len() && is_table_row(raw_lines[i]) {
281 table_lines.push(raw_lines[i]);
282 i += 1;
283 }
284 render_table(&table_lines, &pad, cwidth, &mut lines, |cell| {
285 self.render_inline(cell)
286 });
287 continue;
288 }
289
290 if let Some(rest) = strip_header(line, "######") {
294 list_continuation_indent = None;
295 let h = bold(&colors.md_heading.fg(&self.render_inline(rest)));
296 push_wrapped(&pad, &h, cwidth, &mut lines);
297 } else if let Some(rest) = strip_header(line, "#####") {
298 list_continuation_indent = None;
299 let h = bold(&colors.md_heading.fg(&self.render_inline(rest)));
300 push_wrapped(&pad, &h, cwidth, &mut lines);
301 } else if let Some(rest) = strip_header(line, "####") {
302 list_continuation_indent = None;
303 let h = bold(&colors.md_heading.fg(&self.render_inline(rest)));
304 push_wrapped(&pad, &h, cwidth, &mut lines);
305 } else if let Some(rest) = strip_header(line, "###") {
306 list_continuation_indent = None;
307 let h = bold(&colors.md_heading.fg(&self.render_inline(rest)));
308 push_wrapped(&pad, &h, cwidth, &mut lines);
309 } else if let Some(rest) = strip_header(line, "##") {
310 list_continuation_indent = None;
311 let h = colors
312 .md_heading
313 .fg(&bold(&underline(&self.render_inline(rest))));
314 push_wrapped(&pad, &h, cwidth, &mut lines);
315 } else if let Some(rest) = strip_header(line, "#") {
316 list_continuation_indent = None;
317 let h = colors
318 .md_heading
319 .fg(&bold(&underline(&self.render_inline(rest))));
320 push_wrapped(&pad, &h, cwidth, &mut lines);
321 } else if let Some(item) = parse_list_item(line) {
322 let marker = match item.marker {
323 ListMarker::Bullet => colors.md_list_bullet.fg("•"),
324 ListMarker::Ordered(marker) => colors.md_list_bullet.fg(&marker),
325 ListMarker::Task(marker) => colors.md_list_bullet.fg(&marker.to_string()),
326 };
327 let prefix = format!("{}{} ", " ".repeat(item.depth + 1), marker);
328 list_continuation_indent = Some(visible_width(&prefix));
329 push_wrapped_with_prefix(
330 &pad,
331 &prefix,
332 &self.render_inline(item.body),
333 cwidth,
334 &mut lines,
335 );
336 } else if let Some((depth, body)) = parse_blockquote(line) {
337 list_continuation_indent = None;
338 let border = colors.md_quote_border.fg("│");
339 let prefix = format!(" {}", format!("{border} ").repeat(depth));
340 let body = colors.md_quote.fg(&italic(&self.render_inline(body)));
341 push_wrapped_with_prefix(&pad, &prefix, &body, cwidth, &mut lines);
342 } else if line.trim().starts_with("---") || line.trim().starts_with("***") {
343 list_continuation_indent = None;
344 let rule_w = width.min(80).saturating_sub(self.padding_x * 2);
346 lines.push(format!(
347 "{pad}{}",
348 colors.md_hr.fg(&"─".repeat(rule_w.max(1)))
349 ));
350 } else if line.trim().is_empty() {
351 list_continuation_indent = None;
352 lines.push(String::new());
354 } else if let Some(indent) =
355 list_continuation_indent.filter(|_| line.starts_with(' ') || line.starts_with('\t'))
356 {
357 push_wrapped_with_prefix(
358 &pad,
359 &" ".repeat(indent),
360 &self.render_inline(line.trim()),
361 cwidth,
362 &mut lines,
363 );
364 } else {
365 list_continuation_indent = None;
366 push_wrapped(&pad, &self.render_inline(line), cwidth, &mut lines);
368 }
369
370 i += 1;
371 }
372
373 for _ in 0..self.padding_y {
375 lines.push(String::new());
376 }
377
378 if lines.is_empty() {
379 lines.push(String::new());
380 }
381
382 if !self.options.base_style.is_empty() {
385 for line in lines.iter_mut() {
386 *line = self.apply_base_style(line);
387 }
388 }
389
390 lines
391 }
392
393 fn render_inline(&self, text: &str) -> String {
395 let colors = theme().colors;
396 let mut options = Options::empty();
397 options.insert(Options::ENABLE_STRIKETHROUGH);
398
399 let source = format!("x {text}");
403 let mut discard_prefix = true;
404 let mut frames = vec![InlineFrame::root()];
405
406 for event in Parser::new_ext(&source, options) {
407 match event {
408 Event::Start(Tag::Strong) => frames.push(InlineFrame::new(InlineKind::Bold)),
409 Event::Start(Tag::Emphasis) => frames.push(InlineFrame::new(InlineKind::Italic)),
410 Event::Start(Tag::Strikethrough) => {
411 frames.push(InlineFrame::new(InlineKind::Strike));
412 }
413 Event::Start(Tag::Link { dest_url, .. }) => {
414 frames.push(InlineFrame::new(InlineKind::Link(dest_url.into_string())))
415 }
416 Event::Start(Tag::Image { dest_url, .. }) => {
417 frames.push(InlineFrame::new(InlineKind::Image(dest_url.into_string())))
418 }
419 Event::End(
420 TagEnd::Strong
421 | TagEnd::Emphasis
422 | TagEnd::Strikethrough
423 | TagEnd::Link
424 | TagEnd::Image,
425 ) => close_inline_frame(&mut frames, &colors),
426 Event::Text(value) => {
427 let mut value = value.as_ref();
428 if discard_prefix {
429 value = value.strip_prefix("x ").unwrap_or(value);
430 discard_prefix = false;
431 }
432 frames
433 .last_mut()
434 .expect("root inline frame")
435 .text
436 .push_str(value);
437 }
438 Event::Code(code) => {
439 let chip = format!(" {} ", colors.md_code.fg(code.trim()));
440 frames
441 .last_mut()
442 .expect("root inline frame")
443 .text
444 .push_str(&colors.md_code_bg.bg(&chip));
445 }
446 Event::InlineMath(value) => frames
447 .last_mut()
448 .expect("root inline frame")
449 .text
450 .push_str(&format!("${value}$")),
451 Event::DisplayMath(value) => frames
452 .last_mut()
453 .expect("root inline frame")
454 .text
455 .push_str(&format!("$${value}$$")),
456 Event::Html(value) | Event::InlineHtml(value) => frames
457 .last_mut()
458 .expect("root inline frame")
459 .text
460 .push_str(&value),
461 Event::FootnoteReference(label) => frames
462 .last_mut()
463 .expect("root inline frame")
464 .text
465 .push_str(&format!("[^{label}]")),
466 Event::SoftBreak => frames.last_mut().expect("root inline frame").text.push(' '),
467 Event::HardBreak => frames
468 .last_mut()
469 .expect("root inline frame")
470 .text
471 .push('\n'),
472 Event::TaskListMarker(checked) => frames
473 .last_mut()
474 .expect("root inline frame")
475 .text
476 .push(if checked { '☒' } else { '☐' }),
477 Event::Rule | Event::Start(_) | Event::End(_) => {}
478 }
479 }
480
481 while frames.len() > 1 {
482 close_inline_frame(&mut frames, &colors);
483 }
484 frames.pop().expect("root inline frame").text
485 }
486}
487
488#[derive(Debug)]
489enum InlineKind {
490 Root,
491 Bold,
492 Italic,
493 Strike,
494 Link(String),
495 Image(String),
496}
497
498#[derive(Debug)]
499struct InlineFrame {
500 kind: InlineKind,
501 text: String,
502}
503
504impl InlineFrame {
505 fn root() -> Self {
506 Self::new(InlineKind::Root)
507 }
508
509 fn new(kind: InlineKind) -> Self {
510 Self {
511 kind,
512 text: String::new(),
513 }
514 }
515}
516
517fn close_inline_frame(frames: &mut Vec<InlineFrame>, colors: &crate::theme::ThemeColors) {
518 let Some(frame) = (frames.len() > 1).then(|| frames.pop()).flatten() else {
519 return;
520 };
521 let rendered = match frame.kind {
522 InlineKind::Root => frame.text,
523 InlineKind::Bold => bold(&frame.text),
524 InlineKind::Italic => italic(&frame.text),
525 InlineKind::Strike => format!("\x1b[9m{}\x1b[29m", frame.text),
526 InlineKind::Link(url) => {
527 let label = colors.md_link.fg(&underline(&frame.text));
528 if crate::ansi::strip_ansi(&frame.text) == url {
529 label
530 } else {
531 format!("{label}{}", colors.md_link_url.fg(&format!(" ({url})")))
532 }
533 }
534 InlineKind::Image(url) => {
535 let alt = if frame.text.is_empty() {
536 "image".to_string()
537 } else {
538 frame.text
539 };
540 format!("{alt}{}", colors.md_link_url.fg(&format!(" ({url})")))
541 }
542 };
543 frames
544 .last_mut()
545 .expect("root inline frame")
546 .text
547 .push_str(&rendered);
548}
549
550enum ListMarker {
551 Bullet,
552 Ordered(String),
553 Task(char),
554}
555
556struct ListItem<'a> {
557 depth: usize,
558 marker: ListMarker,
559 body: &'a str,
560}
561
562fn parse_list_item(line: &str) -> Option<ListItem<'_>> {
566 let leading = line.len() - line.trim_start_matches([' ', '\t']).len();
567 let content = &line[leading..];
568 let depth = line[..leading]
569 .chars()
570 .fold(0usize, |width, ch| width + if ch == '\t' { 4 } else { 1 })
571 / 2;
572
573 let (marker, body) = if let Some(rest) = content
574 .strip_prefix("- ")
575 .or_else(|| content.strip_prefix("* "))
576 .or_else(|| content.strip_prefix("+ "))
577 {
578 if let Some(task_body) = rest.strip_prefix("[ ]") {
579 (ListMarker::Task('☐'), task_body.trim_start())
580 } else if let Some(task_body) = rest
581 .strip_prefix("[x]")
582 .or_else(|| rest.strip_prefix("[X]"))
583 {
584 (ListMarker::Task('☒'), task_body.trim_start())
585 } else {
586 (ListMarker::Bullet, rest)
587 }
588 } else {
589 let marker_end = content
590 .bytes()
591 .take_while(|byte| byte.is_ascii_digit())
592 .count();
593 if marker_end == 0 {
594 return None;
595 }
596 let marker_char = content.as_bytes().get(marker_end)?;
597 if !matches!(marker_char, b'.' | b')')
598 || !content
599 .as_bytes()
600 .get(marker_end + 1)
601 .is_some_and(u8::is_ascii_whitespace)
602 {
603 return None;
604 }
605 (
606 ListMarker::Ordered(content[..marker_end + 1].to_string()),
607 content[marker_end + 2..].trim_start(),
608 )
609 };
610
611 Some(ListItem {
612 depth,
613 marker,
614 body,
615 })
616}
617
618fn parse_blockquote(line: &str) -> Option<(usize, &str)> {
621 let mut body = line.trim_start_matches([' ', '\t']);
622 let mut depth = 0;
623 while let Some(rest) = body.strip_prefix('>') {
624 depth += 1;
625 body = rest.strip_prefix(' ').unwrap_or(rest);
626 }
627 (depth > 0).then_some((depth, body))
628}
629
630fn fence_info(line: &str) -> Option<(char, String)> {
633 let trimmed = line.trim_start();
634 if trimmed.starts_with("```") {
635 Some(('`', trimmed[3..].trim().to_string()))
636 } else if trimmed.starts_with("~~~") {
637 Some(('~', trimmed[3..].trim().to_string()))
638 } else {
639 None
640 }
641}
642
643fn push_code_line(
645 line: &str,
646 pad: &str,
647 indent: &str,
648 cwidth: usize,
649 out: &mut Vec<String>,
650 highlighter: Option<&mut crate::syntax_highlight::Highlighter>,
651) {
652 let colors = theme().colors;
653 let panel_width = cwidth.saturating_sub(visible_width(indent)).max(1);
656 let body_width = panel_width.saturating_sub(2).max(1);
657 let expanded = line.replace('\t', " ");
658 let clipped = truncate_to_width(&expanded, body_width, "…");
659 let styled = match highlighter {
661 Some(h) => h.highlight(&clipped, &colors),
662 None => colors.md_code_block.fg(&clipped),
663 };
664 let body = format!(" {styled}");
665 let body =
666 apply_background_to_line(&body, panel_width, |text| colors.md_code_block_bg.bg(text));
667 out.push(format!("{pad}{indent}{body}"));
668}
669
670fn is_partial_fence(line: &str) -> bool {
675 let t = line.trim();
676 if t.is_empty() || t.len() > 2 {
677 return false;
678 }
679 t.chars().all(|c| c == '`' || c == '~')
680}
681
682fn strip_header<'a>(line: &'a str, hashes: &str) -> Option<&'a str> {
684 if line.starts_with(hashes) {
685 let rest = &line[hashes.len()..];
686 if let Some(stripped) = rest.strip_prefix(' ') {
687 return Some(stripped);
688 }
689 }
690 None
691}
692
693fn is_table_row(line: &str) -> bool {
695 let t = line.trim();
696 t.starts_with('|') && t.ends_with('|') && t.len() > 1
697}
698
699fn is_table_separator(maybe: Option<&&str>) -> bool {
701 match maybe {
702 Some(line) => {
703 let t = line.trim();
704 if !t.contains('|') {
705 return false;
706 }
707 let cell = |s: &str| {
708 s.chars()
709 .all(|c| c == '-' || c == ':' || c == ' ' || c == '\t')
710 && s.contains('-')
711 };
712 let inner = t.trim_matches('|');
713 inner.split('|').all(cell)
714 }
715 None => false,
716 }
717}
718
719fn render_table<F>(rows: &[&str], pad: &str, cwidth: usize, out: &mut Vec<String>, render_inline: F)
721where
722 F: Fn(&str) -> String,
723{
724 let parse = |row: &str| -> Vec<String> {
726 split_table_cells(row)
727 .into_iter()
728 .map(|cell| render_inline(cell.trim()))
729 .collect()
730 };
731 if rows.len() < 2 {
732 return;
733 }
734 let header = parse(rows[0]);
735 let _separator = parse(rows[1]);
736 let body: Vec<Vec<String>> = rows[2..].iter().map(|r| parse(r)).collect();
737 let ncols = header.len();
738 if ncols == 0 {
739 return;
740 }
741
742 let mut col_w: Vec<usize> = (0..ncols)
744 .map(|c| {
745 std::iter::once(&header)
746 .chain(body.iter())
747 .map(|r| r.get(c).map(|s| visible_width(s)).unwrap_or(0))
748 .max()
749 .unwrap_or(0)
750 })
751 .collect();
752
753 let overhead = 3 * (ncols - 1) + 4; let budget = cwidth.saturating_sub(overhead);
757 let total: usize = col_w.iter().sum();
758 if total > budget {
759 let scale = budget as f64 / total as f64;
761 for w in col_w.iter_mut() {
762 *w = (*w as f64 * scale).round() as usize;
763 if *w < 1 {
764 *w = 1;
765 }
766 }
767 }
768
769 let join = |cells: &[String]| {
770 cells
771 .iter()
772 .enumerate()
773 .map(|(i, c)| {
774 let w = col_w.get(i).copied().unwrap_or(0);
775 let cell = truncate_to_width(c, w, "…");
776 pad_cell(&cell, w)
777 })
778 .collect::<Vec<_>>()
779 .join("│")
780 };
781
782 let border = |left: &str, mid: &str, joiner: &str, right: &str| -> String {
783 let segs: Vec<String> = col_w.iter().map(|w| mid.repeat(*w)).collect();
784 format!("{left}{}{right}", segs.join(joiner))
785 };
786
787 out.push(format!("{pad}{}", border("┌", "─", "┬", "┐")));
788 out.push(format!("{pad}│{}│", join(&header)));
789 out.push(format!("{pad}{}", border("├", "─", "┼", "┤")));
790 for (row_index, row) in body.iter().enumerate() {
791 let cells: Vec<String> = (0..ncols)
792 .map(|c| row.get(c).cloned().unwrap_or_default())
793 .collect();
794 out.push(format!("{pad}│{}│", join(&cells)));
795 if row_index + 1 < body.len() {
800 out.push(format!("{pad}{}", border("├", "─", "┼", "┤")));
801 }
802 }
803 out.push(format!("{pad}{}", border("└", "─", "┴", "┘")));
804}
805
806fn split_table_cells(row: &str) -> Vec<String> {
809 let inner = row.trim().trim_matches('|');
810 let mut cells = vec![String::new()];
811 let mut escaped = false;
812 let mut code_ticks = 0usize;
813
814 for ch in inner.chars() {
815 if escaped {
816 cells.last_mut().expect("table has one cell").push(ch);
817 escaped = false;
818 } else if ch == '\\' {
819 cells.last_mut().expect("table has one cell").push(ch);
820 escaped = true;
821 } else if ch == '`' {
822 code_ticks ^= 1;
823 cells.last_mut().expect("table has one cell").push(ch);
824 } else if ch == '|' && code_ticks == 0 {
825 cells.push(String::new());
826 } else {
827 cells.last_mut().expect("table has one cell").push(ch);
828 }
829 }
830 cells
831}
832
833fn pad_cell(cell: &str, w: usize) -> String {
835 let vw = visible_width(cell);
836 if vw >= w {
837 cell.to_string()
838 } else {
839 format!("{cell}{}", " ".repeat(w - vw))
840 }
841}
842
843fn push_wrapped(pad: &str, rendered: &str, cwidth: usize, out: &mut Vec<String>) {
846 if cwidth == 0 {
847 out.push(rendered.to_string());
848 return;
849 }
850 let wrapped = wrap_text_with_ansi(rendered, cwidth);
854 if wrapped.is_empty() {
855 out.push(String::new());
856 return;
857 }
858 for sub in wrapped {
859 out.push(format!("{pad}{sub}"));
860 }
861}
862
863fn push_wrapped_with_prefix(
868 pad: &str,
869 prefix: &str,
870 rendered: &str,
871 cwidth: usize,
872 out: &mut Vec<String>,
873) {
874 let prefix_width = visible_width(prefix);
875 let body_width = cwidth.saturating_sub(prefix_width).max(1);
876 let wrapped = wrap_text_with_ansi(rendered, body_width);
877 if wrapped.is_empty() {
878 out.push(format!("{pad}{prefix}"));
879 return;
880 }
881 let continuation = " ".repeat(prefix_width);
882 for (index, line) in wrapped.iter().enumerate() {
883 let line_prefix = if index == 0 { prefix } else { &continuation };
884 let body = if index == 0 {
888 line.as_str()
889 } else {
890 line.trim_start()
891 };
892 out.push(format!("{pad}{line_prefix}{body}"));
893 }
894}
895
896impl Component for Markdown {
897 fn render(&self, width: usize) -> Vec<String> {
898 let content = self.content.lock().map(|c| c.clone()).unwrap_or_default();
899 let theme_revision = crate::theme::theme_revision();
900
901 if let Ok(cache) = self.cache.lock() {
902 if let Some(cached) = cache.as_ref() {
903 if cached.width == width
904 && cached.content == content
905 && cached.theme_revision == theme_revision
906 {
907 return cached.lines.clone();
908 }
909 }
910 }
911
912 let lines = self.render_markdown(width);
913 if let Ok(mut cache) = self.cache.lock() {
914 *cache = Some(MarkdownCache {
915 content,
916 width,
917 theme_revision,
918 lines: lines.clone(),
919 });
920 }
921 lines
922 }
923
924 fn invalidate(&self) {
925 self.clear_cache();
926 }
927
928 fn as_any(&self) -> &dyn Any {
929 self
930 }
931}
932
933#[cfg(test)]
934mod tests {
935 use super::*;
936
937 #[test]
938 fn test_markdown_headers() {
939 let md = Markdown::new("# H1\n## H2\n### H3\n#### H4\n##### H5\n###### H6", 0, 0);
940 let lines = md.render(80);
941 let plain: Vec<String> = lines
942 .iter()
943 .map(|line| crate::ansi::strip_ansi(line))
944 .collect();
945 assert_eq!(plain, vec!["H1", "H2", "H3", "H4", "H5", "H6"]);
946 }
947
948 #[test]
949 fn test_commonmark_inline_parser_handles_nested_spans_and_images() {
950 let md = Markdown::new(
951 "This is ***nested***, ``a ` b``, <https://example.com>, ",
952 0,
953 0,
954 );
955 let rendered = md.render(120).join("\n");
956 let plain = crate::ansi::strip_ansi(&rendered);
957
958 assert_eq!(
959 plain,
960 "This is nested, a ` b , https://example.com, plot (plot.png)"
961 );
962 assert!(rendered.contains("\x1b[1m"), "nested strong style missing");
963 assert!(rendered.contains("\x1b[3m"), "nested emphasis missing");
964 assert!(
965 rendered.contains("\x1b[48;2;48;52;59m"),
966 "multi-backtick code span should use the code chip"
967 );
968 }
969
970 #[test]
971 fn test_markdown_inline() {
972 let md = Markdown::new("This is **bold** and *italic* text", 0, 0);
973 let lines = md.render(80);
974 assert!(!lines[0].is_empty());
975 }
976
977 #[test]
978 fn test_markdown_code() {
979 let md = Markdown::new("Use `code` here", 0, 0);
980 let lines = md.render(80);
981 assert!(!lines[0].is_empty());
982 assert!(
983 lines[0].contains("\x1b[48;2;48;52;59m"),
984 "inline code should use the theme chip background"
985 );
986 }
987
988 #[test]
989 fn test_markdown_no_panic_on_wide_cjk() {
990 let md = Markdown::new("**你好世界**这是一段很长的中文内容需要自动换行", 1, 0);
993 let lines = md.render(10);
994 assert!(lines.len() > 1);
996 }
997
998 #[test]
999 fn test_markdown_wraps_long_line() {
1000 let long = "a".repeat(100);
1001 let md = Markdown::new(long, 0, 0);
1002 let lines = md.render(20);
1003 assert!(lines.len() > 1, "a 100-col line at width 20 should wrap");
1004 }
1005
1006 #[test]
1007 fn test_markdown_code_block_has_background_without_frame() {
1008 let md = Markdown::new("```rust\nlet x = 1;\n```", 0, 0);
1009 let joined = md.render(80).join("\n");
1010 let plain = crate::ansi::strip_ansi(&joined);
1011 assert!(!plain.contains("rust"), "language label leaked: {plain}");
1012 assert!(plain.contains(" let x = 1;"), "missing code body: {plain}");
1013 assert!(
1014 !plain.contains(['╭', '╰', '│']),
1015 "code block must not draw an outer frame: {plain}"
1016 );
1017 assert!(
1018 joined.contains("\x1b[48;2;40;44;52m"),
1019 "code panel background missing: {joined:?}"
1020 );
1021 assert!(
1022 !plain.contains("```"),
1023 "literal fences should be hidden: {plain}"
1024 );
1025 }
1026
1027 #[test]
1028 fn code_fence_language_metadata_is_not_rendered() {
1029 let md = Markdown::new(
1030 "```text\nplain block\n```\n\n```bash\necho hello\n```",
1031 0,
1032 0,
1033 );
1034 let plain = crate::ansi::strip_ansi(&md.render(80).join("\n"));
1035 assert!(plain.contains("plain block"));
1036 assert!(plain.contains("echo hello"));
1037 assert!(!plain.contains("text"), "text label leaked: {plain}");
1038 assert!(!plain.contains("bash"), "bash label leaked: {plain}");
1039 }
1040
1041 #[test]
1042 fn test_mermaid_fence_uses_diagram_renderer() {
1043 let md = Markdown::new("```mermaid\nflowchart LR\nA[Start] --> B[End]\n```", 0, 0);
1044 let plain = crate::ansi::strip_ansi(&md.render(80).join("\n"));
1045 assert!(plain.contains("mermaid"));
1046 assert!(
1047 plain.contains("Start ──▶ End"),
1048 "diagram body missing: {plain}"
1049 );
1050 assert!(
1051 !plain.contains("flowchart LR"),
1052 "raw mermaid header leaked: {plain}"
1053 );
1054 }
1055
1056 #[test]
1057 fn test_markdown_padding_applies_to_first_and_wrapped_lines() {
1058 let md = Markdown::new("abcdefgh", 2, 0);
1059 let lines = md.render(6);
1060 assert_eq!(lines, vec![" ab", " cd", " ef", " gh"]);
1061 }
1062
1063 #[test]
1064 fn test_markdown_code_expands_tabs_and_clips() {
1065 let md = Markdown::new("```\n\tlet value = 123456;\n```", 0, 0);
1066 let lines = md.render(14);
1067 let plain: Vec<String> = lines
1068 .iter()
1069 .map(|line| crate::ansi::strip_ansi(line))
1070 .collect();
1071 assert!(
1072 plain[0].starts_with(" let"),
1073 "tab/indent lost: {plain:?}"
1074 );
1075 assert!(
1076 plain[0].contains('…'),
1077 "long code line should clip: {plain:?}"
1078 );
1079 assert!(plain.iter().all(|line| visible_width(line) <= 14));
1080 }
1081
1082 #[test]
1083 fn test_markdown_task_list_checkbox() {
1084 let md = Markdown::new("- [x] done\n- [ ] todo", 0, 0);
1085 let joined = md.render(80).join("\n");
1086 assert!(joined.contains('☒'), "checked marker missing");
1087 assert!(joined.contains('☐'), "unchecked marker missing");
1088 }
1089
1090 #[test]
1091 fn test_markdown_table_renders_borders() {
1092 let md = Markdown::new("| a | b |\n|---|---|\n| 1 | 2 |", 0, 0);
1093 let joined = md.render(80).join("\n");
1094 assert!(joined.contains('┌'), "missing top-left");
1095 assert!(joined.contains('┬'), "missing top-mid");
1096 assert!(joined.contains('┐'), "missing top-right");
1097 assert!(joined.contains('└'), "missing bottom-left");
1098 }
1099
1100 #[test]
1101 fn test_markdown_table_has_internal_horizontal_lines() {
1102 let md = Markdown::new(
1103 "| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n| 5 | 6 |",
1104 0,
1105 0,
1106 );
1107 let plain = crate::ansi::strip_ansi(&md.render(80).join("\n"));
1108 assert_eq!(
1109 plain.matches('├').count(),
1110 3,
1111 "missing internal row lines: {plain}"
1112 );
1113 assert_eq!(
1114 plain.matches('┼').count(),
1115 3,
1116 "missing internal separators: {plain}"
1117 );
1118 }
1119
1120 #[test]
1121 fn test_markdown_double_underscore_is_bold() {
1122 let md = Markdown::new("__strong__", 0, 0);
1123 let joined = md.render(80).join("\n");
1124 assert!(joined.contains("\x1b[1m"), "`__` should render bold");
1126 assert!(
1127 !joined.contains("\x1b[4m"),
1128 "`__` should NOT render underline"
1129 );
1130 }
1131
1132 #[test]
1133 fn test_markdown_hr_capped() {
1134 let md = Markdown::new("---", 0, 0);
1135 let lines = md.render(200);
1136 assert_eq!(visible_width(&lines[0]), 80);
1138 }
1139
1140 #[test]
1141 fn test_markdown_h4_does_not_fall_through() {
1142 let md = Markdown::new("#### nested", 0, 0);
1143 let joined = md.render(80).join("\n");
1144 assert!(joined.contains("\x1b[1m"), "h4 should be bold");
1145 }
1146
1147 #[test]
1148 fn test_markdown_nested_lists_keep_their_text_indent_when_wrapped() {
1149 let md = Markdown::new(
1150 "- parent item that wraps over several terminal columns\n - child item\n child continuation",
1151 0,
1152 0,
1153 );
1154 let plain: Vec<String> = md
1155 .render(20)
1156 .iter()
1157 .map(|line| crate::ansi::strip_ansi(line))
1158 .collect();
1159
1160 assert!(
1161 plain[0].starts_with(" • "),
1162 "root marker missing: {plain:?}"
1163 );
1164 assert!(
1165 plain.iter().any(|line| line.starts_with(" wraps")),
1166 "wrapped root text should align after its marker: {plain:?}"
1167 );
1168 assert!(
1169 plain.iter().any(|line| line.starts_with(" • child")),
1170 "nested marker missing: {plain:?}"
1171 );
1172 assert!(
1173 plain.iter().any(|line| line.starts_with(" child")),
1174 "continuation should align after nested marker: {plain:?}"
1175 );
1176 }
1177
1178 #[test]
1179 fn test_markdown_nested_blockquote_and_unclosed_inline_are_literal() {
1180 let md = Markdown::new(
1181 "> > nested quote\ntext with *an unfinished emphasis and `unfinished code",
1182 0,
1183 0,
1184 );
1185 let plain: Vec<String> = md
1186 .render(80)
1187 .iter()
1188 .map(|line| crate::ansi::strip_ansi(line))
1189 .collect();
1190 assert!(plain[0].starts_with(" │ │ nested quote"), "{plain:?}");
1191 assert_eq!(
1192 plain[1],
1193 "text with *an unfinished emphasis and `unfinished code"
1194 );
1195 }
1196
1197 #[test]
1198 fn test_markdown_table_preserves_pipes_in_inline_code() {
1199 let md = Markdown::new("| Name | Value |\n|---|---|\n| **bold** | `a|b` |", 0, 0);
1200 let plain = crate::ansi::strip_ansi(&md.render(80).join("\n"));
1201 assert_eq!(
1202 plain.matches('┬').count(),
1203 1,
1204 "table columns split incorrectly: {plain}"
1205 );
1206 assert!(plain.contains("bold"), "inline formatting lost: {plain}");
1207 assert!(
1208 plain.contains("a|b"),
1209 "code pipe split a table cell: {plain}"
1210 );
1211 }
1212
1213 #[test]
1214 fn test_base_style_reopened_after_inline_code_reset() {
1215 let base = "\x1b[3m\x1b[38;5;244m";
1219 let md = Markdown::new("think `code` after", 0, 0).with_base_style(base);
1220 let rendered = md.render(80).join("\n");
1221 let after = rendered
1222 .split("code")
1223 .nth(1)
1224 .expect("inline code not rendered");
1225 assert!(
1226 after.contains("\x1b[38;5;244m"),
1227 "base style not reopened after inline code: {rendered:?}"
1228 );
1229 assert!(
1230 rendered.ends_with("\x1b[0m"),
1231 "rendered line must be reset-terminated: {rendered:?}"
1232 );
1233 }
1234
1235 #[test]
1236 fn test_base_style_applies_to_every_line() {
1237 let base = "\x1b[3m\x1b[38;5;244m";
1240 let md = Markdown::new("first thought\n\nsecond thought", 0, 0).with_base_style(base);
1241 let lines = md.render(80);
1242 for line in lines.iter().filter(|l| !l.is_empty()) {
1243 assert!(
1244 line.contains("\x1b[38;5;244m"),
1245 "base style missing on line: {line:?}"
1246 );
1247 }
1248 }
1249}