1#![allow(clippy::type_complexity, clippy::arc_with_non_send_sync)]
2
3use std::cell::RefCell;
4use std::sync::Arc;
5
6use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
7
8use crate::tui::Component;
9use crate::tui::util::{apply_background_to_line, visible_width, wrap_text_with_ansi};
10
11pub type StyleFn = Arc<dyn Fn(&str) -> String>;
13pub type HighlightFn = Arc<dyn Fn(&str, Option<&str>) -> Vec<String>>;
15
16pub struct MarkdownTheme {
21 pub heading: StyleFn,
22 pub link: StyleFn,
23 pub link_url: StyleFn,
24 pub code: StyleFn,
25 pub code_block: StyleFn,
26 pub code_block_border: StyleFn,
27 pub quote: StyleFn,
28 pub quote_border: StyleFn,
29 pub hr: StyleFn,
30 pub list_bullet: StyleFn,
31 pub bold: StyleFn,
32 pub italic: StyleFn,
33 pub strikethrough: StyleFn,
34 pub underline: StyleFn,
35 pub highlight_code: Option<HighlightFn>,
37 pub code_block_indent: String,
39}
40
41impl MarkdownTheme {
42 #[allow(clippy::too_many_arguments)]
43 pub fn new(
44 heading: StyleFn,
45 link: StyleFn,
46 link_url: StyleFn,
47 code: StyleFn,
48 code_block: StyleFn,
49 code_block_border: StyleFn,
50 quote: StyleFn,
51 quote_border: StyleFn,
52 hr: StyleFn,
53 list_bullet: StyleFn,
54 bold: StyleFn,
55 italic: StyleFn,
56 strikethrough: StyleFn,
57 underline: StyleFn,
58 ) -> Self {
59 Self {
60 heading,
61 link,
62 link_url,
63 code,
64 code_block,
65 code_block_border,
66 quote,
67 quote_border,
68 hr,
69 list_bullet,
70 bold,
71 italic,
72 strikethrough,
73 underline,
74 highlight_code: None,
75 code_block_indent: " ".to_string(),
76 }
77 }
78}
79
80pub struct DefaultTextStyle {
85 pub color: Option<StyleFn>,
87 pub bg_color: Option<StyleFn>,
89 pub bold: bool,
90 pub italic: bool,
91 pub strikethrough: bool,
92 pub underline: bool,
93}
94
95#[derive(Clone, Default)]
98pub struct MarkdownOptions {
99 pub preserve_ordered_list_markers: bool,
101}
102
103struct InlineCtx {
108 apply_text: Arc<dyn Fn(&str) -> String>,
110 style_prefix: String,
113}
114
115impl InlineCtx {
116 fn new(apply_text: Arc<dyn Fn(&str) -> String>) -> Self {
117 let prefix = get_style_prefix(&*apply_text);
118 Self {
119 apply_text,
120 style_prefix: prefix,
121 }
122 }
123}
124
125fn get_style_prefix(style_fn: &dyn Fn(&str) -> String) -> String {
128 const SENTINEL: char = '\0';
129 let styled = style_fn(&SENTINEL.to_string());
130 styled
131 .find(SENTINEL)
132 .map(|i| styled[..i].to_string())
133 .unwrap_or_default()
134}
135
136fn hyperlinks_supported() -> bool {
139 if let Ok(prog) = std::env::var("TERM_PROGRAM")
141 && (prog == "iTerm.app" || prog == "kitty" || prog == "WezTerm" || prog == "vscode")
142 {
143 return true;
144 }
145 if let Ok(term) = std::env::var("TERM")
147 && term.contains("kitty")
148 {
149 return true;
150 }
151 #[cfg(windows)]
153 {
154 if let Ok(prog) = std::env::var("WT_SESSION") {
155 let _ = prog;
156 return true;
157 }
158 }
159 false
160}
161
162fn hyperlink(text: &str, url: &str) -> String {
165 format!("\x1b]8;;{}\x07{}\x1b]8;;\x07", url, text)
166}
167
168pub struct Markdown {
175 text: String,
176 padding_x: usize,
177 padding_y: usize,
178 theme: MarkdownTheme,
179 default_text_style: Option<DefaultTextStyle>,
180 #[allow(dead_code)]
181 options: MarkdownOptions,
182
183 cached_text: RefCell<Option<String>>,
185 cached_width: RefCell<Option<usize>>,
186 cached_lines: RefCell<Vec<String>>,
187}
188
189impl Markdown {
190 #[allow(clippy::too_many_arguments)]
191 pub fn new(
192 text: impl Into<String>,
193 padding_x: usize,
194 padding_y: usize,
195 theme: MarkdownTheme,
196 default_text_style: Option<DefaultTextStyle>,
197 options: Option<MarkdownOptions>,
198 ) -> Self {
199 Self {
200 text: text.into(),
201 padding_x,
202 padding_y,
203 theme,
204 default_text_style,
205 options: options.unwrap_or_default(),
206 cached_text: RefCell::new(None),
207 cached_width: RefCell::new(None),
208 cached_lines: RefCell::new(Vec::new()),
209 }
210 }
211
212 pub fn set_text(&mut self, text: impl Into<String>) {
213 self.text = text.into();
214 self.invalidate();
215 }
216
217 fn build_default_ctx(&self) -> InlineCtx {
218 InlineCtx::new(self.build_default_apply_fn())
219 }
220
221 fn build_default_apply_fn(&self) -> Arc<dyn Fn(&str) -> String> {
223 let style = &self.default_text_style;
224 let theme = &self.theme;
225
226 let color: Option<StyleFn> = style.as_ref().and_then(|s| s.color.clone());
228 let bold = style.as_ref().map(|s| s.bold).unwrap_or(false);
229 let italic = style.as_ref().map(|s| s.italic).unwrap_or(false);
230 let strikethrough = style.as_ref().map(|s| s.strikethrough).unwrap_or(false);
231 let underline = style.as_ref().map(|s| s.underline).unwrap_or(false);
232 let theme_bold = theme.bold.clone();
233 let theme_italic = theme.italic.clone();
234 let theme_strikethrough = theme.strikethrough.clone();
235 let theme_underline = theme.underline.clone();
236
237 Arc::new(move |text: &str| {
238 let mut styled = text.to_string();
239 if let Some(ref color_fn) = color {
240 styled = color_fn(&styled);
241 }
242 if bold {
243 styled = theme_bold(&styled);
244 }
245 if italic {
246 styled = theme_italic(&styled);
247 }
248 if strikethrough {
249 styled = theme_strikethrough(&styled);
250 }
251 if underline {
252 styled = theme_underline(&styled);
253 }
254 styled
255 })
256 }
257
258 fn heading_ctx(&self, level: HeadingLevel) -> InlineCtx {
260 let theme_heading = self.theme.heading.clone();
261 let theme_bold = self.theme.bold.clone();
262 let theme_underline = self.theme.underline.clone();
263
264 let style_fn: Arc<dyn Fn(&str) -> String> = match level {
265 HeadingLevel::H1 => {
266 Arc::new(move |text: &str| theme_heading(&theme_bold(&theme_underline(text))))
267 }
268 _ => Arc::new(move |text: &str| theme_heading(&theme_bold(text))),
269 };
270 InlineCtx::new(style_fn)
271 }
272
273 fn quote_ctx(&self) -> InlineCtx {
275 let theme_quote = self.theme.quote.clone();
276 let theme_italic = self.theme.italic.clone();
277
278 let style_fn: Arc<dyn Fn(&str) -> String> =
279 Arc::new(move |text: &str| theme_quote(&theme_italic(text)));
280 InlineCtx::new(style_fn)
281 }
282}
283
284impl Component for Markdown {
285 fn render(&self, width: usize) -> Vec<String> {
286 if self.cached_text.borrow().as_deref() == Some(&self.text)
288 && *self.cached_width.borrow() == Some(width)
289 {
290 return self.cached_lines.borrow().clone();
291 }
292
293 if self.text.is_empty() || self.text.trim().is_empty() {
295 let result: Vec<String> = Vec::new();
296 *self.cached_text.borrow_mut() = Some(self.text.clone());
297 *self.cached_width.borrow_mut() = Some(width);
298 *self.cached_lines.borrow_mut() = result.clone();
299 return result;
300 }
301
302 let content_width = width.saturating_sub(2 * self.padding_x).max(1);
304
305 let normalized = self.text.replace('\t', " ");
307
308 let md_options = Options::ENABLE_STRIKETHROUGH
310 | Options::ENABLE_TABLES
311 | Options::ENABLE_TASKLISTS
312 | Options::ENABLE_HEADING_ATTRIBUTES
313 | Options::ENABLE_GFM;
314 let parser = Parser::new_ext(&normalized, md_options);
315 let events: Vec<Event> = parser.collect();
316
317 let rendered = self.render_document(&events, content_width);
319
320 let mut wrapped: Vec<String> = Vec::new();
322 for line in &rendered {
323 for wl in wrap_text_with_ansi(line, content_width) {
324 wrapped.push(wl);
325 }
326 }
327
328 let left_margin = " ".repeat(self.padding_x);
330 let right_margin = " ".repeat(self.padding_x);
331 let bg_fn = self
332 .default_text_style
333 .as_ref()
334 .and_then(|s| s.bg_color.clone());
335
336 let mut content_lines: Vec<String> = Vec::new();
337 for line in &wrapped {
338 let line_with_margins = format!("{}{}{}", left_margin, line, right_margin);
339 if let Some(ref bg) = bg_fn {
340 content_lines.push(apply_background_to_line(
341 &line_with_margins,
342 width,
343 bg.as_ref(),
344 ));
345 } else {
346 let visible = visible_width(&line_with_margins);
347 if visible < width {
348 content_lines.push(format!(
349 "{}{}",
350 line_with_margins,
351 " ".repeat(width - visible)
352 ));
353 } else {
354 content_lines.push(line_with_margins);
355 }
356 }
357 }
358
359 let empty_line = " ".repeat(width);
360 let empty_bg = bg_fn
361 .as_ref()
362 .map(|bg| bg(&empty_line))
363 .unwrap_or_else(|| empty_line.clone());
364
365 let mut result = Vec::new();
366 for _ in 0..self.padding_y {
367 result.push(empty_bg.clone());
368 }
369 result.extend(content_lines);
370 for _ in 0..self.padding_y {
371 result.push(empty_bg.clone());
372 }
373
374 *self.cached_text.borrow_mut() = Some(self.text.clone());
376 *self.cached_width.borrow_mut() = Some(width);
377 *self.cached_lines.borrow_mut() = result.clone();
378
379 if result.is_empty() {
380 vec![String::new()]
381 } else {
382 result
383 }
384 }
385
386 fn invalidate(&mut self) {
387 *self.cached_text.borrow_mut() = None;
388 *self.cached_width.borrow_mut() = None;
389 self.cached_lines.borrow_mut().clear();
390 }
391}
392
393impl Markdown {
396 fn render_document(&self, events: &[Event], width: usize) -> Vec<String> {
398 let mut lines: Vec<String> = Vec::new();
399 let mut pos = 0;
400
401 while pos < events.len() {
402 match &events[pos] {
403 Event::Start(tag) => {
404 pos += 1;
405 let block_lines = self.render_block(events, &mut pos, tag, width, false, 0);
406 if !block_lines.is_empty() {
407 lines.extend(block_lines);
408 }
409 }
410 Event::End(_) => {
411 pos += 1;
412 }
413 Event::Rule => {
414 pos += 1;
415 lines.push((self.theme.hr)(&"─".repeat(width.min(80))));
416 if pos < events.len() && !matches!(events[pos], Event::Start(Tag::Paragraph)) {
418 lines.push(String::new());
419 }
420 }
421 Event::SoftBreak | Event::HardBreak => {
422 pos += 1;
423 }
424 Event::Text(text) => {
425 pos += 1;
426 let ctx = self.build_default_ctx();
427 lines.push((ctx.apply_text)(text));
428 }
429 _ => {
430 pos += 1;
431 }
432 }
433 }
434
435 lines
436 }
437
438 fn render_block(
442 &self,
443 events: &[Event],
444 pos: &mut usize,
445 tag: &Tag,
446 width: usize,
447 inside_quote: bool,
448 list_depth: usize,
449 ) -> Vec<String> {
450 match tag {
451 Tag::Paragraph => {
452 let content =
453 self.render_inline(events, pos, TagEnd::Paragraph, &self.build_default_ctx());
454 let mut lines = Vec::new();
455 if !content.is_empty() {
456 lines.push(content);
457 }
458 if *pos < events.len() {
460 let next_is_list = matches!(
461 &events[*pos],
462 Event::Start(Tag::List(_)) | Event::End(TagEnd::List(_))
463 );
464 if !next_is_list {
465 lines.push(String::new());
466 }
467 }
468 lines
469 }
470
471 Tag::Heading { level, .. } => {
472 let ctx = self.heading_ctx(*level);
473 let mut content = self.render_inline(events, pos, TagEnd::Heading(*level), &ctx);
474
475 if *level >= HeadingLevel::H3 {
477 let prefix_marker = format!("{} ", "#".repeat(level_to_usize(*level)));
478 content = format!("{}{}", (ctx.apply_text)(&prefix_marker), content);
479 }
480
481 let mut lines = vec![content];
482 if *pos < events.len() {
484 let next_is_para_or_space = matches!(
485 &events[*pos],
486 Event::Start(Tag::Paragraph)
487 | Event::Start(Tag::List(_))
488 | Event::End(TagEnd::List(_))
489 | Event::End(TagEnd::BlockQuote(None))
490 );
491 if !next_is_para_or_space && !inside_quote {
492 lines.push(String::new());
493 }
494 }
495 lines
496 }
497
498 Tag::BlockQuote(kind) => {
499 let quote_content_width = width.saturating_sub(2).max(1); let quote_ctx = self.quote_ctx();
502
503 let mut inner_lines: Vec<String> = Vec::new();
504 loop {
505 if *pos >= events.len() {
506 break;
507 }
508 match &events[*pos] {
509 Event::End(TagEnd::BlockQuote(k)) if *k == *kind => {
510 *pos += 1;
511 break;
512 }
513 Event::Start(inner_tag) => {
514 *pos += 1;
515 let block_lines = self.render_block(
516 events,
517 pos,
518 inner_tag,
519 quote_content_width,
520 true,
521 0,
522 );
523 inner_lines.extend(block_lines);
524 }
525 Event::End(_) => {
526 *pos += 1;
527 }
528 _ => {
529 let text = self.render_inline(
531 events,
532 pos,
533 TagEnd::BlockQuote(*kind),
534 "e_ctx,
535 );
536 if !text.is_empty() {
537 inner_lines.push(text);
538 }
539 }
540 }
541 }
542
543 while inner_lines.last().is_some_and(|l| l.is_empty()) {
545 inner_lines.pop();
546 }
547
548 let quote_style_prefix = get_style_prefix(&|s: &str| (quote_ctx.apply_text)(s));
550 let qborder = self.theme.quote_border.clone();
551
552 let mut result: Vec<String> = Vec::new();
553 for line in &inner_lines {
554 let restyled = if !quote_style_prefix.is_empty() {
555 line.replace("\x1b[0m", &format!("\x1b[0m{}", quote_style_prefix))
556 } else {
557 line.clone()
558 };
559 let styled = (quote_ctx.apply_text)(&restyled);
560 let wrapped = wrap_text_with_ansi(&styled, quote_content_width);
561 for wl in wrapped {
562 result.push(format!("{} {}", qborder("│"), wl));
563 }
564 }
565
566 if *pos < events.len() && !inside_quote {
568 let next_is_space_or_end = matches!(
569 &events[*pos],
570 Event::End(_) | Event::SoftBreak | Event::HardBreak
571 );
572 if !next_is_space_or_end {
573 result.push(String::new());
574 }
575 }
576 result
577 }
578
579 Tag::CodeBlock(kind) => {
580 let info = match kind {
581 CodeBlockKind::Fenced(info) => {
582 if info.is_empty() {
583 None
584 } else {
585 Some(info.as_ref())
586 }
587 }
588 CodeBlockKind::Indented => None,
589 };
590
591 let mut code_text = String::new();
593 loop {
594 if *pos >= events.len() {
595 break;
596 }
597 match &events[*pos] {
598 Event::End(TagEnd::CodeBlock) => {
599 *pos += 1;
600 break;
601 }
602 Event::Text(t) => {
603 code_text.push_str(t);
604 *pos += 1;
605 }
606 Event::SoftBreak | Event::HardBreak => {
607 code_text.push('\n');
608 *pos += 1;
609 }
610 _ => {
611 *pos += 1;
612 }
613 }
614 }
615
616 let indent = &self.theme.code_block_indent;
617 let border = self.theme.code_block_border.clone();
618 let code_fn = self.theme.code_block.clone();
619
620 let lang_label = info.unwrap_or("");
622 let mut lines = vec![border(&format!("```{}", lang_label))];
623
624 if let Some(ref highlight) = self.theme.highlight_code {
626 let hl_lines = highlight(&code_text, info);
627 for hl in hl_lines {
628 lines.push(format!("{}{}", indent, hl));
629 }
630 } else {
631 for code_line in code_text.split('\n') {
632 lines.push(format!("{}{}", indent, code_fn(code_line)));
633 }
634 }
635
636 lines.push(border("```"));
637
638 if *pos < events.len() {
640 let next_is_space = matches!(
641 &events[*pos],
642 Event::Start(Tag::Paragraph)
643 | Event::End(_)
644 | Event::SoftBreak
645 | Event::HardBreak
646 );
647 if !next_is_space {
648 lines.push(String::new());
649 }
650 }
651 lines
652 }
653
654 Tag::List(start) => self.render_list(events, pos, *start, width, list_depth),
655
656 Tag::Item => {
657 let mut depth = 1;
659 loop {
660 if *pos >= events.len() {
661 break;
662 }
663 match &events[*pos] {
664 Event::Start(Tag::Item) => {
665 depth += 1;
666 *pos += 1;
667 }
668 Event::End(TagEnd::Item) => {
669 depth -= 1;
670 *pos += 1;
671 if depth == 0 {
672 break;
673 }
674 }
675 Event::Start(_) => {
676 *pos += 1;
677 let _ = self.render_block(
679 events,
680 pos,
681 &Tag::Paragraph,
682 width,
683 false,
684 list_depth + 1,
685 );
686 }
687 _ => {
688 *pos += 1;
689 }
690 }
691 }
692 Vec::new()
693 }
694
695 Tag::Table(alignments) => self.render_table(events, pos, alignments, width),
696
697 Tag::HtmlBlock => {
698 let mut html_text = String::new();
700 loop {
701 if *pos >= events.len() {
702 break;
703 }
704 match &events[*pos] {
705 Event::End(TagEnd::HtmlBlock) => {
706 *pos += 1;
707 break;
708 }
709 Event::Text(t) | Event::Html(t) => {
710 html_text.push_str(t);
711 *pos += 1;
712 }
713 Event::SoftBreak | Event::HardBreak => {
714 html_text.push('\n');
715 *pos += 1;
716 }
717 _ => {
718 *pos += 1;
719 }
720 }
721 }
722 let ctx = self.build_default_ctx();
723 let mut lines = Vec::new();
724 for line in html_text.lines() {
725 let trimmed = line.trim();
726 if !trimmed.is_empty() {
727 lines.push((ctx.apply_text)(trimmed));
728 }
729 }
730 lines
731 }
732
733 Tag::TableHead | Tag::TableRow | Tag::TableCell => {
734 let end = tag.to_end();
736 loop {
737 if *pos >= events.len() {
738 break;
739 }
740 if matches!(&events[*pos], Event::End(e) if *e == end) {
741 *pos += 1;
742 break;
743 }
744 if matches!(tag, Tag::TableCell)
746 && let Event::Start(_) = &events[*pos]
747 {
748 *pos += 1;
750 continue;
751 }
752 *pos += 1;
753 }
754 Vec::new()
755 }
756
757 Tag::FootnoteDefinition(_)
758 | Tag::MetadataBlock(_)
759 | Tag::DefinitionList
760 | Tag::DefinitionListTitle
761 | Tag::DefinitionListDefinition => {
762 let end = tag.to_end();
764 skip_until(events, pos, end);
765 Vec::new()
766 }
767
768 Tag::Emphasis
770 | Tag::Strong
771 | Tag::Strikethrough
772 | Tag::Superscript
773 | Tag::Subscript
774 | Tag::Link { .. }
775 | Tag::Image { .. } => {
776 let content =
777 self.render_inline(events, pos, tag.to_end(), &self.build_default_ctx());
778 vec![content]
779 }
780 }
781 }
782
783 fn render_inline(
785 &self,
786 events: &[Event],
787 pos: &mut usize,
788 end: TagEnd,
789 ctx: &InlineCtx,
790 ) -> String {
791 let mut result = String::new();
792
793 loop {
794 if *pos >= events.len() {
795 break;
796 }
797
798 match &events[*pos] {
799 Event::End(tag_end) if *tag_end == end => {
800 *pos += 1;
801 break;
802 }
803
804 Event::Text(text) => {
805 *pos += 1;
806 result.push_str(&split_newline_apply(text, &*ctx.apply_text));
808 }
809
810 Event::Code(code) => {
811 *pos += 1;
812 result.push_str(&(self.theme.code)(code));
813 result.push_str(&ctx.style_prefix);
814 }
815
816 Event::Start(Tag::Emphasis) => {
817 *pos += 1;
818 let inner = self.render_inline(events, pos, TagEnd::Emphasis, ctx);
819 result.push_str(&(self.theme.italic)(&inner));
820 result.push_str(&ctx.style_prefix);
821 }
822
823 Event::Start(Tag::Strong) => {
824 *pos += 1;
825 let inner = self.render_inline(events, pos, TagEnd::Strong, ctx);
826 result.push_str(&(self.theme.bold)(&inner));
827 result.push_str(&ctx.style_prefix);
828 }
829
830 Event::Start(Tag::Strikethrough) => {
831 *pos += 1;
832 let inner = self.render_inline(events, pos, TagEnd::Strikethrough, ctx);
833 result.push_str(&(self.theme.strikethrough)(&inner));
834 result.push_str(&ctx.style_prefix);
835 }
836
837 Event::Start(Tag::Link {
838 dest_url, title: _, ..
839 }) => {
840 *pos += 1;
841 let inner = self.render_inline(events, pos, TagEnd::Link, ctx);
842
843 let styled_link = (self.theme.link)(&(self.theme.underline)(&inner));
844
845 if hyperlinks_supported() {
846 result.push_str(&hyperlink(&styled_link, dest_url));
847 } else {
848 let href = dest_url.as_ref();
850 let href_clean = if let Some(mailto) = href.strip_prefix("mailto:") {
851 mailto
852 } else {
853 href
854 };
855 if inner.trim() == href_clean || inner.trim() == href {
856 result.push_str(&styled_link);
857 } else {
858 result.push_str(&styled_link);
859 result.push_str(&(self.theme.link_url)(&format!(" ({})", href)));
860 }
861 }
862 result.push_str(&ctx.style_prefix);
863 }
864
865 Event::Start(Tag::Image { .. }) => {
866 *pos += 1;
868 let _ = self.render_inline(events, pos, TagEnd::Image, ctx);
869 }
870
871 Event::SoftBreak => {
872 *pos += 1;
873 result.push('\n');
874 }
875
876 Event::HardBreak => {
877 *pos += 1;
878 result.push('\n');
879 }
880
881 Event::InlineHtml(html) | Event::Html(html) => {
882 *pos += 1;
883 result.push_str(&(ctx.apply_text)(html.trim()));
884 }
885
886 Event::TaskListMarker(checked) => {
888 *pos += 1;
889 let marker = if *checked { "[x] " } else { "[ ] " };
890 let styled = (self.theme.list_bullet)(marker);
891 result.push_str(&styled);
892 }
893
894 Event::InlineMath(math) | Event::DisplayMath(math) => {
896 *pos += 1;
897 result.push_str(&(ctx.apply_text)(math));
898 }
899
900 Event::FootnoteReference(ref_id) => {
902 *pos += 1;
903 result.push_str(&(ctx.apply_text)(&format!("[^{}]", ref_id)));
904 }
905
906 Event::Start(tag) => {
908 *pos += 1;
909 let content = self.render_block(events, pos, tag, 80, false, 0);
910 for (i, line) in content.iter().enumerate() {
911 if i > 0 {
912 result.push('\n');
913 }
914 result.push_str(line);
915 }
916 }
917
918 _ => {
919 *pos += 1;
920 }
921 }
922 }
923
924 while result.ends_with(&ctx.style_prefix) && !ctx.style_prefix.is_empty() {
926 result = result[..result.len() - ctx.style_prefix.len()].to_string();
927 }
928
929 result
930 }
931
932 fn render_list(
934 &self,
935 events: &[Event],
936 pos: &mut usize,
937 start: Option<u64>,
938 width: usize,
939 depth: usize,
940 ) -> Vec<String> {
941 let mut lines: Vec<String> = Vec::new();
942 let indent_str = " ".repeat(depth);
943 let start_number = start.unwrap_or(1);
944 let mut item_index: u64 = 0;
945
946 loop {
947 if *pos >= events.len() {
948 break;
949 }
950
951 match &events[*pos] {
952 Event::End(TagEnd::List(ordered)) => {
953 if *ordered == start.is_some() {
954 *pos += 1;
955 break;
956 }
957 *pos += 1;
958 }
959
960 Event::Start(Tag::Item) => {
961 *pos += 1;
962 item_index += 1;
963
964 let task_marker = if *pos < events.len() {
966 match &events[*pos] {
967 Event::TaskListMarker(checked) => {
968 *pos += 1;
969 let checked_str = if *checked { "[x] " } else { "[ ] " };
970 Some(checked_str.to_string())
971 }
972 _ => None,
973 }
974 } else {
975 None
976 };
977
978 let is_ordered = start.is_some();
979 let marker = if is_ordered {
980 let num_str = (start_number + item_index - 1).to_string();
981 format!("{}. ", num_str)
982 } else {
983 "- ".to_string()
984 };
985 let marker = task_marker
986 .map(|tm| format!("{}{}", marker, tm))
987 .unwrap_or(marker);
988
989 let bullet_prefix = indent_str.clone() + &(self.theme.list_bullet)(&marker);
990 let continuation_prefix =
991 indent_str.clone() + &" ".repeat(visible_width(&marker));
992 let item_width = width.saturating_sub(visible_width(&bullet_prefix)).max(1);
993 let mut rendered_any = false;
994
995 loop {
997 if *pos >= events.len() {
998 break;
999 }
1000
1001 match &events[*pos] {
1002 Event::End(TagEnd::Item) => {
1003 *pos += 1;
1004 break;
1005 }
1006
1007 Event::Start(Tag::List(lst)) => {
1008 *pos += 1;
1009 let nested = self.render_list(events, pos, *lst, width, depth + 1);
1010 for nl in nested {
1011 lines.push(nl);
1012 }
1013 rendered_any = true;
1014 }
1015
1016 Event::Start(Tag::Item) => {
1017 break;
1019 }
1020
1021 Event::Start(tag) => {
1022 *pos += 1;
1023 let block_lines =
1024 self.render_block(events, pos, tag, item_width, false, depth);
1025 for bl in block_lines.iter() {
1026 for wl in wrap_text_with_ansi(bl, item_width) {
1027 let prefix = if rendered_any {
1028 &continuation_prefix
1029 } else {
1030 &bullet_prefix
1031 };
1032 lines.push(format!("{}{}", prefix, wl));
1033 rendered_any = true;
1034 }
1035 }
1036 }
1037
1038 Event::Text(_)
1040 | Event::Code(_)
1041 | Event::SoftBreak
1042 | Event::HardBreak
1043 | Event::InlineHtml(_)
1044 | Event::InlineMath(_)
1045 | Event::DisplayMath(_) => {
1046 let inline = self.render_inline(
1047 events,
1048 pos,
1049 TagEnd::Item,
1050 &self.build_default_ctx(),
1051 );
1052 for wl in wrap_text_with_ansi(&inline, item_width) {
1053 let prefix = if rendered_any {
1054 &continuation_prefix
1055 } else {
1056 &bullet_prefix
1057 };
1058 lines.push(format!("{}{}", prefix, wl));
1059 rendered_any = true;
1060 }
1061 }
1062
1063 Event::End(TagEnd::Paragraph) => {
1064 *pos += 1;
1066 }
1067
1068 _ => {
1069 *pos += 1;
1070 }
1071 }
1072 }
1073
1074 if !rendered_any {
1075 lines.push(bullet_prefix);
1076 }
1077 }
1078
1079 _ => {
1080 *pos += 1;
1081 }
1082 }
1083 }
1084
1085 lines
1086 }
1087
1088 fn render_table(
1090 &self,
1091 events: &[Event],
1092 pos: &mut usize,
1093 alignments: &[pulldown_cmark::Alignment],
1094 width: usize,
1095 ) -> Vec<String> {
1096 let ctx = self.build_default_ctx();
1097 let num_cols = alignments.len();
1098
1099 if num_cols == 0 {
1100 skip_until(events, pos, TagEnd::Table);
1102 return Vec::new();
1103 }
1104
1105 let mut headers: Vec<Vec<String>> = Vec::new(); let mut body: Vec<Vec<Vec<String>>> = Vec::new(); let mut current_cell_content: Vec<Event> = Vec::new(); let mut current_row: Vec<Vec<String>> = Vec::new(); let mut _current_cell_idx: usize = 0;
1112 let mut in_body = false;
1113
1114 loop {
1115 if *pos >= events.len() {
1116 break;
1117 }
1118
1119 match &events[*pos] {
1120 Event::End(TagEnd::Table) => {
1121 if !current_cell_content.is_empty() {
1123 let cell_text = self.render_collected_inline(¤t_cell_content, &ctx);
1124 current_row.push(cell_text);
1125 current_cell_content.clear();
1126 }
1127 if !current_row.is_empty() {
1128 body.push(current_row.clone());
1129 }
1130 *pos += 1;
1131 break;
1132 }
1133
1134 Event::Start(Tag::TableHead) => {
1135 *pos += 1;
1136 }
1138
1139 Event::End(TagEnd::TableHead) => {
1140 *pos += 1;
1141 if !current_row.is_empty() {
1142 headers = current_row.clone();
1143 current_row.clear();
1144 }
1145 in_body = true;
1146 }
1147
1148 Event::Start(Tag::TableRow) => {
1149 *pos += 1;
1150 _current_cell_idx = 0;
1151 }
1152
1153 Event::End(TagEnd::TableRow) => {
1154 *pos += 1;
1155 if !current_cell_content.is_empty() {
1157 let cell_text = self.render_collected_inline(¤t_cell_content, &ctx);
1158 current_row.push(cell_text);
1159 current_cell_content.clear();
1160 }
1161 if !current_row.is_empty() {
1162 if !in_body {
1163 headers = current_row.clone();
1164 } else {
1165 body.push(current_row.clone());
1166 }
1167 current_row.clear();
1168 }
1169 _current_cell_idx = 0;
1170 }
1171
1172 Event::Start(Tag::TableCell) => {
1173 *pos += 1;
1174 if !current_cell_content.is_empty() {
1176 let cell_text = self.render_collected_inline(¤t_cell_content, &ctx);
1177 current_row.push(cell_text);
1178 current_cell_content.clear();
1179 _current_cell_idx += 1;
1180 }
1181 }
1182
1183 Event::End(TagEnd::TableCell) => {
1184 *pos += 1;
1185 let cell_text = self.render_collected_inline(¤t_cell_content, &ctx);
1187 current_row.push(cell_text);
1188 current_cell_content.clear();
1189 _current_cell_idx += 1;
1190 }
1191
1192 Event::Text(_t) => {
1194 current_cell_content.push(events[*pos].clone());
1195 *pos += 1;
1196 }
1197 Event::Code(_c) => {
1198 current_cell_content.push(events[*pos].clone());
1199 *pos += 1;
1200 }
1201 Event::Start(Tag::Emphasis)
1202 | Event::Start(Tag::Strong)
1203 | Event::Start(Tag::Strikethrough)
1204 | Event::Start(Tag::Link { .. }) => {
1205 current_cell_content.push(events[*pos].clone());
1206 *pos += 1;
1207 }
1208 Event::End(TagEnd::Emphasis)
1209 | Event::End(TagEnd::Strong)
1210 | Event::End(TagEnd::Strikethrough)
1211 | Event::End(TagEnd::Link) => {
1212 current_cell_content.push(events[*pos].clone());
1213 *pos += 1;
1214 }
1215 Event::SoftBreak | Event::HardBreak => {
1216 current_cell_content.push(events[*pos].clone());
1217 *pos += 1;
1218 }
1219 Event::InlineHtml(_h) => {
1220 current_cell_content.push(events[*pos].clone());
1221 *pos += 1;
1222 }
1223
1224 Event::Start(_) => {
1226 current_cell_content.push(events[*pos].clone());
1227 *pos += 1;
1228 }
1229 Event::End(_) => {
1230 *pos += 1;
1231 }
1232 _ => {
1233 *pos += 1;
1234 }
1235 }
1236 }
1237
1238 let border_overhead = 3 * num_cols + 1;
1240 let available = width.saturating_sub(border_overhead);
1241 if available < num_cols {
1242 return Vec::new();
1244 }
1245
1246 let max_unbroken_word_width = 30;
1248 let mut natural_widths = vec![0usize; num_cols];
1249 let mut min_word_widths = vec![1usize; num_cols];
1250
1251 let update_widths =
1253 |cells: &[Vec<String>], natural: &mut [usize], min_word: &mut [usize]| {
1254 for (i, cell_lines) in cells.iter().enumerate() {
1255 if i >= num_cols {
1256 break;
1257 }
1258 for cl in cell_lines {
1259 let vw = visible_width(cl);
1260 natural[i] = natural[i].max(vw);
1261 let longest = cl
1263 .split_whitespace()
1264 .map(visible_width)
1265 .max()
1266 .unwrap_or(0)
1267 .min(max_unbroken_word_width);
1268 min_word[i] = min_word[i].max(longest.max(1));
1269 }
1270 }
1271 };
1272
1273 update_widths(&headers, &mut natural_widths, &mut min_word_widths);
1275
1276 for row_cells in &body {
1277 update_widths(row_cells, &mut natural_widths, &mut min_word_widths);
1278 }
1279
1280 let total_natural: usize = natural_widths.iter().sum();
1282 let mut column_widths = vec![0usize; num_cols];
1283
1284 if total_natural + border_overhead <= width {
1285 for i in 0..num_cols {
1287 column_widths[i] = natural_widths[i].max(min_word_widths[i]);
1288 }
1289 } else {
1290 let min_total: usize = min_word_widths.iter().sum();
1292 let extra = available.saturating_sub(min_total);
1293
1294 let grow_potential: usize = natural_widths
1295 .iter()
1296 .zip(min_word_widths.iter())
1297 .map(|(n, m)| n.saturating_sub(*m))
1298 .sum();
1299
1300 if min_total <= available {
1301 for i in 0..num_cols {
1302 let n = natural_widths[i];
1303 let m = min_word_widths[i];
1304 let potential = n.saturating_sub(m);
1305 let grow = if grow_potential > 0 {
1306 extra
1307 .checked_mul(potential)
1308 .map(|p| p / grow_potential)
1309 .unwrap_or(0)
1310 } else {
1311 0
1312 };
1313 column_widths[i] = m + grow;
1314 }
1315 let allocated: usize = column_widths.iter().sum();
1317 let mut remaining = available.saturating_sub(allocated);
1318 for i in 0..num_cols {
1319 if remaining == 0 {
1320 break;
1321 }
1322 if column_widths[i] < natural_widths[i] {
1323 column_widths[i] += 1;
1324 remaining -= 1;
1325 }
1326 }
1327 } else {
1328 let base = available / num_cols;
1330 let rem = available % num_cols;
1331 for (i, cw) in column_widths.iter_mut().enumerate() {
1332 *cw = base + if i < rem { 1 } else { 0 };
1333 }
1334 }
1335 }
1336
1337 let mut result: Vec<String> = Vec::new();
1338
1339 let top_cells: Vec<String> = column_widths.iter().map(|w| "─".repeat(*w)).collect();
1341 result.push(format!("┌─{}─┐", top_cells.join("─┬─")));
1342
1343 let header_lines = self.render_table_row(&headers, &column_widths, num_cols, &ctx, true);
1345 result.extend(header_lines);
1346
1347 let sep_cells: Vec<String> = column_widths.iter().map(|w| "─".repeat(*w)).collect();
1349 result.push(format!("├─{}─┤", sep_cells.join("─┼─")));
1350
1351 for (ri, row_cells) in body.iter().enumerate() {
1353 let row_lines = self.render_table_row(row_cells, &column_widths, num_cols, &ctx, false);
1354 result.extend(row_lines);
1355 if ri < body.len() - 1 {
1356 result.push(format!("├─{}─┤", sep_cells.join("─┼─")));
1358 }
1359 }
1360
1361 let bottom_cells: Vec<String> = column_widths.iter().map(|w| "─".repeat(*w)).collect();
1363 result.push(format!("└─{}─┘", bottom_cells.join("─┴─")));
1364
1365 if *pos < events.len() {
1367 let next_is_space = matches!(
1368 &events[*pos],
1369 Event::End(_) | Event::SoftBreak | Event::HardBreak
1370 );
1371 if !next_is_space {
1372 result.push(String::new());
1373 }
1374 }
1375
1376 result
1377 }
1378
1379 fn render_table_row(
1381 &self,
1382 cells: &[Vec<String>],
1383 column_widths: &[usize],
1384 num_cols: usize,
1385 _ctx: &InlineCtx,
1386 is_header: bool,
1387 ) -> Vec<String> {
1388 if cells.is_empty() {
1389 return Vec::new();
1390 }
1391
1392 let mut wrapped_cells: Vec<Vec<String>> = Vec::new();
1394 for (i, cell_lines) in cells.iter().enumerate() {
1395 if i >= num_cols {
1396 break;
1397 }
1398 let col_width = column_widths[i];
1399 let mut wrapped: Vec<String> = Vec::new();
1400 for cl in cell_lines {
1401 for wl in wrap_text_with_ansi(cl, col_width) {
1402 wrapped.push(wl);
1403 }
1404 }
1405 if wrapped.is_empty() {
1406 wrapped.push(String::new());
1407 }
1408 wrapped_cells.push(wrapped);
1409 }
1410
1411 let max_lines = wrapped_cells.iter().map(|c| c.len()).max().unwrap_or(1);
1413 for cell in &mut wrapped_cells {
1414 while cell.len() < max_lines {
1415 cell.push(String::new());
1416 }
1417 }
1418
1419 let mut result: Vec<String> = Vec::new();
1420 for line_idx in 0..max_lines {
1421 let mut row_parts: Vec<String> = Vec::new();
1422 for (col_idx, cell) in wrapped_cells.iter().enumerate() {
1423 let text = cell.get(line_idx).map(|s| s.as_str()).unwrap_or("");
1424 let vw = visible_width(text);
1425 let padding = column_widths[col_idx].saturating_sub(vw);
1426 let padded = if is_header {
1427 (self.theme.bold)(&format!("{}{}", text, " ".repeat(padding)))
1428 } else {
1429 format!("{}{}", text, " ".repeat(padding))
1430 };
1431 row_parts.push(padded);
1432 }
1433 result.push(format!("│ {} │", row_parts.join(" │ ")));
1434 }
1435
1436 result
1437 }
1438
1439 fn render_collected_inline(&self, events: &[Event], ctx: &InlineCtx) -> Vec<String> {
1441 if events.is_empty() {
1442 return vec![String::new()];
1443 }
1444 let mut pos = 0usize;
1446 let rendered = self.render_inline(events, &mut pos, TagEnd::TableCell, ctx);
1447 if rendered.is_empty() {
1448 vec![String::new()]
1449 } else {
1450 rendered.split('\n').map(|s| s.to_string()).collect()
1451 }
1452 }
1453}
1454
1455fn skip_until(events: &[Event], pos: &mut usize, end: TagEnd) {
1459 let mut depth = 0;
1460 loop {
1461 if *pos >= events.len() {
1462 break;
1463 }
1464 match &events[*pos] {
1465 Event::End(tag_end) if *tag_end == end => {
1466 if depth == 0 {
1467 *pos += 1;
1468 break;
1469 }
1470 depth -= 1;
1471 *pos += 1;
1472 }
1473 Event::Start(_) => {
1474 depth += 1;
1475 *pos += 1;
1476 }
1477 _ => {
1478 *pos += 1;
1479 }
1480 }
1481 }
1482}
1483
1484fn level_to_usize(level: HeadingLevel) -> usize {
1486 match level {
1487 HeadingLevel::H1 => 1,
1488 HeadingLevel::H2 => 2,
1489 HeadingLevel::H3 => 3,
1490 HeadingLevel::H4 => 4,
1491 HeadingLevel::H5 => 5,
1492 HeadingLevel::H6 => 6,
1493 }
1494}
1495
1496fn split_newline_apply(text: &str, apply: &dyn Fn(&str) -> String) -> String {
1499 let segments: Vec<&str> = text.split('\n').collect();
1500 segments
1501 .iter()
1502 .enumerate()
1503 .map(|(i, s)| {
1504 if i > 0 {
1505 format!("\n{}", apply(s))
1506 } else {
1507 apply(s)
1508 }
1509 })
1510 .collect()
1511}
1512
1513pub fn create_highlight_fn() -> Option<HighlightFn> {
1518 #[cfg(feature = "syntect")]
1519 {
1520 Some(Arc::new(highlight_code))
1521 }
1522 #[cfg(not(feature = "syntect"))]
1523 {
1524 None
1525 }
1526}
1527
1528#[cfg(feature = "syntect")]
1529pub fn highlight_code(code: &str, lang: Option<&str>) -> Vec<String> {
1530 use std::sync::LazyLock;
1531
1532 use syntect::{
1533 easy::HighlightLines,
1534 highlighting::ThemeSet,
1535 parsing::SyntaxSet,
1536 util::{LinesWithEndings, as_24_bit_terminal_escaped},
1537 };
1538
1539 static SYNTAX_SET: LazyLock<SyntaxSet> = LazyLock::new(SyntaxSet::load_defaults_newlines);
1540
1541 static THEME_SET: LazyLock<ThemeSet> = LazyLock::new(ThemeSet::load_defaults);
1542
1543 let ss = &SYNTAX_SET;
1544 let ts = &THEME_SET;
1545
1546 let syntax = lang
1548 .and_then(|l| ss.find_syntax_by_token(l))
1549 .unwrap_or_else(|| ss.find_syntax_plain_text());
1550
1551 let theme = ts
1553 .themes
1554 .get("base16-ocean.dark")
1555 .or_else(|| ts.themes.iter().next().map(|(_, t)| t));
1556
1557 let Some(theme) = theme else {
1558 return code.split('\n').map(|s| s.to_string()).collect();
1560 };
1561
1562 let mut highlighter = HighlightLines::new(syntax, theme);
1563 let mut result = Vec::new();
1564
1565 for line in LinesWithEndings::from(code) {
1566 match highlighter.highlight_line(line, ss) {
1567 Ok(ranges) => {
1568 let escaped = as_24_bit_terminal_escaped(&ranges, false);
1569 let trimmed = escaped.trim_end_matches('\n');
1571 if trimmed.is_empty() {
1572 result.push(String::new());
1573 } else {
1574 result.push(format!("{}\x1b[0m", trimmed));
1575 }
1576 }
1577 Err(_) => {
1578 result.push(line.trim_end_matches('\n').to_string());
1579 }
1580 }
1581 }
1582
1583 result
1584}
1585
1586pub fn path_to_language(path: &str) -> Option<&'static str> {
1588 let ext = path.rsplit('.').next()?.to_lowercase();
1589 let lang = match ext.as_str() {
1590 "ts" | "tsx" => "typescript",
1591 "js" | "jsx" | "mjs" | "cjs" => "javascript",
1592 "py" => "python",
1593 "rb" => "ruby",
1594 "rs" => "rust",
1595 "go" => "go",
1596 "java" => "java",
1597 "kt" => "kotlin",
1598 "swift" => "swift",
1599 "c" | "h" => "c",
1600 "cpp" | "cc" | "cxx" | "hpp" => "cpp",
1601 "cs" => "csharp",
1602 "php" => "php",
1603 "sh" | "bash" | "zsh" => "bash",
1604 "ps1" => "powershell",
1605 "sql" => "sql",
1606 "html" | "htm" => "html",
1607 "css" | "scss" | "sass" | "less" => "css",
1608 "json" => "json",
1609 "yaml" | "yml" => "yaml",
1610 "toml" => "toml",
1611 "xml" => "xml",
1612 "md" | "markdown" => "markdown",
1613 "clj" | "cljs" | "cljc" => "clojure",
1614 "ex" | "exs" => "elixir",
1615 "hs" => "haskell",
1616 "lua" => "lua",
1617 _ => return None,
1618 };
1619 Some(lang)
1620}
1621
1622#[cfg(test)]
1625mod tests {
1626 use super::*;
1627
1628 fn test_theme() -> MarkdownTheme {
1630 MarkdownTheme::new(
1631 Arc::new(|s| format!("\x1b[33m{}\x1b[39m", s)), Arc::new(|s| format!("\x1b[34m{}\x1b[39m", s)), Arc::new(|s| format!("\x1b[90m{}\x1b[39m", s)), Arc::new(|s| format!("\x1b[36m{}\x1b[39m", s)), Arc::new(|s| format!("\x1b[32m{}\x1b[39m", s)), Arc::new(|s| format!("\x1b[90m{}\x1b[39m", s)), Arc::new(|s| format!("\x1b[90m{}\x1b[39m", s)), Arc::new(|s| format!("\x1b[90m{}\x1b[39m", s)), Arc::new(|s| format!("\x1b[90m{}\x1b[39m", s)), Arc::new(|s| format!("\x1b[33m{}\x1b[39m", s)), Arc::new(|s| format!("\x1b[1m{}\x1b[22m", s)), Arc::new(|s| format!("\x1b[3m{}\x1b[23m", s)), Arc::new(|s| format!("\x1b[9m{}\x1b[29m", s)), Arc::new(|s| format!("\x1b[4m{}\x1b[24m", s)), )
1646 }
1647
1648 #[test]
1649 fn test_basic_paragraph() {
1650 let theme = test_theme();
1651 let md = Markdown::new("hello world", 0, 0, theme, None, None);
1652 let lines = md.render(80);
1653 let all = lines.join("\n");
1654 assert!(all.contains("hello world"));
1655 assert!(!all.contains("\x1b[")); }
1657
1658 #[test]
1659 fn test_heading_h1() {
1660 let theme = test_theme();
1661 let md = Markdown::new("# Heading 1", 0, 0, theme, None, None);
1662 let lines = md.render(80);
1663 let all = lines.join("\n");
1664 assert!(all.contains("Heading 1"), "Should contain heading text");
1665 assert!(all.contains("\x1b[1m"), "Should have bold for h1");
1666 assert!(all.contains("\x1b[33m"), "Should have heading color");
1667 }
1668
1669 #[test]
1670 fn test_heading_h3_marker() {
1671 let theme = test_theme();
1672 let md = Markdown::new("### Heading 3", 0, 0, theme, None, None);
1673 let lines = md.render(80);
1674 let all = lines.join("\n");
1675 assert!(all.contains("### Heading 3") || all.contains("Heading 3"));
1676 assert!(
1678 !all.contains("### ") || all.contains("###"),
1679 "h3 should show ### marker"
1680 );
1681 }
1682
1683 #[test]
1684 fn test_bold_italic() {
1685 let theme = test_theme();
1686 let md = Markdown::new("**bold** and *italic*", 0, 0, theme, None, None);
1687 let lines = md.render(80);
1688 let all = lines.join("\n");
1689 assert!(all.contains("bold"), "Should contain bold text");
1690 assert!(all.contains("italic"), "Should contain italic text");
1691 assert!(all.contains("\x1b[1m"), "Should contain bold ANSI");
1692 assert!(all.contains("\x1b[3m"), "Should contain italic ANSI");
1693 }
1694
1695 #[test]
1696 fn test_codespan() {
1697 let theme = test_theme();
1698 let md = Markdown::new("use `code` here", 0, 0, theme, None, None);
1699 let lines = md.render(80);
1700 let all = lines.join("\n");
1701 assert!(all.contains("code"), "Should contain code text");
1702 assert!(all.contains("\x1b[36m"), "Should contain code color (cyan)");
1703 }
1704
1705 #[test]
1706 fn test_inline_code_style_restore() {
1707 let theme = test_theme();
1708 let md = Markdown::new("**bold `code` end**", 0, 0, theme, None, None);
1709 let lines = md.render(80);
1710 let all = lines.join("\n");
1711 assert!(all.contains("bold"), "Should contain bold text");
1712 assert!(all.contains("code"), "Should contain code text");
1713 assert!(all.contains("end"), "Should contain 'end' text");
1714 }
1716
1717 #[test]
1718 fn test_code_block() {
1719 let theme = test_theme();
1720 let md = Markdown::new("```\nlet x = 1;\n```", 0, 0, theme, None, None);
1721 let lines = md.render(80);
1722 let all = lines.join("\n");
1723 assert!(all.contains("let x = 1;"), "Should contain code");
1724 assert!(all.contains("\x1b[32m"), "Should have code block color");
1725 assert!(all.contains("```"), "Should have fence markers");
1726 }
1727
1728 #[test]
1729 fn test_fenced_code_with_language() {
1730 let theme = test_theme();
1731 let md = Markdown::new("```rust\nfn main() {}\n```", 0, 0, theme, None, None);
1732 let lines = md.render(80);
1733 let all = lines.join("\n");
1734 assert!(all.contains("```rust"), "Should show language tag");
1735 assert!(all.contains("fn main() {}"), "Should contain code");
1736 }
1737
1738 #[test]
1739 fn test_unordered_list() {
1740 let theme = test_theme();
1741 let md = Markdown::new("- item 1\n- item 2\n- item 3", 0, 0, theme, None, None);
1742 let lines = md.render(80);
1743 let all = lines.join("\n");
1744 assert!(all.contains("item 1"), "Should contain first item");
1745 assert!(all.contains("item 2"), "Should contain second item");
1746 assert!(all.contains("item 3"), "Should contain third item");
1747 }
1748
1749 #[test]
1750 fn test_strikethrough() {
1751 let theme = test_theme();
1752 let md = Markdown::new("~~struck~~", 0, 0, theme, None, None);
1753 let lines = md.render(80);
1754 let all = lines.join("\n");
1755 assert!(all.contains("struck"), "Should contain text");
1756 assert!(all.contains("\x1b[9m"), "Should contain strikethrough");
1757 }
1758
1759 #[test]
1760 fn test_link_inline() {
1761 let theme = test_theme();
1762 let md = Markdown::new("[text](https://example.com)", 0, 0, theme, None, None);
1763 let lines = md.render(80);
1764 let all = lines.join("\n");
1765 assert!(all.contains("text"), "Should contain link text");
1766 assert!(
1767 all.contains("https://example.com"),
1768 "Should contain URL in fallback"
1769 );
1770 }
1771
1772 #[test]
1773 fn test_empty_text() {
1774 let theme = test_theme();
1775 let md = Markdown::new("", 0, 0, theme, None, None);
1776 let lines = md.render(80);
1777 assert!(lines.is_empty() || (lines.len() == 1 && lines[0].is_empty()));
1778 }
1779
1780 #[test]
1781 fn test_whitespace_only() {
1782 let theme = test_theme();
1783 let md = Markdown::new(" ", 0, 0, theme, None, None);
1784 let lines = md.render(80);
1785 assert!(lines.is_empty() || (lines.len() == 1 && lines[0].is_empty()));
1786 }
1787
1788 #[test]
1789 fn test_horizontal_rule() {
1790 let theme = test_theme();
1791 let md = Markdown::new("---", 0, 0, theme, None, None);
1792 let lines = md.render(80);
1793 let all = lines.join("\n");
1794 assert!(all.contains('─'), "Should have horizontal rule");
1795 }
1796
1797 #[test]
1798 fn test_padding_x() {
1799 let theme = test_theme();
1800 let md = Markdown::new("hello", 2, 0, theme, None, None);
1801 let lines = md.render(20);
1802 assert_eq!(
1803 visible_width(&lines[0]),
1804 20,
1805 "Should be padded to full width"
1806 );
1807 assert!(lines[0].starts_with(" "), "Should have left padding");
1808 }
1809
1810 #[test]
1811 fn test_padding_y() {
1812 let theme = test_theme();
1813 let md = Markdown::new("hello", 0, 1, theme, None, None);
1814 let lines = md.render(20);
1815 assert_eq!(
1816 lines.len(),
1817 3,
1818 "Should have top padding + content + bottom padding"
1819 );
1820 }
1821
1822 #[test]
1823 fn test_cache_hit() {
1824 let theme = test_theme();
1825 let md = Markdown::new("hello", 1, 0, theme, None, None);
1826 let a = md.render(20);
1827 let b = md.render(20);
1828 assert_eq!(a, b, "Cache should return same result");
1829 }
1830
1831 #[test]
1832 fn test_cache_invalidation() {
1833 let theme = test_theme();
1834 let mut md = Markdown::new("hello", 1, 0, theme, None, None);
1835 let a = md.render(20);
1836 md.set_text("world");
1837 let b = md.render(20);
1838 assert_ne!(a, b, "Cache should be invalidated on set_text");
1839 }
1840
1841 #[test]
1842 fn test_strikethrough_not_enabled_without_tilde() {
1843 let theme = test_theme();
1845 let md = Markdown::new("~not struck~", 0, 0, theme, None, None);
1846 let lines = md.render(80);
1847 let all = lines.join("\n");
1848 assert!(
1851 all.contains("~not struck~") || all.contains("not struck"),
1852 "~ should work as plain text or strikethrough"
1853 );
1854 }
1855
1856 #[test]
1857 fn test_blockquote() {
1858 let theme = test_theme();
1859 let md = Markdown::new("> quoted text", 0, 0, theme, None, None);
1860 let lines = md.render(80);
1861 let all = lines.join("\n");
1862 assert!(all.contains("quoted text"), "Should contain quote text");
1863 assert!(all.contains("│"), "Should have blockquote border");
1864 }
1865
1866 #[test]
1867 fn test_task_list() {
1868 let theme = test_theme();
1869 let md = Markdown::new("- [x] done\n- [ ] todo", 0, 0, theme, None, None);
1870 let lines = md.render(80);
1871 let all = lines.join("\n");
1872 assert!(all.contains("[x]"), "Should show done marker");
1873 assert!(all.contains("[ ]"), "Should show todo marker");
1874 assert!(all.contains("done"), "Should contain done text");
1875 assert!(all.contains("todo"), "Should contain todo text");
1876 }
1877
1878 #[test]
1879 fn test_paragraph_spacing() {
1880 let theme = test_theme();
1881 let md = Markdown::new("para one\n\npara two", 0, 0, theme, None, None);
1882 let lines = md.render(80);
1883 assert!(lines.len() >= 2, "Should have multiple lines");
1884 }
1885
1886 #[test]
1887 fn test_tabs_replaced() {
1888 let theme = test_theme();
1889 let md = Markdown::new("\tindented", 0, 0, theme, None, None);
1890 let lines = md.render(80);
1891 let all = lines.join("\n");
1892 assert!(
1893 all.contains("indented"),
1894 "Tabs should be replaced with 3 spaces"
1895 );
1896 }
1897
1898 #[test]
1899 fn test_default_text_style() {
1900 let theme = test_theme();
1901 let default_style = DefaultTextStyle {
1902 color: Some(Arc::new(|s| format!("\x1b[33m{}\x1b[39m", s))),
1903 bg_color: None,
1904 bold: true,
1905 italic: false,
1906 strikethrough: false,
1907 underline: false,
1908 };
1909 let md = Markdown::new("styled text", 0, 0, theme, Some(default_style), None);
1910 let lines = md.render(80);
1911 let all = lines.join("\n");
1912 assert!(all.contains("styled text"));
1913 assert!(
1914 all.contains("\x1b[1m"),
1915 "Should have bold from default style"
1916 );
1917 assert!(
1918 all.contains("\x1b[33m"),
1919 "Should have yellow from default style"
1920 );
1921 }
1922
1923 #[test]
1924 fn test_table_basic() {
1925 let theme = test_theme();
1926 let md = Markdown::new(
1927 "| H1 | H2 |\n| --- | --- |\n| A1 | B1 |\n| A2 | B2 |",
1928 0,
1929 0,
1930 theme,
1931 None,
1932 None,
1933 );
1934 let lines = md.render(80);
1935 let all = lines.join("\n");
1936 assert!(all.contains("H1"), "Should contain header");
1937 assert!(all.contains("H2"), "Should contain header");
1938 assert!(all.contains("A1"), "Should contain cell");
1939 assert!(all.contains("B1"), "Should contain cell");
1940 assert!(all.contains("┌"), "Should have top border");
1941 assert!(all.contains("└"), "Should have bottom border");
1942 assert!(all.contains("│"), "Should have column separators");
1943 }
1944
1945 #[test]
1946 fn test_table_narrow_fallback() {
1947 let theme = test_theme();
1948 let md = Markdown::new(
1949 "| A | B |\n| --- | --- |\n| 1 | 2 |",
1950 0,
1951 0,
1952 theme,
1953 None,
1954 None,
1955 );
1956 let lines = md.render(10);
1958 assert!(!lines.is_empty());
1960 }
1961
1962 #[test]
1963 fn test_ordered_list() {
1964 let theme = test_theme();
1965 let md = Markdown::new("1. first\n2. second\n3. third", 0, 0, theme, None, None);
1966 let lines = md.render(80);
1967 let all = lines.join("\n");
1968 assert!(all.contains("first"), "Should contain first");
1969 assert!(all.contains("second"), "Should contain second");
1970 assert!(all.contains("third"), "Should contain third");
1971 }
1972
1973 #[test]
1974 fn test_nested_list() {
1975 let theme = test_theme();
1976 let md = Markdown::new("- outer\n - inner\n- more", 0, 0, theme, None, None);
1977 let lines = md.render(80);
1978 let all = lines.join("\n");
1979 assert!(all.contains("outer"), "Should contain outer");
1980 assert!(all.contains("inner"), "Should contain nested");
1981 assert!(all.contains("more"), "Should contain more");
1982 }
1983
1984 #[test]
1985 fn test_blockquote_nested() {
1986 let theme = test_theme();
1987 let md = Markdown::new("> outer\n> > nested\n> back", 0, 0, theme, None, None);
1988 let lines = md.render(80);
1989 let all = lines.join("\n");
1990 assert!(all.contains("outer"), "Should contain outer text");
1991 assert!(all.contains("nested"), "Should contain nested text");
1992 assert!(all.contains("back"), "Should contain text after nested");
1993 assert!(all.contains("│"), "Should have blockquote border");
1994 }
1995
1996 #[test]
1997 fn test_link_with_dest() {
1998 let theme = test_theme();
1999 let md = Markdown::new(
2000 "[example](https://example.com/page)",
2001 0,
2002 0,
2003 theme,
2004 None,
2005 None,
2006 );
2007 let lines = md.render(80);
2008 let all = lines.join("\n");
2009 assert!(all.contains("example"), "Should contain link text");
2010 assert!(all.contains("example.com/page"), "Should contain URL");
2011 }
2012
2013 #[test]
2014 fn test_autolink() {
2015 let theme = test_theme();
2016 let md = Markdown::new("<https://example.com>", 0, 0, theme, None, None);
2017 let lines = md.render(80);
2018 let all = lines.join("\n");
2019 assert!(all.contains("example.com"), "Should contain URL");
2020 }
2021
2022 #[test]
2023 fn test_heading_h2_spacing() {
2024 let theme = test_theme();
2025 let md = Markdown::new("## Heading\n\nParagraph", 0, 0, theme, None, None);
2026 let lines = md.render(80);
2027 let all = lines.join("\n");
2028 assert!(all.contains("Heading"), "Should contain heading");
2029 assert!(all.contains("Paragraph"), "Should contain paragraph");
2030 }
2031
2032 #[test]
2033 fn test_code_block_markers() {
2034 let theme = test_theme();
2035 let md = Markdown::new("```rust\nfn hello() {}\n```", 0, 0, theme, None, None);
2036 let lines = md.render(80);
2037 let all = lines.join("\n");
2038 assert!(all.contains("```rust"), "Should show language in fence");
2039 assert!(all.contains("fn hello() {}"), "Should contain code");
2040 }
2041
2042 #[test]
2043 fn test_strikethrough_markers() {
2044 let theme = test_theme();
2045 let md = Markdown::new("~~struck text~~", 0, 0, theme, None, None);
2046 let lines = md.render(80);
2047 let all = lines.join("\n");
2048 assert!(all.contains("struck text"), "Should contain text");
2049 assert!(all.contains("\x1b[9m"), "Should have strikethrough ANSI");
2050 }
2051
2052 #[test]
2053 fn test_wrap_long_text() {
2054 let theme = test_theme();
2055 let long = "this is a very long line that should definitely wrap to multiple lines when rendered in a narrow terminal column";
2056 let md = Markdown::new(long, 0, 0, theme, None, None);
2057 let lines = md.render(30);
2058 assert!(lines.len() > 1, "Long text should wrap");
2059 for line in &lines {
2060 assert!(visible_width(line) <= 30, "Each line should fit width");
2061 }
2062 }
2063
2064 #[test]
2065 fn test_cache_different_width() {
2066 let theme = test_theme();
2067 let md = Markdown::new("hello world", 1, 0, theme, None, None);
2068 let a = md.render(30);
2069 let b = md.render(50);
2070 assert_ne!(a, b, "Different widths should produce different output");
2071 }
2072
2073 #[test]
2074 fn test_html_block_plain() {
2075 let theme = test_theme();
2076 let md = Markdown::new("<div>plain html</div>", 0, 0, theme, None, None);
2077 let lines = md.render(80);
2078 let all = lines.join("\n");
2079 assert!(
2080 all.contains("plain html"),
2081 "Should render HTML as plain text"
2082 );
2083 }
2084
2085 #[test]
2086 fn test_bold_italic_style_restore() {
2087 let theme = test_theme();
2088 let md = Markdown::new("**bold `code` more bold**", 0, 0, theme, None, None);
2089 let lines = md.render(80);
2090 let all = lines.join("\n");
2091 assert!(all.contains("bold"), "Should contain bold text");
2092 assert!(all.contains("code"), "Should contain code");
2093 assert!(all.contains("more"), "Should contain text after code");
2094 assert!(
2096 all.contains("\x1b[22m") || all.contains("more bold"),
2097 "Style should be restored after codespan"
2098 );
2099 }
2100
2101 #[test]
2102 fn test_heading_h4_marker() {
2103 let theme = test_theme();
2104 let md = Markdown::new("#### Heading 4", 0, 0, theme, None, None);
2105 let lines = md.render(80);
2106 let all = lines.join("\n");
2107 assert!(all.contains("####"), "h4 should show prefix marker");
2108 assert!(all.contains("Heading 4"), "Should contain heading text");
2109 }
2110
2111 #[test]
2112 fn test_heading_h5_marker() {
2113 let theme = test_theme();
2114 let md = Markdown::new("##### Heading 5", 0, 0, theme, None, None);
2115 let lines = md.render(80);
2116 let all = lines.join("\n");
2117 assert!(all.contains("#####"), "h5 should show prefix marker");
2118 assert!(all.contains("Heading 5"), "Should contain heading text");
2119 }
2120
2121 #[test]
2122 fn test_heading_h6_marker() {
2123 let theme = test_theme();
2124 let md = Markdown::new("###### Heading 6", 0, 0, theme, None, None);
2125 let lines = md.render(80);
2126 let all = lines.join("\n");
2127 assert!(all.contains("######"), "h6 should show prefix marker");
2128 assert!(all.contains("Heading 6"), "Should contain heading text");
2129 }
2130}