1#![allow(clippy::type_complexity, clippy::arc_with_non_send_sync)]
2
3use std::sync::Arc;
13
14use comrak::nodes::{AstNode, ListType, NodeCodeBlock, NodeTable, NodeValue};
15use comrak::{Arena, Options, parse_document};
16
17use crate::tui::Component;
18use crate::tui::util::{visible_width, wrap_text_with_ansi};
19
20pub type StyleFn = Arc<dyn Fn(&str) -> String>;
24pub type HighlightFn = Arc<dyn Fn(&str, Option<&str>) -> Vec<String>>;
26
27pub const CODE_BLOCK_INDENT: &str = " ";
32
33pub struct MarkdownTheme {
38 pub heading: StyleFn,
39 pub link: StyleFn,
40 pub link_url: StyleFn,
41 pub code: StyleFn,
42 pub code_block: StyleFn,
43 pub code_block_border: StyleFn,
44 pub quote: StyleFn,
45 pub quote_border: StyleFn,
46 pub hr: StyleFn,
47 pub list_bullet: StyleFn,
48 pub bold: StyleFn,
49 pub italic: StyleFn,
50 pub strikethrough: StyleFn,
51 pub underline: StyleFn,
52 pub highlight_code: Option<HighlightFn>,
54 pub code_block_indent: String,
57}
58
59impl MarkdownTheme {
60 #[allow(clippy::too_many_arguments)]
61 pub fn new(
62 heading: StyleFn,
63 link: StyleFn,
64 link_url: StyleFn,
65 code: StyleFn,
66 code_block: StyleFn,
67 code_block_border: StyleFn,
68 quote: StyleFn,
69 quote_border: StyleFn,
70 hr: StyleFn,
71 list_bullet: StyleFn,
72 bold: StyleFn,
73 italic: StyleFn,
74 strikethrough: StyleFn,
75 underline: StyleFn,
76 ) -> Self {
77 Self {
78 heading,
79 link,
80 link_url,
81 code,
82 code_block,
83 code_block_border,
84 quote,
85 quote_border,
86 hr,
87 list_bullet,
88 bold,
89 italic,
90 strikethrough,
91 underline,
92 highlight_code: None,
93 code_block_indent: " ".to_string(),
94 }
95 }
96}
97
98pub struct DefaultTextStyle {
103 pub color: Option<StyleFn>,
105 pub bold: bool,
106 pub italic: bool,
107 pub strikethrough: bool,
108 pub underline: bool,
109}
110
111struct InlineCtx {
116 apply_text: Arc<dyn Fn(&str) -> String>,
118 style_prefix: String,
121}
122
123impl InlineCtx {
124 fn new(apply_text: Arc<dyn Fn(&str) -> String>) -> Self {
125 let prefix = get_style_prefix(&*apply_text);
126 Self {
127 apply_text,
128 style_prefix: prefix,
129 }
130 }
131}
132
133fn get_style_prefix(style_fn: &dyn Fn(&str) -> String) -> String {
135 const SENTINEL: char = '\0';
136 let styled = style_fn(&SENTINEL.to_string());
137 styled
138 .find(SENTINEL)
139 .map(|i| styled[..i].to_string())
140 .unwrap_or_default()
141}
142
143pub(crate) fn hyperlinks_supported() -> bool {
145 if let Ok(prog) = std::env::var("TERM_PROGRAM")
146 && (prog == "iTerm.app" || prog == "kitty" || prog == "WezTerm" || prog == "vscode")
147 {
148 return true;
149 }
150 if let Ok(term) = std::env::var("TERM")
151 && term.contains("kitty")
152 {
153 return true;
154 }
155 #[cfg(windows)]
156 {
157 if let Ok(prog) = std::env::var("WT_SESSION") {
158 let _ = prog;
159 return true;
160 }
161 }
162 false
163}
164
165pub(crate) fn hyperlink(text: &str, url: &str) -> String {
167 format!("\x1b]8;;{}\x07{}\x1b]8;;\x07", url, text)
168}
169
170pub(crate) fn kitty_images_supported() -> bool {
174 if let Ok(prog) = std::env::var("TERM_PROGRAM")
176 && (prog == "iTerm.app" || prog == "kitty" || prog == "WezTerm")
177 {
178 return true;
179 }
180 if let Ok(term) = std::env::var("TERM")
181 && term.contains("kitty")
182 {
183 return true;
184 }
185 false
186}
187
188pub(crate) fn kitty_image_sequence(data: &[u8], mime_type: &str) -> String {
196 use base64::Engine as _;
197 let format = match mime_type {
198 "image/png" => 100,
199 "image/jpeg" | "image/jpg" => 101,
200 "image/gif" => 102,
201 "image/webp" => 103,
202 _ => 100, };
204 let b64 = base64::engine::general_purpose::STANDARD.encode(data);
205 format!("\x1b_Ga=T,f={},m=0;{}\x1b\\", format, b64)
206}
207
208pub struct Markdown {
216 text: String,
217 padding_x: usize,
218 padding_y: usize,
219 theme: MarkdownTheme,
220 default_text_style: Option<DefaultTextStyle>,
221
222 cached_text: Option<String>,
224 cached_width: Option<usize>,
225 cached_lines: Vec<String>,
226}
227
228impl Markdown {
229 pub fn new(
230 text: impl Into<String>,
231 padding_x: usize,
232 padding_y: usize,
233 theme: MarkdownTheme,
234 default_text_style: Option<DefaultTextStyle>,
235 ) -> Self {
236 Self {
237 text: text.into(),
238 padding_x,
239 padding_y,
240 theme,
241 default_text_style,
242 cached_text: None,
243 cached_width: None,
244 cached_lines: Vec::new(),
245 }
246 }
247
248 pub fn set_text(&mut self, text: impl Into<String>) {
249 self.text = text.into();
250 self.invalidate();
251 }
252
253 pub fn cached_text_matches(&self, other: &str) -> bool {
254 self.cached_text.as_deref() == Some(&self.text) && self.text == other
255 }
256
257 pub fn get_text(&self) -> &str {
258 &self.text
259 }
260
261 fn build_default_ctx(&self) -> InlineCtx {
264 InlineCtx::new(self.build_default_apply_fn())
265 }
266
267 fn build_default_apply_fn(&self) -> Arc<dyn Fn(&str) -> String> {
268 let style = &self.default_text_style;
269 let theme = &self.theme;
270
271 let color: Option<StyleFn> = style.as_ref().and_then(|s| s.color.clone());
272 let bold = style.as_ref().map(|s| s.bold).unwrap_or(false);
273 let italic = style.as_ref().map(|s| s.italic).unwrap_or(false);
274 let strikethrough = style.as_ref().map(|s| s.strikethrough).unwrap_or(false);
275 let underline = style.as_ref().map(|s| s.underline).unwrap_or(false);
276 let theme_bold = theme.bold.clone();
277 let theme_italic = theme.italic.clone();
278 let theme_strikethrough = theme.strikethrough.clone();
279 let theme_underline = theme.underline.clone();
280
281 Arc::new(move |text: &str| {
282 let mut styled = text.to_string();
283 if let Some(ref color_fn) = color {
284 styled = color_fn(&styled);
285 }
286 if bold {
287 styled = theme_bold(&styled);
288 }
289 if italic {
290 styled = theme_italic(&styled);
291 }
292 if strikethrough {
293 styled = theme_strikethrough(&styled);
294 }
295 if underline {
296 styled = theme_underline(&styled);
297 }
298 styled
299 })
300 }
301
302 fn heading_ctx(&self, level: u8) -> InlineCtx {
303 let theme_heading = self.theme.heading.clone();
304 let theme_bold = self.theme.bold.clone();
305 let theme_underline = self.theme.underline.clone();
306
307 let style_fn: Arc<dyn Fn(&str) -> String> = match level {
308 1 => Arc::new(move |text: &str| theme_heading(&theme_bold(&theme_underline(text)))),
309 _ => Arc::new(move |text: &str| theme_heading(&theme_bold(text))),
310 };
311 InlineCtx::new(style_fn)
312 }
313
314 fn quote_ctx(&self) -> InlineCtx {
315 let theme_quote = self.theme.quote.clone();
316 let theme_italic = self.theme.italic.clone();
317 let style_fn: Arc<dyn Fn(&str) -> String> =
318 Arc::new(move |text: &str| theme_quote(&theme_italic(text)));
319 InlineCtx::new(style_fn)
320 }
321
322 fn collect_float_candidates<'a>(&self, root: &'a AstNode<'a>) -> Vec<&'a AstNode<'a>> {
342 let mut candidates: Vec<&'a AstNode<'a>> = Vec::new();
343
344 for node in root.descendants() {
345 let val = node.data.borrow();
346 let is_floatable = matches!(
347 val.value,
348 NodeValue::Heading(_) | NodeValue::CodeBlock(_) | NodeValue::BlockQuote
349 );
350 if !is_floatable {
351 continue;
352 }
353 let mut ancestor = node.parent();
355 let mut inside_list = false;
356 while let Some(anc) = ancestor {
357 let av = anc.data.borrow();
358 match av.value {
359 NodeValue::List(_) | NodeValue::Item { .. } => {
360 inside_list = true;
361 break;
362 }
363 NodeValue::Document => break,
364 _ => {}
365 }
366 ancestor = anc.parent();
367 }
368 if inside_list {
369 candidates.push(node);
370 }
371 }
372 candidates
373 }
374
375 fn find_enclosing_list<'a>(&self, node: &'a AstNode<'a>) -> Option<&'a AstNode<'a>> {
377 let mut ancestor = node.parent();
378 while let Some(anc) = ancestor {
379 let av = anc.data.borrow();
380 if matches!(av.value, NodeValue::List(_)) {
381 return Some(anc);
382 }
383 ancestor = anc.parent();
384 }
385 None
386 }
387
388 fn float_block_nodes<'a>(&self, root: &'a AstNode<'a>) {
390 let candidates = self.collect_float_candidates(root);
391 for node in candidates {
392 if node.parent().is_none() {
394 continue;
395 }
396 let Some(list_node) = self.find_enclosing_list(node) else {
397 continue;
398 };
399
400 node.detach();
402
403 list_node.insert_after(node);
405 }
406 }
407
408 fn comrak_options() -> Options<'static> {
411 use comrak::options::Extension;
412 Options {
413 extension: Extension {
414 strikethrough: true,
415 table: true,
416 autolink: true,
417 tasklist: true,
418 tagfilter: false,
419 ..Extension::default()
420 },
421 ..Options::default()
422 }
423 }
424}
425
426impl Component for Markdown {
427 fn render(&mut self, width: usize) -> Vec<String> {
428 if self.cached_text.as_deref() == Some(&self.text) && self.cached_width == Some(width) {
430 return self.cached_lines.clone();
431 }
432
433 if self.text.is_empty() || self.text.trim().is_empty() {
435 self.cached_text = Some(self.text.clone());
436 self.cached_width = Some(width);
437 self.cached_lines = Vec::new();
438 return Vec::new();
439 }
440
441 let content_width = width.saturating_sub(2 * self.padding_x).max(1);
442
443 let arena = Arena::new();
445 let normalized = self.text.replace('\t', " ");
446 let opts = Self::comrak_options();
447 let root = parse_document(&arena, &normalized, &opts);
448
449 self.float_block_nodes(root);
451
452 let rendered = self.render_node_lines(root, content_width, 0);
454
455 let mut wrapped: Vec<String> = Vec::new();
457 for line in &rendered {
458 for wl in wrap_text_with_ansi(line, content_width) {
459 wrapped.push(wl);
460 }
461 }
462
463 let left_margin = " ".repeat(self.padding_x);
465 let right_margin = " ".repeat(self.padding_x);
466 let mut content_lines: Vec<String> = Vec::new();
467 for line in &wrapped {
468 let line_with_margins = format!("{}{}{}", left_margin, line, right_margin);
469 let visible = visible_width(&line_with_margins);
470 let padded = if visible < width {
471 format!("{}{}", line_with_margins, " ".repeat(width - visible))
472 } else {
473 line_with_margins
474 };
475 content_lines.push(padded);
476 }
477
478 let empty_line = " ".repeat(width);
479 let mut result = Vec::new();
480 for _ in 0..self.padding_y {
481 result.push(empty_line.clone());
482 }
483 result.extend(content_lines);
484 for _ in 0..self.padding_y {
485 result.push(empty_line.clone());
486 }
487
488 self.cached_text = Some(self.text.clone());
490 self.cached_width = Some(width);
491 self.cached_lines = result.clone();
492
493 if result.is_empty() {
494 vec![String::new()]
495 } else {
496 result
497 }
498 }
499
500 fn invalidate(&mut self) {
501 self.cached_text = None;
502 self.cached_width = None;
503 self.cached_lines.clear();
504 }
505}
506
507impl Markdown {
510 fn should_add_block_spacing(current: &NodeValue, next: &NodeValue) -> bool {
521 match current {
522 NodeValue::Paragraph => !matches!(next, NodeValue::List(_)),
523 NodeValue::List(_) => false,
524 NodeValue::Heading(_)
525 | NodeValue::CodeBlock(_)
526 | NodeValue::BlockQuote
527 | NodeValue::Table(_)
528 | NodeValue::ThematicBreak => true,
529 _ => false,
530 }
531 }
532
533 fn render_node_lines<'a>(
534 &self,
535 node: &'a AstNode<'a>,
536 width: usize,
537 list_depth: usize,
538 ) -> Vec<String> {
539 let val = node.data.borrow();
540 let mut lines: Vec<String> = Vec::new();
541 let children: Vec<_> = node.children().collect();
542
543 match &val.value {
544 NodeValue::Document => {
545 for (i, child) in children.iter().enumerate() {
546 let child_lines = self.render_node_lines(child, width, 0);
547 let is_last = i + 1 == children.len();
548 if child_lines.is_empty() && is_last {
549 continue;
550 }
551 lines.extend(child_lines);
552 if !is_last {
553 let current_val = child.data.borrow();
554 let next_val = children[i + 1].data.borrow();
555 if Self::should_add_block_spacing(¤t_val.value, &next_val.value) {
556 lines.push(String::new());
557 }
558 }
559 }
560 }
561
562 NodeValue::Paragraph => {
563 let ctx = self.build_default_ctx();
564 let text = self.render_inline_children(&children, &ctx);
565 if !text.is_empty() {
566 lines.push(text);
567 }
568 }
569
570 NodeValue::Heading(h) => {
571 let ctx = self.heading_ctx(h.level);
572 let content = self.render_inline_children(&children, &ctx);
573 let styled = if h.level >= 3 {
574 let prefix = format!("{} ", "#".repeat(h.level as usize));
575 format!("{}{}", (ctx.apply_text)(&prefix), content)
576 } else {
577 content
578 };
579 lines.push(styled);
580 }
581
582 NodeValue::CodeBlock(cb) => {
583 self.render_code_block(cb, &mut lines);
584 }
585
586 NodeValue::List(_lst) => {
587 let list_lines = self.render_list(node, children.clone(), width, list_depth);
588 lines.extend(list_lines);
589 }
590
591 NodeValue::Item(_) => {
592 for child in &children {
594 lines.extend(self.render_node_lines(child, width, list_depth));
595 }
596 }
597
598 NodeValue::BlockQuote => {
599 lines.extend(self.render_blockquote(&children, width));
600 }
601
602 NodeValue::Table(tbl) => {
603 lines.extend(self.render_table(node, tbl, &children, width));
604 }
605
606 NodeValue::ThematicBreak => {
607 lines.push((self.theme.hr)(&"─".repeat(width.min(80))));
608 }
609
610 NodeValue::HtmlBlock(hb) => {
611 let ctx = self.build_default_ctx();
612 for line in hb.literal.lines() {
613 let trimmed = line.trim();
614 if !trimmed.is_empty() {
615 lines.push((ctx.apply_text)(trimmed));
616 }
617 }
618 }
619
620 NodeValue::FrontMatter(_) => {
621 }
623
624 _ => {
625 let ctx = self.build_default_ctx();
627 let text = self.render_inline_children(&children, &ctx);
628 if !text.is_empty() {
629 lines.push(text);
630 }
631 }
632 }
633
634 lines
635 }
636
637 fn render_code_block(&self, cb: &NodeCodeBlock, lines: &mut Vec<String>) {
640 let border = self.theme.code_block_border.clone();
641 let code_fn = self.theme.code_block.clone();
642 let indent = &self.theme.code_block_indent;
643
644 let lang = if cb.info.is_empty() {
645 None
646 } else {
647 Some(cb.info.as_str())
648 };
649
650 lines.push(border(&format!("```{}", lang.unwrap_or(""))));
652
653 if let Some(ref highlight) = self.theme.highlight_code {
655 let hl_lines = highlight(&cb.literal, lang);
656 for hl in hl_lines {
657 lines.push(format!("{}{}", indent, hl));
658 }
659 } else {
660 for code_line in cb.literal.split('\n') {
661 lines.push(format!("{}{}", indent, code_fn(code_line)));
662 }
663 }
664
665 lines.push(border("```"));
667 }
668
669 fn render_list<'a>(
672 &self,
673 node: &'a AstNode<'a>,
674 children: Vec<&'a AstNode<'a>>,
675 width: usize,
676 depth: usize,
677 ) -> Vec<String> {
678 let mut result: Vec<String> = Vec::new();
679 let val = node.data.borrow();
680 let NodeValue::List(lst) = &val.value else {
681 return result;
682 };
683
684 let indent_str = " ".repeat(depth.min(8));
685 let start_number = lst.start.max(1);
686 let mut item_index: u64 = 0;
687
688 for child in &children {
689 let cv = child.data.borrow();
690 let is_item = matches!(cv.value, NodeValue::Item(_) | NodeValue::TaskItem(_));
691 if !is_item {
692 continue;
693 }
694 item_index += 1;
695
696 let mut task_marker = String::new();
698 if let NodeValue::TaskItem(ti) = &cv.value {
699 task_marker = if ti.symbol.is_some() {
700 "[x] ".to_string()
701 } else {
702 "[ ] ".to_string()
703 };
704 } else {
705 for ic in child.children() {
707 if let NodeValue::TaskItem(ti) = &ic.data.borrow().value {
708 task_marker = if ti.symbol.is_some() {
709 "[x] ".to_string()
710 } else {
711 "[ ] ".to_string()
712 };
713 break;
714 }
715 }
716 }
717
718 let raw_marker = if lst.list_type == ListType::Ordered {
719 format!("{}. ", start_number + item_index as usize - 1)
720 } else {
721 "- ".to_string()
722 };
723 let marker = format!("{}{}", raw_marker, task_marker);
724
725 let bullet_prefix = indent_str.clone() + &(self.theme.list_bullet)(&marker);
726 let continuation_prefix = indent_str.clone() + &" ".repeat(visible_width(&marker));
727 let item_width = width.saturating_sub(visible_width(&bullet_prefix)).max(1);
728 let mut rendered_any = false;
729
730 let item_children: Vec<_> = child.children().collect();
732 for item_child in &item_children {
733 let ic_val = item_child.data.borrow();
734 match &ic_val.value {
735 NodeValue::List(_) => {
736 let nested = self.render_list(
738 item_child,
739 item_child.children().collect(),
740 width,
741 depth + 1,
742 );
743 result.extend(nested);
744 rendered_any = true;
745 }
746 NodeValue::Paragraph => {
747 let ctx = self.build_default_ctx();
748 let text = self.render_inline_children(
749 &item_child.children().collect::<Vec<_>>(),
750 &ctx,
751 );
752 for wl in wrap_text_with_ansi(&text, item_width) {
753 let prefix = if rendered_any {
754 &continuation_prefix
755 } else {
756 &bullet_prefix
757 };
758 result.push(format!("{}{}", prefix, wl));
759 rendered_any = true;
760 }
761 }
762 _ => {
763 let block_lines = self.render_node_lines(item_child, item_width, depth);
765 for bl in &block_lines {
766 for wl in wrap_text_with_ansi(bl, item_width) {
767 let prefix = if rendered_any {
768 &continuation_prefix
769 } else {
770 &bullet_prefix
771 };
772 result.push(format!("{}{}", prefix, wl));
773 rendered_any = true;
774 }
775 }
776 }
777 }
778 }
779
780 if !rendered_any {
781 result.push(bullet_prefix);
782 }
783 }
784
785 result
786 }
787
788 fn render_blockquote<'a>(&self, children: &[&'a AstNode<'a>], width: usize) -> Vec<String> {
791 let quote_content_width = width.saturating_sub(2).max(1);
792 let quote_ctx = self.quote_ctx();
793 let quote_style_prefix = get_style_prefix(&|s: &str| (quote_ctx.apply_text)(s));
794 let qborder = self.theme.quote_border.clone();
795
796 let mut inner_lines: Vec<String> = Vec::new();
797 for (i, child) in children.iter().enumerate() {
798 let child_lines = self.render_node_lines(child, quote_content_width, 0);
799 let is_last = i + 1 == children.len();
800 inner_lines.extend(child_lines);
801 if !is_last {
802 let current_val = child.data.borrow();
803 let next_val = children[i + 1].data.borrow();
804 if Self::should_add_block_spacing(¤t_val.value, &next_val.value) {
805 inner_lines.push(String::new());
806 }
807 }
808 }
809
810 while inner_lines.last().is_some_and(|l| l.is_empty()) {
812 inner_lines.pop();
813 }
814
815 let mut result: Vec<String> = Vec::new();
816 for line in &inner_lines {
817 let restyled = if !quote_style_prefix.is_empty() {
818 line.replace("\x1b[0m", &format!("\x1b[0m{}", quote_style_prefix))
819 } else {
820 line.clone()
821 };
822 let styled = (quote_ctx.apply_text)(&restyled);
823 let wrapped = wrap_text_with_ansi(&styled, quote_content_width);
824 for wl in wrapped {
825 result.push(format!("{} {}", qborder("│"), wl));
826 }
827 }
828
829 result
830 }
831
832 fn render_table<'a>(
835 &self,
836 _node: &'a AstNode<'a>,
837 tbl: &NodeTable,
838 children: &[&'a AstNode<'a>],
839 width: usize,
840 ) -> Vec<String> {
841 let ctx = self.build_default_ctx();
842 let num_cols = tbl.num_columns;
843 if num_cols == 0 {
844 return Vec::new();
845 }
846
847 let border_overhead = 3 * num_cols + 1;
848 let available_for_cells = width.saturating_sub(border_overhead);
849 if available_for_cells < num_cols {
850 return Vec::new();
851 }
852
853 let mut header_cells: Vec<Vec<String>> = Vec::new();
855 let mut body_rows: Vec<Vec<Vec<String>>> = Vec::new();
856
857 for child in children {
858 let cv = child.data.borrow();
859 if let NodeValue::TableRow(is_header) = &cv.value {
860 let row_cells: Vec<Vec<String>> = child
861 .children()
862 .filter_map(|cell_node| {
863 let cell_val = cell_node.data.borrow();
864 if matches!(cell_val.value, NodeValue::TableCell) {
865 let cell_children: Vec<_> = cell_node.children().collect();
866 let text = self.render_inline_children(&cell_children, &ctx);
867 Some(text.split('\n').map(|s| s.to_string()).collect::<Vec<_>>())
868 } else {
869 None
870 }
871 })
872 .collect();
873
874 if *is_header {
875 header_cells = row_cells;
876 } else {
877 body_rows.push(row_cells);
878 }
879 }
880 }
881
882 if header_cells.is_empty() {
883 return Vec::new();
884 }
885
886 let max_unbroken_word_width = 30;
888 let mut natural_widths = vec![0usize; num_cols];
889 let mut min_word_widths = vec![1usize; num_cols];
890
891 let update_widths =
892 |cells: &[Vec<String>], natural: &mut [usize], min_word: &mut [usize]| {
893 for (i, cell_lines) in cells.iter().enumerate() {
894 if i >= num_cols {
895 break;
896 }
897 for cl in cell_lines {
898 let vw = visible_width(cl);
899 natural[i] = natural[i].max(vw);
900 let longest = cl
901 .split_whitespace()
902 .map(visible_width)
903 .max()
904 .unwrap_or(0)
905 .min(max_unbroken_word_width);
906 min_word[i] = min_word[i].max(longest.max(1));
907 }
908 }
909 };
910
911 update_widths(&header_cells, &mut natural_widths, &mut min_word_widths);
912 for row_cells in &body_rows {
913 update_widths(row_cells, &mut natural_widths, &mut min_word_widths);
914 }
915
916 let total_natural: usize = natural_widths.iter().sum();
917 let mut column_widths = vec![0usize; num_cols];
918
919 if total_natural + border_overhead <= width {
920 for i in 0..num_cols {
921 column_widths[i] = natural_widths[i].max(min_word_widths[i]);
922 }
923 } else {
924 let min_total: usize = min_word_widths.iter().sum();
925 let extra = available_for_cells.saturating_sub(min_total);
926 let grow_potential: usize = natural_widths
927 .iter()
928 .zip(min_word_widths.iter())
929 .map(|(n, m)| n.saturating_sub(*m))
930 .sum();
931
932 if min_total <= available_for_cells {
933 for i in 0..num_cols {
934 let n = natural_widths[i];
935 let m = min_word_widths[i];
936 let potential = n.saturating_sub(m);
937 let grow = if grow_potential > 0 {
938 extra
939 .checked_mul(potential)
940 .map(|p| p / grow_potential)
941 .unwrap_or(0)
942 } else {
943 0
944 };
945 column_widths[i] = m + grow;
946 }
947 let allocated: usize = column_widths.iter().sum();
948 let mut remaining = available_for_cells.saturating_sub(allocated);
949 for i in 0..num_cols {
950 if remaining == 0 {
951 break;
952 }
953 if column_widths[i] < natural_widths[i] {
954 column_widths[i] += 1;
955 remaining -= 1;
956 }
957 }
958 } else {
959 let base = available_for_cells / num_cols;
960 let rem = available_for_cells % num_cols;
961 for (i, cw) in column_widths.iter_mut().enumerate() {
962 *cw = base + if i < rem { 1 } else { 0 };
963 }
964 }
965 }
966
967 let mut result: Vec<String> = Vec::new();
969
970 let top_cells: Vec<String> = column_widths.iter().map(|w| "─".repeat(*w)).collect();
972 result.push(format!("┌─{}─┐", top_cells.join("─┬─")));
973
974 let header_lines = self.render_table_row(&header_cells, &column_widths, num_cols, true);
976 result.extend(header_lines);
977
978 let sep_cells: Vec<String> = column_widths.iter().map(|w| "─".repeat(*w)).collect();
980 result.push(format!("├─{}─┤", sep_cells.join("─┼─")));
981
982 for (ri, row_cells) in body_rows.iter().enumerate() {
984 let row_lines = self.render_table_row(row_cells, &column_widths, num_cols, false);
985 result.extend(row_lines);
986 if ri < body_rows.len() - 1 {
987 result.push(format!("├─{}─┤", sep_cells.join("─┼─")));
988 }
989 }
990
991 let bottom_cells: Vec<String> = column_widths.iter().map(|w| "─".repeat(*w)).collect();
993 result.push(format!("└─{}─┘", bottom_cells.join("─┴─")));
994
995 result
996 }
997
998 fn render_table_row(
999 &self,
1000 cells: &[Vec<String>],
1001 column_widths: &[usize],
1002 num_cols: usize,
1003 is_header: bool,
1004 ) -> Vec<String> {
1005 if cells.is_empty() {
1006 return Vec::new();
1007 }
1008
1009 let mut wrapped_cells: Vec<Vec<String>> = Vec::new();
1010 for (i, cell_lines) in cells.iter().enumerate() {
1011 if i >= num_cols {
1012 break;
1013 }
1014 let col_width = column_widths[i];
1015 let mut wrapped: Vec<String> = Vec::new();
1016 for cl in cell_lines {
1017 for wl in wrap_text_with_ansi(cl, col_width) {
1018 wrapped.push(wl);
1019 }
1020 }
1021 if wrapped.is_empty() {
1022 wrapped.push(String::new());
1023 }
1024 wrapped_cells.push(wrapped);
1025 }
1026
1027 let max_lines = wrapped_cells.iter().map(|c| c.len()).max().unwrap_or(1);
1028 for cell in &mut wrapped_cells {
1029 while cell.len() < max_lines {
1030 cell.push(String::new());
1031 }
1032 }
1033
1034 let mut result: Vec<String> = Vec::new();
1035 for line_idx in 0..max_lines {
1036 let mut row_parts: Vec<String> = Vec::new();
1037 for (col_idx, cell) in wrapped_cells.iter().enumerate() {
1038 let text = cell.get(line_idx).map(|s| s.as_str()).unwrap_or("");
1039 let vw = visible_width(text);
1040 let padding = column_widths[col_idx].saturating_sub(vw);
1041 let padded = if is_header {
1042 (self.theme.bold)(&format!("{}{}", text, " ".repeat(padding)))
1043 } else {
1044 format!("{}{}", text, " ".repeat(padding))
1045 };
1046 row_parts.push(padded);
1047 }
1048 result.push(format!("│ {} │", row_parts.join(" │ ")));
1049 }
1050
1051 result
1052 }
1053
1054 fn render_inline_children<'a>(&self, children: &[&'a AstNode<'a>], ctx: &InlineCtx) -> String {
1058 let mut result = String::new();
1059
1060 for node in children {
1061 let val = node.data.borrow();
1062 match &val.value {
1063 NodeValue::Text(t) => {
1064 result.push_str(&split_newline_apply(t, &*ctx.apply_text));
1065 }
1066 NodeValue::Code(c) => {
1067 result.push_str(&(self.theme.code)(&c.literal));
1068 result.push_str(&ctx.style_prefix);
1069 }
1070 NodeValue::Emph => {
1071 let inner =
1072 self.render_inline_children(&node.children().collect::<Vec<_>>(), ctx);
1073 result.push_str(&(self.theme.italic)(&inner));
1074 result.push_str(&ctx.style_prefix);
1075 }
1076 NodeValue::Strong => {
1077 let inner =
1078 self.render_inline_children(&node.children().collect::<Vec<_>>(), ctx);
1079 result.push_str(&(self.theme.bold)(&inner));
1080 result.push_str(&ctx.style_prefix);
1081 }
1082 NodeValue::Strikethrough => {
1083 let inner =
1084 self.render_inline_children(&node.children().collect::<Vec<_>>(), ctx);
1085 result.push_str(&(self.theme.strikethrough)(&inner));
1086 result.push_str(&ctx.style_prefix);
1087 }
1088 NodeValue::Link(link) => {
1089 let inner =
1090 self.render_inline_children(&node.children().collect::<Vec<_>>(), ctx);
1091 let styled_link = (self.theme.link)(&(self.theme.underline)(&inner));
1092 if hyperlinks_supported() {
1093 result.push_str(&hyperlink(&styled_link, &link.url));
1094 } else {
1095 let href_clean = if let Some(mailto) = link.url.strip_prefix("mailto:") {
1096 mailto
1097 } else {
1098 &link.url
1099 };
1100 if inner.trim() == href_clean || inner.trim() == link.url {
1101 result.push_str(&styled_link);
1102 } else {
1103 result.push_str(&styled_link);
1104 result.push_str(&(self.theme.link_url)(&format!(" ({})", link.url)));
1105 }
1106 }
1107 result.push_str(&ctx.style_prefix);
1108 }
1109 NodeValue::Image(_) => {
1110 }
1112 NodeValue::SoftBreak | NodeValue::LineBreak => {
1113 result.push('\n');
1114 }
1115 NodeValue::HtmlInline(h) => {
1116 result.push_str(&(ctx.apply_text)(h.trim()));
1117 }
1118
1119 _ => {
1120 }
1122 }
1123 }
1124
1125 while result.ends_with(&ctx.style_prefix) && !ctx.style_prefix.is_empty() {
1127 result = result[..result.len() - ctx.style_prefix.len()].to_string();
1128 }
1129
1130 result
1131 }
1132}
1133
1134fn split_newline_apply(text: &str, apply: &dyn Fn(&str) -> String) -> String {
1138 let segments: Vec<&str> = text.split('\n').collect();
1139 segments
1140 .iter()
1141 .enumerate()
1142 .map(|(i, s)| {
1143 if i > 0 {
1144 format!("\n{}", apply(s))
1145 } else {
1146 apply(s)
1147 }
1148 })
1149 .collect()
1150}
1151
1152pub fn create_highlight_fn() -> Option<HighlightFn> {
1156 #[cfg(feature = "syntect")]
1157 {
1158 Some(Arc::new(highlight_code))
1159 }
1160 #[cfg(not(feature = "syntect"))]
1161 {
1162 None
1163 }
1164}
1165
1166#[cfg(feature = "syntect")]
1167pub fn highlight_code(code: &str, lang: Option<&str>) -> Vec<String> {
1168 use std::sync::LazyLock;
1169
1170 use syntect::{
1171 easy::HighlightLines,
1172 highlighting::ThemeSet,
1173 parsing::SyntaxSet,
1174 util::{LinesWithEndings, as_24_bit_terminal_escaped},
1175 };
1176
1177 static SYNTAX_SET: LazyLock<SyntaxSet> = LazyLock::new(SyntaxSet::load_defaults_newlines);
1178
1179 static THEME_SET: LazyLock<ThemeSet> = LazyLock::new(ThemeSet::load_defaults);
1180
1181 let ss = &SYNTAX_SET;
1182 let ts = &THEME_SET;
1183
1184 let syntax = lang
1185 .and_then(|l| ss.find_syntax_by_token(l))
1186 .unwrap_or_else(|| ss.find_syntax_plain_text());
1187
1188 let theme = ts
1189 .themes
1190 .get("base16-ocean.dark")
1191 .or_else(|| ts.themes.iter().next().map(|(_, t)| t));
1192
1193 let Some(theme) = theme else {
1194 return code.split('\n').map(|s| s.to_string()).collect();
1195 };
1196
1197 let mut highlighter = HighlightLines::new(syntax, theme);
1198 let mut result = Vec::new();
1199
1200 for line in LinesWithEndings::from(code) {
1201 match highlighter.highlight_line(line, ss) {
1202 Ok(ranges) => {
1203 let escaped = as_24_bit_terminal_escaped(&ranges, false);
1204 let trimmed = escaped.trim_end_matches('\n');
1205 if trimmed.is_empty() {
1206 result.push(String::new());
1207 } else {
1208 result.push(format!("{}\x1b[0m", trimmed));
1209 }
1210 }
1211 Err(_) => {
1212 result.push(line.trim_end_matches('\n').to_string());
1213 }
1214 }
1215 }
1216
1217 result
1218}
1219
1220pub fn path_to_language(path: &str) -> Option<&'static str> {
1222 let ext = path.rsplit('.').next()?.to_lowercase();
1223 let lang = match ext.as_str() {
1224 "ts" | "tsx" => "typescript",
1225 "js" | "jsx" | "mjs" | "cjs" => "javascript",
1226 "py" => "python",
1227 "rb" => "ruby",
1228 "rs" => "rust",
1229 "go" => "go",
1230 "java" => "java",
1231 "kt" => "kotlin",
1232 "swift" => "swift",
1233 "c" | "h" => "c",
1234 "cpp" | "cc" | "cxx" | "hpp" => "cpp",
1235 "cs" => "csharp",
1236 "php" => "php",
1237 "sh" | "bash" | "zsh" => "bash",
1238 "ps1" => "powershell",
1239 "sql" => "sql",
1240 "html" | "htm" => "html",
1241 "css" | "scss" | "sass" | "less" => "css",
1242 "json" => "json",
1243 "yaml" | "yml" => "yaml",
1244 "toml" => "toml",
1245 "xml" => "xml",
1246 "md" | "markdown" => "markdown",
1247 "clj" | "cljs" | "cljc" => "clojure",
1248 "ex" | "exs" => "elixir",
1249 "hs" => "haskell",
1250 "lua" => "lua",
1251 _ => return None,
1252 };
1253 Some(lang)
1254}
1255
1256#[cfg(test)]
1259mod tests {
1260 use super::*;
1261
1262 fn test_theme() -> MarkdownTheme {
1263 MarkdownTheme::new(
1264 Arc::new(|s| format!("\x1b[33m{}\x1b[39m", s)),
1265 Arc::new(|s| format!("\x1b[34m{}\x1b[39m", s)),
1266 Arc::new(|s| format!("\x1b[90m{}\x1b[39m", s)),
1267 Arc::new(|s| format!("\x1b[36m{}\x1b[39m", s)),
1268 Arc::new(|s| format!("\x1b[32m{}\x1b[39m", s)),
1269 Arc::new(|s| format!("\x1b[90m{}\x1b[39m", s)),
1270 Arc::new(|s| format!("\x1b[90m{}\x1b[39m", s)),
1271 Arc::new(|s| format!("\x1b[90m{}\x1b[39m", s)),
1272 Arc::new(|s| format!("\x1b[90m{}\x1b[39m", s)),
1273 Arc::new(|s| format!("\x1b[33m{}\x1b[39m", s)),
1274 Arc::new(|s| format!("\x1b[1m{}\x1b[22m", s)),
1275 Arc::new(|s| format!("\x1b[3m{}\x1b[23m", s)),
1276 Arc::new(|s| format!("\x1b[9m{}\x1b[29m", s)),
1277 Arc::new(|s| format!("\x1b[4m{}\x1b[24m", s)),
1278 )
1279 }
1280
1281 #[test]
1282 fn test_basic_paragraph() {
1283 let theme = test_theme();
1284 let mut md = Markdown::new("hello world", 0, 0, theme, None);
1285 let lines = md.render(80);
1286 let all = lines.join("\n");
1287 assert!(all.contains("hello world"));
1288 assert!(!all.contains("\x1b["));
1289 }
1290
1291 #[test]
1292 fn test_heading_h1() {
1293 let theme = test_theme();
1294 let mut md = Markdown::new("# Heading 1", 0, 0, theme, None);
1295 let lines = md.render(80);
1296 let all = lines.join("\n");
1297 assert!(all.contains("Heading 1"));
1298 assert!(all.contains("\x1b[1m"));
1299 assert!(all.contains("\x1b[33m"));
1300 }
1301
1302 #[test]
1303 fn test_heading_h3_marker() {
1304 let theme = test_theme();
1305 let mut md = Markdown::new("### Heading 3", 0, 0, theme, None);
1306 let lines = md.render(80);
1307 let all = lines.join("\n");
1308 assert!(all.contains("###") || all.contains("Heading 3"));
1309 }
1310
1311 #[test]
1312 fn test_bold_italic() {
1313 let theme = test_theme();
1314 let mut md = Markdown::new("**bold** and *italic*", 0, 0, theme, None);
1315 let lines = md.render(80);
1316 let all = lines.join("\n");
1317 assert!(all.contains("bold"));
1318 assert!(all.contains("italic"));
1319 assert!(all.contains("\x1b[1m"));
1320 assert!(all.contains("\x1b[3m"));
1321 }
1322
1323 #[test]
1324 fn test_codespan() {
1325 let theme = test_theme();
1326 let mut md = Markdown::new("use `code` here", 0, 0, theme, None);
1327 let lines = md.render(80);
1328 let all = lines.join("\n");
1329 assert!(all.contains("code"));
1330 assert!(all.contains("\x1b[36m"));
1331 }
1332
1333 #[test]
1334 fn test_inline_code_style_restore() {
1335 let theme = test_theme();
1336 let mut md = Markdown::new("**bold `code` end**", 0, 0, theme, None);
1337 let lines = md.render(80);
1338 let all = lines.join("\n");
1339 assert!(all.contains("bold"));
1340 assert!(all.contains("code"));
1341 assert!(all.contains("end"));
1342 }
1343
1344 #[test]
1345 fn test_code_block() {
1346 let theme = test_theme();
1347 let mut md = Markdown::new("```\nlet x = 1;\n```", 0, 0, theme, None);
1348 let lines = md.render(80);
1349 let all = lines.join("\n");
1350 assert!(all.contains("let x = 1;"));
1351 assert!(all.contains("\x1b[32m"));
1352 assert!(all.contains("```"));
1353 }
1354
1355 #[test]
1356 fn test_fenced_code_with_language() {
1357 let theme = test_theme();
1358 let mut md = Markdown::new("```rust\nfn main() {}\n```", 0, 0, theme, None);
1359 let lines = md.render(80);
1360 let all = lines.join("\n");
1361 assert!(all.contains("```rust"));
1362 assert!(all.contains("fn main() {}"));
1363 }
1364
1365 #[test]
1366 fn test_unordered_list() {
1367 let theme = test_theme();
1368 let mut md = Markdown::new("- item 1\n- item 2\n- item 3", 0, 0, theme, None);
1369 let lines = md.render(80);
1370 let all = lines.join("\n");
1371 assert!(all.contains("item 1"));
1372 assert!(all.contains("item 2"));
1373 assert!(all.contains("item 3"));
1374 }
1375
1376 #[test]
1377 fn test_strikethrough() {
1378 let theme = test_theme();
1379 let mut md = Markdown::new("~~struck~~", 0, 0, theme, None);
1380 let lines = md.render(80);
1381 let all = lines.join("\n");
1382 assert!(all.contains("struck"));
1383 assert!(all.contains("\x1b[9m"));
1384 }
1385
1386 #[test]
1387 fn test_link_inline() {
1388 let theme = test_theme();
1389 let mut md = Markdown::new("[text](https://example.com)", 0, 0, theme, None);
1390 let lines = md.render(80);
1391 let all = lines.join("\n");
1392 assert!(all.contains("text"));
1393 assert!(all.contains("https://example.com"));
1394 }
1395
1396 #[test]
1397 fn test_empty_text() {
1398 let theme = test_theme();
1399 let mut md = Markdown::new("", 0, 0, theme, None);
1400 let lines = md.render(80);
1401 assert!(lines.is_empty() || (lines.len() == 1 && lines[0].is_empty()));
1402 }
1403
1404 #[test]
1405 fn test_whitespace_only() {
1406 let theme = test_theme();
1407 let mut md = Markdown::new(" ", 0, 0, theme, None);
1408 let lines = md.render(80);
1409 assert!(lines.is_empty() || (lines.len() == 1 && lines[0].is_empty()));
1410 }
1411
1412 #[test]
1413 fn test_horizontal_rule() {
1414 let theme = test_theme();
1415 let mut md = Markdown::new("---", 0, 0, theme, None);
1416 let lines = md.render(80);
1417 let all = lines.join("\n");
1418 assert!(all.contains('─'));
1419 }
1420
1421 #[test]
1422 fn test_padding_x() {
1423 let theme = test_theme();
1424 let mut md = Markdown::new("hello", 2, 0, theme, None);
1425 let lines = md.render(20);
1426 assert_eq!(visible_width(&lines[0]), 20);
1427 assert!(lines[0].starts_with(" "));
1428 }
1429
1430 #[test]
1431 fn test_padding_y() {
1432 let theme = test_theme();
1433 let mut md = Markdown::new("hello", 0, 1, theme, None);
1434 let lines = md.render(20);
1435 assert_eq!(lines.len(), 3);
1436 }
1437
1438 #[test]
1439 fn test_cache_hit() {
1440 let theme = test_theme();
1441 let mut md = Markdown::new("hello", 1, 0, theme, None);
1442 let a = md.render(20);
1443 let b = md.render(20);
1444 assert_eq!(a, b);
1445 }
1446
1447 #[test]
1448 fn test_cache_invalidation() {
1449 let theme = test_theme();
1450 let mut md = Markdown::new("hello", 1, 0, theme, None);
1451 let a = md.render(20);
1452 md.set_text("world");
1453 let b = md.render(20);
1454 assert_ne!(a, b);
1455 }
1456
1457 #[test]
1458 fn test_blockquote() {
1459 let theme = test_theme();
1460 let mut md = Markdown::new("> quoted text", 0, 0, theme, None);
1461 let lines = md.render(80);
1462 let all = lines.join("\n");
1463 assert!(all.contains("quoted text"));
1464 assert!(all.contains("│"));
1465 }
1466
1467 #[test]
1468 fn test_task_list() {
1469 let theme = test_theme();
1470 let mut md = Markdown::new("- [x] done\n- [ ] todo", 0, 0, theme, None);
1471 let lines = md.render(80);
1472 let all = lines.join("\n");
1473 assert!(all.contains("[x]") || all.contains("done"));
1474 assert!(all.contains("[ ]") || all.contains("todo"));
1475 }
1476
1477 #[test]
1478 fn test_paragraph_spacing() {
1479 let theme = test_theme();
1480 let mut md = Markdown::new("para one\n\npara two", 0, 0, theme, None);
1481 let lines = md.render(80);
1482 assert!(lines.len() >= 2, "should have at least 2 lines");
1484 assert!(
1486 lines[0].trim_end().ends_with("para one"),
1487 "first line should contain 'para one', got {:?}",
1488 lines[0]
1489 );
1490 assert!(
1491 lines[1].trim().is_empty(),
1492 "line between paragraphs should be empty, got {:?}",
1493 lines[1]
1494 );
1495 assert!(
1496 lines[2].trim_end().ends_with("para two"),
1497 "third line should contain 'para two', got {:?}",
1498 lines[2]
1499 );
1500 }
1501
1502 #[test]
1503 fn test_tabs_replaced() {
1504 let theme = test_theme();
1505 let mut md = Markdown::new("\tindented", 0, 0, theme, None);
1506 let lines = md.render(80);
1507 let all = lines.join("\n");
1508 assert!(all.contains("indented"));
1509 }
1510
1511 #[test]
1512 fn test_default_text_style() {
1513 let theme = test_theme();
1514 let default_style = DefaultTextStyle {
1515 color: Some(Arc::new(|s| format!("\x1b[33m{}\x1b[39m", s))),
1516 bold: true,
1517 italic: false,
1518 strikethrough: false,
1519 underline: false,
1520 };
1521 let mut md = Markdown::new("styled text", 0, 0, theme, Some(default_style));
1522 let lines = md.render(80);
1523 let all = lines.join("\n");
1524 assert!(all.contains("styled text"));
1525 assert!(all.contains("\x1b[1m"));
1526 assert!(all.contains("\x1b[33m"));
1527 }
1528
1529 #[test]
1530 fn test_table_basic() {
1531 let theme = test_theme();
1532 let mut md = Markdown::new(
1533 "| H1 | H2 |\n| --- | --- |\n| A1 | B1 |\n| A2 | B2 |",
1534 0,
1535 0,
1536 theme,
1537 None,
1538 );
1539 let lines = md.render(80);
1540 let all = lines.join("\n");
1541 assert!(all.contains("H1"));
1542 assert!(all.contains("H2"));
1543 assert!(all.contains("A1"));
1544 assert!(all.contains("┌"));
1545 assert!(all.contains("└"));
1546 assert!(all.contains("│"));
1547 }
1548
1549 #[test]
1550 fn test_table_narrow_fallback() {
1551 let theme = test_theme();
1552 let mut md = Markdown::new("| A | B |\n| --- | --- |\n| 1 | 2 |", 0, 0, theme, None);
1553 let lines = md.render(10);
1554 assert!(!lines.is_empty());
1555 }
1556
1557 #[test]
1558 fn test_ordered_list() {
1559 let theme = test_theme();
1560 let mut md = Markdown::new("1. first\n2. second\n3. third", 0, 0, theme, None);
1561 let lines = md.render(80);
1562 let all = lines.join("\n");
1563 assert!(all.contains("first"));
1564 assert!(all.contains("second"));
1565 assert!(all.contains("third"));
1566 }
1567
1568 #[test]
1569 fn test_nested_list() {
1570 let theme = test_theme();
1571 let mut md = Markdown::new("- outer\n - inner\n- more", 0, 0, theme, None);
1572 let lines = md.render(80);
1573 let all = lines.join("\n");
1574 assert!(all.contains("outer"));
1575 assert!(all.contains("inner"));
1576 assert!(all.contains("more"));
1577 }
1578
1579 #[test]
1580 fn test_blockquote_nested() {
1581 let theme = test_theme();
1582 let mut md = Markdown::new("> outer\n> > nested\n> back", 0, 0, theme, None);
1583 let lines = md.render(80);
1584 let all = lines.join("\n");
1585 assert!(all.contains("outer"));
1586 assert!(all.contains("nested"));
1587 assert!(all.contains("back"));
1588 assert!(all.contains("│"));
1589 }
1590
1591 #[test]
1592 fn test_link_with_dest() {
1593 let theme = test_theme();
1594 let mut md = Markdown::new("[example](https://example.com/page)", 0, 0, theme, None);
1595 let lines = md.render(80);
1596 let all = lines.join("\n");
1597 assert!(all.contains("example"));
1598 assert!(all.contains("example.com/page"));
1599 }
1600
1601 #[test]
1602 fn test_autolink() {
1603 let theme = test_theme();
1604 let mut md = Markdown::new("<https://example.com>", 0, 0, theme, None);
1605 let lines = md.render(80);
1606 let all = lines.join("\n");
1607 assert!(all.contains("example.com"));
1608 }
1609
1610 #[test]
1611 fn test_wrap_long_text() {
1612 let theme = test_theme();
1613 let long = "this is a very long line that should definitely wrap to multiple lines when rendered in a narrow terminal column";
1614 let mut md = Markdown::new(long, 0, 0, theme, None);
1615 let lines = md.render(30);
1616 assert!(lines.len() > 1);
1617 for line in &lines {
1618 assert!(visible_width(line) <= 30);
1619 }
1620 }
1621
1622 #[test]
1623 fn test_cache_different_width() {
1624 let theme = test_theme();
1625 let mut md = Markdown::new("hello world", 1, 0, theme, None);
1626 let a = md.render(30);
1627 let b = md.render(50);
1628 assert_ne!(a, b);
1629 }
1630
1631 #[test]
1632 fn test_html_block_plain() {
1633 let theme = test_theme();
1634 let mut md = Markdown::new("<div>plain html</div>", 0, 0, theme, None);
1635 let lines = md.render(80);
1636 let all = lines.join("\n");
1637 assert!(all.contains("plain html"));
1638 }
1639
1640 #[test]
1641 fn test_bold_italic_style_restore() {
1642 let theme = test_theme();
1643 let mut md = Markdown::new("**bold `code` more bold**", 0, 0, theme, None);
1644 let lines = md.render(80);
1645 let all = lines.join("\n");
1646 assert!(all.contains("bold"));
1647 assert!(all.contains("code"));
1648 assert!(all.contains("more"));
1649 }
1650
1651 #[test]
1654 fn test_heading_inside_list_is_floated() {
1655 let theme = test_theme();
1656 let md_text = "- item\n ### heading\n - nested\n- more";
1657 let mut md = Markdown::new(md_text, 0, 0, theme, None);
1658 let lines = md.render(80);
1659 let all = lines.join("\n");
1660 assert!(all.contains("heading"), "Should contain heading text");
1663 assert!(all.contains("nested"), "Should contain nested item");
1665 assert!(all.contains("more"), "Should contain more item");
1666 }
1667
1668 #[test]
1671 fn test_code_block_inside_list_is_floated() {
1672 let theme = test_theme();
1673 let md_text = "- item\n ```python\n print('hi')\n ```\n- more";
1674 let mut md = Markdown::new(md_text, 0, 0, theme, None);
1675 let lines = md.render(80);
1676 let all = lines.join("\n");
1677 assert!(all.contains("print('hi')"), "Should contain code content");
1678 assert!(all.contains("```"), "Should have fence markers");
1679 assert!(all.contains("item"), "Should contain item text");
1680 assert!(all.contains("more"), "Should contain more item");
1681 }
1682}