1use crate::highlighter::SyntaxHighlighter;
2use colored::*;
3use mq_markdown::{Markdown, Node};
4use std::io::{self, Write};
5use std::path::Path;
6use std::sync::LazyLock;
7use terminal_size::{Height, Width, terminal_size};
8use unicode_width::UnicodeWidthStr;
9
10#[derive(Debug, Clone)]
12pub struct RenderConfig {
13 pub header_full_width_highlight: bool,
15}
16
17impl Default for RenderConfig {
18 fn default() -> Self {
19 Self {
20 header_full_width_highlight: true,
21 }
22 }
23}
24
25const LIST_BULLETS: &[&str] = &["●", "○", "◆", "◇"];
27
28static WIDTH: LazyLock<usize> = LazyLock::new(|| {
29 let size = terminal_size();
30 if let Some((Width(w), Height(_))) = size {
31 w.into()
32 } else {
33 80
34 }
35});
36
37#[derive(Debug, Clone)]
39struct Callout {
40 icon: &'static str,
41 color: colored::Color,
42 name: &'static str,
43}
44
45const CALLOUTS: &[(&str, Callout)] = &[
46 (
47 "NOTE",
48 Callout {
49 icon: "ℹ️",
50 color: colored::Color::Blue,
51 name: "Note",
52 },
53 ),
54 (
55 "TIP",
56 Callout {
57 icon: "💡",
58 color: colored::Color::Green,
59 name: "Tip",
60 },
61 ),
62 (
63 "IMPORTANT",
64 Callout {
65 icon: "❗",
66 color: colored::Color::Magenta,
67 name: "Important",
68 },
69 ),
70 (
71 "WARNING",
72 Callout {
73 icon: "⚠️",
74 color: colored::Color::Yellow,
75 name: "Warning",
76 },
77 ),
78 (
79 "CAUTION",
80 Callout {
81 icon: "🔥",
82 color: colored::Color::Red,
83 name: "Caution",
84 },
85 ),
86];
87
88fn make_clickable_link(url: &str, display_text: &str) -> String {
91 format!("\x1b]8;;{}\x1b\\{}\x1b]8;;\x1b\\", url, display_text)
93}
94
95pub fn render_markdown<W: Write>(markdown: &Markdown, writer: &mut W) -> io::Result<()> {
116 render_markdown_with_config(markdown, writer, &RenderConfig::default())
117}
118
119pub fn render_markdown_with_config<W: Write>(
125 markdown: &Markdown,
126 writer: &mut W,
127 config: &RenderConfig,
128) -> io::Result<()> {
129 let mut highlighter = SyntaxHighlighter::new();
130 let mut i = 0;
131 let len = markdown.nodes.len();
132
133 while i < len {
134 let node = &markdown.nodes[i];
135 if matches!(node, Node::TableCell(_)) {
136 let table_nodes: Vec<&Node> = markdown.nodes[i..]
138 .iter()
139 .take_while(|n| {
140 matches!(
141 n,
142 Node::TableCell(_) | Node::TableAlign(_) | Node::TableRow(_)
143 )
144 })
145 .collect();
146 render_table(&table_nodes, &mut highlighter, writer)?;
147 i += table_nodes.len();
148 } else {
149 render_node(node, 0, &mut highlighter, config, writer)?;
150 i += 1;
151 }
152 }
153 Ok(())
154}
155
156pub fn render_markdown_to_string(markdown: &Markdown) -> io::Result<String> {
169 let mut output = Vec::new();
170 render_markdown(markdown, &mut output)?;
171 String::from_utf8(output).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
172}
173
174pub(crate) fn visible_width(s: &str) -> usize {
181 let mut width = 0;
182 let mut run = String::new();
183 let mut chars = s.chars().peekable();
184 while let Some(c) = chars.next() {
185 if c == '\x1b' {
186 width += UnicodeWidthStr::width(run.as_str());
187 run.clear();
188 match chars.peek() {
189 Some('[') => {
190 chars.next();
191 for c2 in chars.by_ref() {
192 if c2.is_ascii_alphabetic() {
193 break;
194 }
195 }
196 }
197 Some(']') => {
198 chars.next();
199 while let Some(c2) = chars.next() {
200 if c2 == '\x07' {
201 break;
202 }
203 if c2 == '\x1b' && chars.peek() == Some(&'\\') {
204 chars.next();
205 break;
206 }
207 }
208 }
209 _ => {}
210 }
211 continue;
212 }
213 run.push(c);
214 }
215 width += UnicodeWidthStr::width(run.as_str());
216 width
217}
218
219fn wrap_visible(s: &str, width: usize) -> Vec<String> {
222 if width == 0 || s.trim().is_empty() {
223 return vec![s.to_string()];
224 }
225 let mut lines = Vec::new();
226 let mut current = String::new();
227 let mut current_width = 0;
228
229 for word in s.split(' ').filter(|w| !w.is_empty()) {
230 let word_width = visible_width(word);
231 if current.is_empty() {
232 current = word.to_string();
233 current_width = word_width;
234 } else if current_width + 1 + word_width <= width {
235 current.push(' ');
236 current.push_str(word);
237 current_width += 1 + word_width;
238 } else {
239 lines.push(current);
240 current = word.to_string();
241 current_width = word_width;
242 }
243 }
244 if !current.is_empty() {
245 lines.push(current);
246 }
247 if lines.is_empty() {
248 lines.push(String::new());
249 }
250 lines
251}
252
253fn box_inner_width(header_width: usize) -> usize {
257 WIDTH.saturating_sub(4).max(header_width + 2)
258}
259
260fn render_boxed_lines<W: Write>(
261 writer: &mut W,
262 header: Option<&str>,
263 color: colored::Color,
264 lines: &[String],
265) -> io::Result<()> {
266 let header_width = header.map(visible_width).unwrap_or(0);
267 let inner_width = box_inner_width(header_width);
268 let border = "─".repeat(inner_width + 2);
269
270 let top = match header {
271 Some(h) if !h.is_empty() => format!(
272 "┌─ {} {}┐",
273 h,
274 "─".repeat(inner_width.saturating_sub(header_width + 1))
275 ),
276 _ => format!("┌{}┐", border),
277 };
278 writeln!(writer, "{}", top.color(color))?;
279
280 for line in lines {
281 let w = visible_width(line);
282 if w <= inner_width {
283 let pad = inner_width - w;
284 writeln!(
285 writer,
286 "{} {}{} {}",
287 "│".color(color),
288 line,
289 " ".repeat(pad),
290 "│".color(color)
291 )?;
292 } else {
293 writeln!(writer, "{} {}", "│".color(color), line)?;
294 }
295 }
296
297 writeln!(writer, "{}", format!("└{}┘", border).color(color))?;
298 Ok(())
299}
300
301fn detect_callout(text: &str) -> Option<&'static Callout> {
302 let trimmed = text.trim();
303 if trimmed.starts_with("[!")
304 && trimmed.contains(']')
305 && let Some(end) = trimmed.find(']')
306 {
307 let callout_type = &trimmed[2..end];
308 return CALLOUTS
309 .iter()
310 .find(|(name, _)| name.eq_ignore_ascii_case(callout_type))
311 .map(|(_, callout)| callout);
312 }
313 None
314}
315
316fn render_node<W: Write>(
317 node: &Node,
318 depth: usize,
319 highlighter: &mut SyntaxHighlighter,
320 config: &RenderConfig,
321 writer: &mut W,
322) -> io::Result<()> {
323 render_node_inline(node, depth, false, highlighter, config, writer)
324}
325
326fn render_node_inline<W: Write>(
327 node: &Node,
328 depth: usize,
329 inline: bool,
330 highlighter: &mut SyntaxHighlighter,
331 config: &RenderConfig,
332 writer: &mut W,
333) -> io::Result<()> {
334 match node {
335 Node::Heading(heading) => {
336 if !inline {
337 writeln!(writer)?;
338 }
339
340 let symbol = "▶".repeat(heading.depth.clamp(1, 6) as usize);
344
345 let text = render_inline_content(&heading.values);
346
347 if config.header_full_width_highlight {
348 let padding =
349 WIDTH.saturating_sub(visible_width(&text) + visible_width(&symbol) + 2);
350 let line = format!("{}{}", text, " ".repeat(padding));
351
352 match heading.depth {
354 1 => {
355 writeln!(
356 writer,
357 "{}{}{}",
358 symbol.bold().black().on_bright_blue(),
359 " ".on_bright_blue(),
360 line.bold().bright_black().on_bright_blue()
361 )?;
362 }
363 2 => {
364 writeln!(
365 writer,
366 "{}{}{}",
367 symbol.bold().black().on_cyan(),
368 " ".on_cyan(),
369 line.bold().bright_black().on_cyan()
370 )?;
371 }
372 3 => {
373 writeln!(
374 writer,
375 "{}{}{}",
376 symbol.bold().black().on_yellow(),
377 " ".on_yellow(),
378 line.bold().bright_black().on_yellow()
379 )?;
380 }
381 4 => {
382 writeln!(
383 writer,
384 "{}{}{}",
385 symbol.bold().black().on_green(),
386 " ".on_green(),
387 line.bold().bright_black().on_green()
388 )?;
389 }
390 5 => {
391 writeln!(
392 writer,
393 "{}{}{}",
394 symbol.bold().black().on_magenta(),
395 " ".on_magenta(),
396 line.bold().bright_black().on_magenta()
397 )?;
398 }
399 _ => {
400 writeln!(writer, "{} {}", symbol.bold().white(), text.bold().white())?;
401 }
402 }
403 } else {
404 match heading.depth {
406 1 => {
407 writeln!(
408 writer,
409 "{} {}",
410 symbol.bold().bright_blue(),
411 text.bold().bright_blue()
412 )?;
413 }
414 2 => {
415 writeln!(writer, "{} {}", symbol.bold().cyan(), text.bold().cyan())?;
416 }
417 3 => {
418 writeln!(
419 writer,
420 "{} {}",
421 symbol.bold().yellow(),
422 text.bold().yellow()
423 )?;
424 }
425 4 => {
426 writeln!(writer, "{} {}", symbol.bold().green(), text.bold().green())?;
427 }
428 5 => {
429 writeln!(
430 writer,
431 "{} {}",
432 symbol.bold().magenta(),
433 text.bold().magenta()
434 )?;
435 }
436 _ => {
437 writeln!(writer, "{} {}", symbol.bold().white(), text.bold().white())?;
438 }
439 }
440 }
441 writeln!(writer)?;
442 }
443
444 Node::Text(text) => {
445 if !text.value.trim().is_empty() {
446 if inline {
447 write!(writer, "{}", text.value)?;
448 } else {
449 writeln!(writer, "{}", text.value)?;
450 }
451 }
452 }
453
454 Node::List(list) => {
455 render_list(list, depth, highlighter, config, writer)?;
456 }
457
458 Node::Code(code) => {
459 let is_mermaid = code
460 .lang
461 .as_deref()
462 .is_some_and(|lang| lang.eq_ignore_ascii_case("mermaid"));
463
464 let mermaid_diagram = is_mermaid
465 .then(|| crate::mermaid::render(&code.value, *WIDTH))
466 .flatten();
467
468 if let Some(diagram) = mermaid_diagram {
469 writeln!(writer)?;
470 write!(writer, "{}", diagram)?;
471 writeln!(writer)?;
472 } else {
473 let highlighted = highlighter.highlight(&code.value, code.lang.as_deref());
475 let lines: Vec<String> = highlighted
476 .strip_suffix('\n')
477 .unwrap_or(&highlighted)
478 .split('\n')
479 .map(str::to_string)
480 .collect();
481
482 writeln!(writer)?;
483 render_boxed_lines(
484 writer,
485 code.lang.as_deref(),
486 colored::Color::BrightBlack,
487 &lines,
488 )?;
489 writeln!(writer)?;
490 }
491 }
492
493 Node::CodeInline(code) => {
494 write!(writer, "{}", format!("`{}`", code.value).bright_yellow())?;
495 }
496
497 Node::Strong(strong) => {
498 write!(writer, "{}", render_inline_content(&strong.values).bold())?;
499 }
500
501 Node::Emphasis(emphasis) => {
502 write!(
503 writer,
504 "{}",
505 render_inline_content(&emphasis.values).italic()
506 )?;
507 }
508
509 Node::Link(link) => {
510 let text = render_inline_content(&link.values);
511 let url = link.url.as_str();
512
513 if text.trim().is_empty() {
514 write!(
516 writer,
517 " {} {}",
518 "🔗".bright_blue(),
519 make_clickable_link(url, url)
520 )?;
521 } else {
522 write!(
524 writer,
525 " {} {}",
526 "🔗".bright_blue(),
527 make_clickable_link(url, &text).underline().bright_blue()
528 )?;
529 }
530 }
531
532 Node::Image(image) => {
533 let alt = image.alt.as_str();
534 let url = image.url.as_str();
535
536 let _ = render_image_to_terminal(url);
537
538 if alt.trim().is_empty() {
540 writeln!(
541 writer,
542 "{} {}",
543 "🖼️ ".bright_green(),
544 url.underline().bright_green()
545 )?;
546 } else {
547 writeln!(
548 writer,
549 "{} {} ({})",
550 "🖼️ ".bright_green(),
551 alt.bright_green(),
552 url.bright_black()
553 )?;
554 }
555 }
556
557 Node::HorizontalRule(_) => {
558 writeln!(writer, "{}", "─".repeat(80).bright_black())?;
559 writeln!(writer)?;
560 }
561
562 Node::Blockquote(blockquote) => {
563 if !inline {
564 writeln!(writer)?;
565 }
566
567 let is_callout = {
569 let mut found_callout = false;
570 for value in &blockquote.values {
572 match value {
573 Node::Fragment(para) => {
574 for child in ¶.values {
575 if let Node::Text(text) = child
576 && detect_callout(&text.value).is_some()
577 {
578 found_callout = true;
579 break;
580 }
581 }
582 }
583 Node::Text(text) if detect_callout(&text.value).is_some() => {
584 found_callout = true;
585 break;
586 }
587 _ => {}
588 }
589 if found_callout {
590 break;
591 }
592 }
593 found_callout
594 };
595
596 if is_callout {
597 render_callout_blockquote(blockquote, writer)?;
598 } else {
599 render_regular_blockquote(blockquote, depth, highlighter, config, writer)?;
600 }
601
602 writeln!(writer)?;
603 }
604
605 Node::Html(html) => {
606 let highlighted = highlighter.highlight(&html.value, Some("html"));
608 writeln!(writer, "{}", highlighted)?;
609 }
610
611 Node::Break(_) => {
612 if inline {
613 write!(writer, " ")?;
614 } else {
615 writeln!(writer)?;
616 }
617 }
618
619 Node::Fragment(fragment) => {
620 for child in &fragment.values {
622 render_node_inline(child, depth, true, highlighter, config, writer)?;
623 }
624 if !inline {
626 writeln!(writer)?;
627 }
628 }
629
630 Node::TableAlign(_) | Node::TableRow(_) => {
631 }
634
635 Node::TableCell(cell) => {
636 let column_widths = calculate_column_widths(&[Node::TableCell(cell.clone())]);
639 render_table_cell(cell, &column_widths, highlighter, config, writer)?;
640 }
641
642 _ => {
644 if let Some(children) = get_node_children(node) {
645 for child in children {
646 render_node_inline(child, depth, inline, highlighter, config, writer)?;
647 }
648 }
649 }
650 }
651
652 Ok(())
653}
654
655fn render_list<W: Write>(
656 list: &mq_markdown::List,
657 depth: usize,
658 highlighter: &mut SyntaxHighlighter,
659 config: &RenderConfig,
660 writer: &mut W,
661) -> io::Result<()> {
662 let indent = " ".repeat(depth);
663 let bullet_index = depth % LIST_BULLETS.len();
664 let bullet = if list.ordered {
665 format!("{}.", list.index + 1)
666 } else {
667 LIST_BULLETS[bullet_index].to_string()
668 };
669
670 let checkbox = match list.checked {
672 Some(true) => "☑️ ",
673 Some(false) => "☐ ",
674 None => "",
675 };
676
677 write!(writer, "{}{} {}", indent, bullet.bright_magenta(), checkbox)?;
678
679 let mut has_content = false;
680 for value in &list.values {
681 match value {
682 Node::List(nested_list) => {
683 if has_content {
684 writeln!(writer)?; }
686 render_list(nested_list, depth + 1, highlighter, config, writer)?;
687 }
688 Node::Fragment(fragment) => {
689 for child in &fragment.values {
691 render_node_inline(child, depth + 1, true, highlighter, config, writer)?;
692 }
693 has_content = true;
694 }
695 _ => {
696 render_node_inline(value, depth + 1, true, highlighter, config, writer)?;
697 has_content = true;
698 }
699 }
700 }
701
702 writeln!(writer)?; Ok(())
704}
705
706fn flatten_inline(values: &[Node]) -> Vec<&Node> {
711 let mut out = Vec::new();
712 for value in values {
713 if let Node::Fragment(para) = value {
714 out.extend(para.values.iter());
715 } else {
716 out.push(value);
717 }
718 }
719 out
720}
721
722fn inline_node_to_text(node: &Node) -> String {
726 match node {
727 Node::Text(text) => text.value.replace('\n', " "),
728 Node::Link(link) => {
729 let text = render_inline_content(&link.values);
730 let url = link.url.as_str();
731 if text.trim().is_empty() {
732 format!(" 🔗 {}", make_clickable_link(url, url))
733 } else {
734 format!(" 🔗 {}", make_clickable_link(url, &text))
735 }
736 }
737 Node::Break(_) => "\n".to_string(),
738 other => render_inline_content(std::slice::from_ref(other)),
739 }
740}
741
742fn render_callout_blockquote<W: Write>(
743 blockquote: &mq_markdown::Blockquote,
744 writer: &mut W,
745) -> io::Result<()> {
746 let inline_nodes = flatten_inline(&blockquote.values);
747
748 let marker_idx = inline_nodes
750 .iter()
751 .position(|n| matches!(n, Node::Text(t) if detect_callout(&t.value).is_some()));
752 let Some(marker_idx) = marker_idx else {
753 return Ok(());
754 };
755 let Node::Text(marker_text) = inline_nodes[marker_idx] else {
756 unreachable!()
757 };
758 let Some(callout) = detect_callout(&marker_text.value) else {
759 unreachable!()
760 };
761
762 let mut body = String::new();
767 if let Some(end) = marker_text.value.find(']') {
768 body.push_str(&marker_text.value[end + 1..].replace('\n', " "));
769 }
770 for node in &inline_nodes[marker_idx + 1..] {
771 body.push_str(&inline_node_to_text(node));
772 }
773
774 let mut content_lines: Vec<String> = Vec::new();
775 for paragraph in body.split('\n') {
776 if paragraph.trim().is_empty() {
777 continue;
778 }
779 content_lines.push(paragraph.trim().to_string());
780 }
781
782 let header_text = format!("{} {}", callout.icon, callout.name);
783 let inner_width = box_inner_width(visible_width(&header_text));
784 let wrapped_lines: Vec<String> = content_lines
785 .iter()
786 .flat_map(|line| wrap_visible(line, inner_width))
787 .collect();
788
789 render_boxed_lines(writer, Some(&header_text), callout.color, &wrapped_lines)
790}
791
792fn render_regular_blockquote<W: Write>(
793 blockquote: &mq_markdown::Blockquote,
794 depth: usize,
795 highlighter: &mut SyntaxHighlighter,
796 config: &RenderConfig,
797 writer: &mut W,
798) -> io::Result<()> {
799 for value in &blockquote.values {
800 write!(writer, "{} ", "▌".bright_black())?;
801 render_node_inline(value, depth, false, highlighter, config, writer)?;
802 }
803 Ok(())
804}
805
806fn render_inline_content(nodes: &[Node]) -> String {
807 let mut result = String::new();
808 for (i, node) in nodes.iter().enumerate() {
809 if i > 0 && needs_space_before(node) && !result.ends_with(' ') {
811 result.push(' ');
812 }
813
814 match node {
815 Node::Text(text) => result.push_str(&text.value),
816 Node::CodeInline(code) => result.push_str(&format!("`{}`", code.value)),
817 Node::Strong(strong) => result.push_str(&render_inline_content(&strong.values)),
818 Node::Emphasis(emphasis) => result.push_str(&render_inline_content(&emphasis.values)),
819 Node::Link(link) => {
820 let text = render_inline_content(&link.values);
821 let url = link.url.as_str();
822 if text.trim().is_empty() {
823 result.push_str(&format!("🔗 {}", make_clickable_link(url, url)));
824 } else {
825 result.push_str(&format!("🔗 {}", make_clickable_link(url, &text)));
826 }
827 }
828 _ => {}
829 }
830 }
831 result
832}
833
834fn needs_space_before(node: &Node) -> bool {
835 matches!(
836 node,
837 Node::Link(_) | Node::Strong(_) | Node::Emphasis(_) | Node::CodeInline(_)
838 )
839}
840
841fn get_node_children(node: &Node) -> Option<&Vec<Node>> {
842 match node {
843 Node::Fragment(fragment) => Some(&fragment.values),
844 Node::TableRow(row) => Some(&row.values),
845 Node::TableCell(cell) => Some(&cell.values),
846 _ => None,
847 }
848}
849
850fn render_table<W: Write>(
852 table_nodes: &[&Node],
853 highlighter: &mut SyntaxHighlighter,
854 writer: &mut W,
855) -> io::Result<()> {
856 if table_nodes.is_empty() {
857 return Ok(());
858 }
859
860 let config = RenderConfig::default();
862
863 let all_nodes: Vec<Node> = table_nodes.iter().map(|n| (*n).clone()).collect();
865 let column_widths = calculate_column_widths(&all_nodes);
866
867 let col_count = table_nodes
869 .iter()
870 .find_map(|node| {
871 if let Node::TableAlign(header) = node {
872 Some(header.align.len())
873 } else {
874 None
875 }
876 })
877 .unwrap_or(column_widths.len());
878
879 writeln!(writer)?;
880
881 render_table_top_border(&column_widths, col_count, writer)?;
883
884 write!(writer, "{}", "│ ".bright_cyan())?;
886
887 for (i, node) in table_nodes.iter().enumerate() {
888 match node {
889 Node::TableCell(cell) => {
890 let content = render_inline_content(&cell.values);
891 let width = column_widths.get(cell.column).copied().unwrap_or(0);
892
893 for value in &cell.values {
894 render_node_inline(value, 0, true, highlighter, &config, writer)?;
895 }
896
897 let content_width = visible_width(&content);
899 if content_width < width {
900 write!(writer, "{}", " ".repeat(width - content_width))?;
901 }
902
903 write!(writer, " {}", "│ ".bright_cyan())?;
904
905 let is_last_in_row = match table_nodes.get(i + 1) {
907 Some(Node::TableCell(next_cell)) => next_cell.row != cell.row,
908 _ => true,
909 };
910
911 if is_last_in_row {
912 writeln!(writer)?;
913 if i + 1 < table_nodes.len() {
915 if let Some(Node::TableAlign(header)) = table_nodes.get(i + 1) {
916 render_table_header(header, &column_widths, writer)?;
917 if i + 2 < table_nodes.len()
919 && matches!(table_nodes.get(i + 2), Some(Node::TableCell(_)))
920 {
921 write!(writer, "{}", "│ ".bright_cyan())?;
922 }
923 } else if matches!(table_nodes.get(i + 1), Some(Node::TableCell(_))) {
924 write!(writer, "{}", "│ ".bright_cyan())?;
926 }
927 }
928 }
929 }
930 Node::TableAlign(_) => {
931 }
933 Node::TableRow(row) => {
934 render_table_row(row, &column_widths, highlighter, &config, writer)?;
935 }
936 _ => {}
937 }
938 }
939
940 render_table_bottom_border(&column_widths, col_count, writer)?;
942
943 writeln!(writer)?;
944 Ok(())
945}
946
947fn calculate_column_widths(nodes: &[Node]) -> Vec<usize> {
949 let mut column_widths: Vec<usize> = Vec::new();
950
951 for node in nodes {
952 match node {
953 Node::TableRow(row) => {
954 for (col_idx, cell_node) in row.values.iter().enumerate() {
955 if let Node::TableCell(cell) = cell_node {
956 let content = render_inline_content(&cell.values);
957 let width = visible_width(&content);
958
959 if col_idx >= column_widths.len() {
960 column_widths.resize(col_idx + 1, 0);
961 }
962 column_widths[col_idx] = column_widths[col_idx].max(width);
963 }
964 }
965 }
966 Node::TableCell(cell) => {
967 let content = render_inline_content(&cell.values);
968 let width = visible_width(&content);
969
970 if cell.column >= column_widths.len() {
971 column_widths.resize(cell.column + 1, 0);
972 }
973 column_widths[cell.column] = column_widths[cell.column].max(width);
974 }
975 _ => {}
976 }
977 }
978
979 column_widths
980}
981
982fn render_table_top_border<W: Write>(
984 column_widths: &[usize],
985 col_count: usize,
986 writer: &mut W,
987) -> io::Result<()> {
988 write!(writer, "{}", "┌".bright_black())?;
989 for i in 0..col_count {
990 let width = column_widths.get(i).copied().unwrap_or(4);
991 write!(writer, "{}", "─".repeat(width + 2).bright_black())?;
992 if i < col_count - 1 {
993 write!(writer, "{}", "┬".bright_black())?;
994 }
995 }
996 writeln!(writer, "{}", "┐".bright_black())?;
997 Ok(())
998}
999
1000fn render_table_bottom_border<W: Write>(
1002 column_widths: &[usize],
1003 col_count: usize,
1004 writer: &mut W,
1005) -> io::Result<()> {
1006 write!(writer, "{}", "└".bright_black())?;
1007 for i in 0..col_count {
1008 let width = column_widths.get(i).copied().unwrap_or(4);
1009 write!(writer, "{}", "─".repeat(width + 2).bright_black())?;
1010 if i < col_count - 1 {
1011 write!(writer, "{}", "┴".bright_black())?;
1012 }
1013 }
1014 writeln!(writer, "{}", "┘".bright_black())?;
1015 Ok(())
1016}
1017
1018fn render_table_header<W: Write>(
1020 header: &mq_markdown::TableAlign,
1021 column_widths: &[usize],
1022 writer: &mut W,
1023) -> io::Result<()> {
1024 write!(writer, "{}", "├".bright_black())?;
1025 for (i, align) in header.align.iter().enumerate() {
1026 let width = column_widths.get(i).copied().unwrap_or(4);
1027 let (left, right) = match align {
1028 mq_markdown::TableAlignKind::Left => (":", "─"),
1029 mq_markdown::TableAlignKind::Right => ("─", ":"),
1030 mq_markdown::TableAlignKind::Center => (":", ":"),
1031 mq_markdown::TableAlignKind::None => ("─", "─"),
1032 };
1033
1034 write!(writer, "{}", left.bright_black())?;
1035 write!(writer, "{}", "─".repeat(width).bright_black())?;
1036 write!(writer, "{}", right.bright_black())?;
1037
1038 if i < header.align.len() - 1 {
1039 write!(writer, "{}", "┼".bright_black())?;
1040 }
1041 }
1042 writeln!(writer, "{}", "┤".bright_black())?;
1043 Ok(())
1044}
1045
1046fn render_table_row<W: Write>(
1048 row: &mq_markdown::TableRow,
1049 column_widths: &[usize],
1050 highlighter: &mut SyntaxHighlighter,
1051 config: &RenderConfig,
1052 writer: &mut W,
1053) -> io::Result<()> {
1054 write!(writer, "{}", "│ ".bright_cyan())?;
1055 for (col_idx, cell_node) in row.values.iter().enumerate() {
1056 if let Node::TableCell(cell) = cell_node {
1057 let content = render_inline_content(&cell.values);
1058 let width = column_widths.get(col_idx).copied().unwrap_or(0);
1059
1060 for value in &cell.values {
1061 render_node_inline(value, 0, true, highlighter, config, writer)?;
1062 }
1063
1064 let content_width = visible_width(&content);
1066 if content_width < width {
1067 write!(writer, "{}", " ".repeat(width - content_width))?;
1068 }
1069
1070 write!(writer, " {}", "│ ".bright_cyan())?;
1071 }
1072 }
1073 writeln!(writer)?;
1074 Ok(())
1075}
1076
1077fn render_table_cell<W: Write>(
1079 cell: &mq_markdown::TableCell,
1080 column_widths: &[usize],
1081 highlighter: &mut SyntaxHighlighter,
1082 config: &RenderConfig,
1083 writer: &mut W,
1084) -> io::Result<()> {
1085 write!(writer, "{}", "│ ".bright_cyan())?;
1086
1087 let content = render_inline_content(&cell.values);
1088 let width = column_widths.get(cell.column).copied().unwrap_or(0);
1089
1090 for value in &cell.values {
1091 render_node_inline(value, 0, true, highlighter, config, writer)?;
1092 }
1093
1094 let content_width = visible_width(&content);
1096 if content_width < width {
1097 write!(writer, "{}", " ".repeat(width - content_width))?;
1098 }
1099
1100 write!(writer, " ")?;
1101 writeln!(writer, "{}", "│".bright_cyan())?;
1102 Ok(())
1103}
1104
1105fn render_image_to_terminal(path: &str) -> io::Result<()> {
1107 if path.starts_with("http://") || path.starts_with("https://") {
1109 return Ok(());
1112 }
1113
1114 let image_path = Path::new(path);
1115 if !image_path.exists() {
1116 return Ok(());
1117 }
1118
1119 let conf = viuer::Config {
1122 width: Some(60),
1123 height: None,
1124 absolute_offset: false,
1125 ..Default::default()
1126 };
1127
1128 if let Ok(img) = image::open(path) {
1130 let _ = viuer::print(&img, &conf);
1131 }
1132
1133 Ok(())
1134}
1135
1136#[cfg(test)]
1137mod tests {
1138 use super::*;
1139 use mq_markdown::{Markdown, Node};
1140
1141 #[test]
1142 fn test_render_markdown_to_string_simple_text() {
1143 let markdown: Markdown = "Hello World".parse().unwrap();
1144 let result = render_markdown_to_string(&markdown).unwrap();
1145 assert!(result.contains("Hello World"));
1146 }
1147
1148 #[test]
1149 fn test_render_markdown_to_string_heading() {
1150 let markdown: Markdown = "# Heading 1\n## Heading 2\n### Heading 3\n#### Heading 4\n##### Heading 5\n###### Heading 6\n".parse().unwrap();
1151 let result = render_markdown_to_string(&markdown).unwrap();
1152 assert!(result.contains("Heading 1"));
1153 assert!(result.contains("Heading 2"));
1154 assert!(result.contains("Heading 3"));
1155 assert!(result.contains("Heading 4"));
1156 assert!(result.contains("Heading 5"));
1157 assert!(result.contains("Heading 6"));
1158 }
1159
1160 #[test]
1161 fn test_heading_full_width_highlight_padding_accounts_for_symbol_and_links() {
1162 let markdown: Markdown = "## [Linked Heading](https://example.com)".parse().unwrap();
1168 let result = render_markdown_to_string(&markdown).unwrap();
1169 let line = result.lines().find(|l| !l.trim().is_empty()).unwrap();
1170 assert_eq!(visible_width(line), *WIDTH);
1171 }
1172
1173 #[test]
1174 fn test_render_markdown_to_string_list() {
1175 let markdown: Markdown = "- Item 1\n- Item 2\n- Item 3".parse().unwrap();
1176 let result = render_markdown_to_string(&markdown).unwrap();
1177 assert!(result.contains("Item 1"));
1178 assert!(result.contains("Item 2"));
1179 assert!(result.contains("Item 3"));
1180 }
1181
1182 #[test]
1183 fn test_render_markdown_to_string_code_block() {
1184 let markdown: Markdown = "```rust\nfn main() {}\n```".parse().unwrap();
1185 let result = render_markdown_to_string(&markdown).unwrap();
1186 assert!(result.contains("main"));
1188 }
1189
1190 #[test]
1191 fn test_render_markdown_to_string_inline_code() {
1192 let markdown: Markdown = "This is `inline code` text".parse().unwrap();
1193 let result = render_markdown_to_string(&markdown).unwrap();
1194 assert!(result.contains("inline code"));
1195 }
1196
1197 #[test]
1198 fn test_render_markdown_to_string_bold() {
1199 let markdown: Markdown = "This is **bold** text".parse().unwrap();
1200 let result = render_markdown_to_string(&markdown).unwrap();
1201 assert!(result.contains("bold"));
1202 }
1203
1204 #[test]
1205 fn test_render_markdown_to_string_italic() {
1206 let markdown: Markdown = "This is *italic* text".parse().unwrap();
1207 let result = render_markdown_to_string(&markdown).unwrap();
1208 assert!(result.contains("italic"));
1209 }
1210
1211 #[test]
1212 fn test_render_markdown_to_string_link() {
1213 let markdown: Markdown = "[Link Text](https://example.com)".parse().unwrap();
1214 let result = render_markdown_to_string(&markdown).unwrap();
1215 assert!(result.contains("Link Text"));
1216 }
1217
1218 #[test]
1219 fn test_render_markdown_to_string_blockquote() {
1220 let markdown: Markdown = "> This is a quote".parse().unwrap();
1221 let result = render_markdown_to_string(&markdown).unwrap();
1222 assert!(result.contains("This is a quote"));
1223 }
1224
1225 #[test]
1226 fn test_render_markdown_to_string_horizontal_rule() {
1227 let markdown: Markdown = "---".parse().unwrap();
1228 let result = render_markdown_to_string(&markdown).unwrap();
1229 assert!(!result.is_empty());
1231 }
1232
1233 #[test]
1234 fn test_detect_callout_note() {
1235 assert!(detect_callout("[!NOTE] Test").is_some());
1236 }
1237
1238 #[test]
1239 fn test_detect_callout_tip() {
1240 assert!(detect_callout("[!TIP] Test").is_some());
1241 }
1242
1243 #[test]
1244 fn test_detect_callout_important() {
1245 assert!(detect_callout("[!IMPORTANT] Test").is_some());
1246 }
1247
1248 #[test]
1249 fn test_detect_callout_warning() {
1250 assert!(detect_callout("[!WARNING] Test").is_some());
1251 }
1252
1253 #[test]
1254 fn test_detect_callout_caution() {
1255 assert!(detect_callout("[!CAUTION] Test").is_some());
1256 }
1257
1258 #[test]
1259 fn test_detect_callout_case_insensitive() {
1260 assert!(detect_callout("[!note] Test").is_some());
1261 assert!(detect_callout("[!Note] Test").is_some());
1262 }
1263
1264 #[test]
1265 fn test_detect_callout_none() {
1266 assert!(detect_callout("Regular text").is_none());
1267 assert!(detect_callout("[NOTE] No exclamation").is_none());
1268 }
1269
1270 #[test]
1271 fn test_make_clickable_link() {
1272 let link = make_clickable_link("https://example.com", "Example");
1273 assert!(link.contains("https://example.com"));
1274 assert!(link.contains("Example"));
1275 }
1276
1277 #[test]
1278 fn test_render_inline_content_text() {
1279 let nodes = vec![Node::Text(mq_markdown::Text {
1280 value: "Hello".to_string(),
1281 position: None,
1282 })];
1283 let result = render_inline_content(&nodes);
1284 assert_eq!(result, "Hello");
1285 }
1286
1287 #[test]
1288 fn test_render_inline_content_inline_code() {
1289 let nodes = vec![Node::CodeInline(mq_markdown::CodeInline {
1290 value: "code".into(),
1291 position: None,
1292 })];
1293 let result = render_inline_content(&nodes);
1294 assert_eq!(result, "`code`");
1295 }
1296
1297 #[test]
1298 fn test_render_inline_content_strong() {
1299 let nodes = vec![Node::Strong(mq_markdown::Strong {
1300 values: vec![Node::Text(mq_markdown::Text {
1301 value: "bold".to_string(),
1302 position: None,
1303 })],
1304 position: None,
1305 })];
1306 let result = render_inline_content(&nodes);
1307 assert_eq!(result, "bold");
1308 }
1309
1310 #[test]
1311 fn test_render_inline_content_emphasis() {
1312 let nodes = vec![Node::Emphasis(mq_markdown::Emphasis {
1313 values: vec![Node::Text(mq_markdown::Text {
1314 value: "italic".to_string(),
1315 position: None,
1316 })],
1317 position: None,
1318 })];
1319 let result = render_inline_content(&nodes);
1320 assert_eq!(result, "italic");
1321 }
1322
1323 #[test]
1324 fn test_needs_space_before() {
1325 let markdown: Markdown = "[link](url) **bold** *italic* `code` text".parse().unwrap();
1327
1328 if let Some(Node::Fragment(fragment)) = markdown.nodes.first() {
1330 for node in &fragment.values {
1331 match node {
1332 Node::Link(_) => assert!(needs_space_before(node)),
1333 Node::Strong(_) => assert!(needs_space_before(node)),
1334 Node::Emphasis(_) => assert!(needs_space_before(node)),
1335 Node::CodeInline(_) => assert!(needs_space_before(node)),
1336 Node::Text(_) => assert!(!needs_space_before(node)),
1337 _ => {}
1338 }
1339 }
1340 }
1341 }
1342
1343 #[test]
1344 fn test_calculate_column_widths() {
1345 let nodes = vec![
1346 Node::TableCell(mq_markdown::TableCell {
1347 values: vec![Node::Text(mq_markdown::Text {
1348 value: "Short".to_string(),
1349 position: None,
1350 })],
1351 column: 0,
1352 row: 0,
1353 position: None,
1354 }),
1355 Node::TableCell(mq_markdown::TableCell {
1356 values: vec![Node::Text(mq_markdown::Text {
1357 value: "Very Long Text".to_string(),
1358 position: None,
1359 })],
1360 column: 1,
1361 row: 0,
1362 position: None,
1363 }),
1364 ];
1365 let widths = calculate_column_widths(&nodes);
1366 assert_eq!(widths[0], 5); assert_eq!(widths[1], 14); }
1369
1370 #[test]
1371 fn test_render_markdown_ordered_list() {
1372 let markdown: Markdown = "1. First\n2. Second\n3. Third".parse().unwrap();
1373 let result = render_markdown_to_string(&markdown).unwrap();
1374 assert!(result.contains("First"));
1375 assert!(result.contains("Second"));
1376 assert!(result.contains("Third"));
1377 }
1378
1379 #[test]
1380 fn test_render_markdown_checkbox_list() {
1381 let markdown: Markdown = "- [x] Done\n- [ ] Todo".parse().unwrap();
1382 let result = render_markdown_to_string(&markdown).unwrap();
1383 assert!(result.contains("Done"));
1384 assert!(result.contains("Todo"));
1385 }
1386
1387 #[test]
1388 fn test_render_markdown_table() {
1389 let markdown: Markdown =
1390 "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |"
1391 .parse()
1392 .unwrap();
1393 let result = render_markdown_to_string(&markdown).unwrap();
1394 assert!(result.contains("Header 1"));
1395 assert!(result.contains("Header 2"));
1396 assert!(result.contains("Cell 1"));
1397 assert!(result.contains("Cell 2"));
1398 }
1399
1400 #[test]
1401 fn test_render_markdown_nested_list() {
1402 let markdown: Markdown = "- Item 1\n - Nested 1\n - Nested 2\n- Item 2"
1403 .parse()
1404 .unwrap();
1405 let result = render_markdown_to_string(&markdown).unwrap();
1406 assert!(result.contains("Item 1"));
1407 assert!(result.contains("Nested 1"));
1408 assert!(result.contains("Nested 2"));
1409 assert!(result.contains("Item 2"));
1410 }
1411
1412 #[test]
1413 fn test_render_markdown_mixed_formatting() {
1414 let markdown: Markdown = "**Bold** and *italic* with `code`".parse().unwrap();
1415 let result = render_markdown_to_string(&markdown).unwrap();
1416 assert!(result.contains("Bold"));
1417 assert!(result.contains("italic"));
1418 assert!(result.contains("code"));
1419 }
1420
1421 #[test]
1422 fn test_render_callout_blockquote_note() {
1423 let markdown: Markdown = "> [!NOTE] This is a note callout\n> Additional info"
1424 .parse()
1425 .unwrap();
1426 let result = render_markdown_to_string(&markdown).unwrap();
1427 assert!(result.contains("ℹ️"));
1429 assert!(result.contains("Note"));
1430 assert!(result.contains("This is a note callout"));
1431 assert!(result.contains("Additional info"));
1432 assert!(result.contains("┌─"));
1434 assert!(result.contains("└─"));
1435 }
1436
1437 #[test]
1438 fn test_render_callout_blockquote_tip() {
1439 let markdown: Markdown = "> [!TIP] This is a tip callout".parse().unwrap();
1440 let result = render_markdown_to_string(&markdown).unwrap();
1441 assert!(result.contains("💡"));
1442 assert!(result.contains("Tip"));
1443 assert!(result.contains("This is a tip callout"));
1444 assert!(result.contains("┌─"));
1445 assert!(result.contains("└─"));
1446 }
1447
1448 #[test]
1449 fn test_render_callout_blockquote_important() {
1450 let markdown: Markdown = "> [!IMPORTANT] Important info".parse().unwrap();
1451 let result = render_markdown_to_string(&markdown).unwrap();
1452 assert!(result.contains("❗"));
1453 assert!(result.contains("Important"));
1454 assert!(result.contains("Important info"));
1455 assert!(result.contains("┌─"));
1456 assert!(result.contains("└─"));
1457 }
1458
1459 #[test]
1460 fn test_render_callout_blockquote_warning() {
1461 let markdown: Markdown = "> [!WARNING] Warning info".parse().unwrap();
1462 let result = render_markdown_to_string(&markdown).unwrap();
1463 assert!(result.contains("⚠️"));
1464 assert!(result.contains("Warning"));
1465 assert!(result.contains("Warning info"));
1466 assert!(result.contains("┌─"));
1467 assert!(result.contains("└─"));
1468 }
1469
1470 #[test]
1471 fn test_render_callout_blockquote_caution() {
1472 let markdown: Markdown = "> [!CAUTION] Caution info".parse().unwrap();
1473 let result = render_markdown_to_string(&markdown).unwrap();
1474 assert!(result.contains("🔥"));
1475 assert!(result.contains("Caution"));
1476 assert!(result.contains("Caution info"));
1477 assert!(result.contains("┌─"));
1478 assert!(result.contains("└─"));
1479 }
1480
1481 #[test]
1482 fn test_render_callout_blockquote_case_insensitive() {
1483 let markdown: Markdown = "> [!note] lower case note\n\n> [!Tip] mixed case tip"
1484 .parse()
1485 .unwrap();
1486 let result = render_markdown_to_string(&markdown).unwrap();
1487 assert!(result.contains("ℹ️"));
1488 assert!(result.contains("Note"));
1489 assert!(result.contains("lower case note"));
1490 assert!(result.contains("💡"));
1491 assert!(result.contains("Tip"));
1492 assert!(result.contains("mixed case tip"));
1493 }
1494
1495 #[test]
1496 fn test_render_markdown_html_block() {
1497 let markdown: Markdown = "<div>Hello HTML</div>".parse().unwrap();
1498 let result = render_markdown_to_string(&markdown).unwrap();
1499 assert!(result.contains("Hello HTML"));
1501 assert!(result.contains("\x1b"));
1503 }
1504
1505 #[test]
1506 fn test_render_markdown_inline_html() {
1507 let markdown: Markdown = "Text <span>inline html</span> more text".parse().unwrap();
1508 let result = render_markdown_to_string(&markdown).unwrap();
1509 assert!(result.contains("inline html"));
1510 assert!(result.contains("Text"));
1511 assert!(result.contains("more text"));
1512 }
1513
1514 #[test]
1515 fn test_render_markdown_image_with_alt() {
1516 let markdown: Markdown = "".parse().unwrap();
1517 let result = render_markdown_to_string(&markdown).unwrap();
1518 assert!(result.contains("🖼️"));
1519 assert!(result.contains("Alt text"));
1520 assert!(result.contains("image.png"));
1521 }
1522
1523 #[test]
1524 fn test_render_markdown_image_without_alt() {
1525 let markdown: Markdown = "".parse().unwrap();
1526 let result = render_markdown_to_string(&markdown).unwrap();
1527 assert!(result.contains("🖼️"));
1528 assert!(result.contains("image.png"));
1529 }
1530
1531 #[test]
1532 fn test_render_markdown_remote_image() {
1533 let markdown: Markdown = "".parse().unwrap();
1534 let result = render_markdown_to_string(&markdown).unwrap();
1535 assert!(result.contains("🖼️"));
1536 assert!(result.contains("Remote"));
1537 assert!(result.contains("https://example.com/image.png"));
1538 }
1539
1540 #[test]
1541 fn test_render_markdown_table_with_alignment() {
1542 let markdown: Markdown = r#"
1543| Left | Center | Right |
1544|:-----|:------:|------:|
1545| L1 | C1 | R1 |
1546| L2 | C2 | R2 |
1547"#
1548 .parse()
1549 .unwrap();
1550 let result = render_markdown_to_string(&markdown).unwrap();
1551 assert!(result.contains("Left"));
1552 assert!(result.contains("Center"));
1553 assert!(result.contains("Right"));
1554 assert!(result.contains("L1"));
1555 assert!(result.contains("C1"));
1556 assert!(result.contains("R1"));
1557 assert!(result.contains("L2"));
1558 assert!(result.contains("C2"));
1559 assert!(result.contains("R2"));
1560 assert!(result.contains(":"));
1562 }
1563
1564 #[test]
1565 fn test_render_markdown_table_with_inline_formatting() {
1566 let markdown: Markdown = r#"
1567| **Bold** | *Italic* | `Code` |
1568|----------|----------|--------|
1569| A | B | C |
1570"#
1571 .parse()
1572 .unwrap();
1573 let result = render_markdown_to_string(&markdown).unwrap();
1574 assert!(result.contains("Bold"));
1575 assert!(result.contains("Italic"));
1576 assert!(result.contains("Code"));
1577 assert!(result.contains("A"));
1578 assert!(result.contains("B"));
1579 assert!(result.contains("C"));
1580 }
1581
1582 #[test]
1583 fn test_render_markdown_table_with_links_and_images() {
1584 let markdown: Markdown = r#"
1585| Link | Image |
1586|------|-------|
1587| [Google](https://google.com) |  |
1588"#
1589 .parse()
1590 .unwrap();
1591 let result = render_markdown_to_string(&markdown).unwrap();
1592 assert!(result.contains("Google"));
1593 assert!(result.contains("https://google.com"));
1594 assert!(result.contains("🖼️"));
1595 assert!(result.contains("Alt"));
1596 assert!(result.contains("img.png"));
1597 }
1598
1599 #[test]
1600 fn test_render_markdown_table_empty_cells() {
1601 let markdown: Markdown = r#"
1602| A | B | C |
1603|---|---|---|
1604| | 1 | |
1605| 2 | | 3 |
1606"#
1607 .parse()
1608 .unwrap();
1609 let result = render_markdown_to_string(&markdown).unwrap();
1610 assert!(result.contains("A"));
1611 assert!(result.contains("B"));
1612 assert!(result.contains("C"));
1613 assert!(result.contains("1"));
1614 assert!(result.contains("2"));
1615 assert!(result.contains("3"));
1616 }
1617
1618 #[test]
1619 fn test_render_markdown_table_with_multiple_rows_and_columns() {
1620 let markdown: Markdown = r#"
1621| Col1 | Col2 | Col3 | Col4 |
1622|------|------|------|------|
1623| A | B | C | D |
1624| E | F | G | H |
1625| I | J | K | L |
1626"#
1627 .parse()
1628 .unwrap();
1629 let result = render_markdown_to_string(&markdown).unwrap();
1630 for val in &[
1631 "Col1", "Col2", "Col3", "Col4", "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K",
1632 "L",
1633 ] {
1634 assert!(result.contains(val));
1635 }
1636 }
1637
1638 #[test]
1639 fn test_render_markdown_table_with_rowspan_and_colspan_like_content() {
1640 let markdown: Markdown = r#"
1642| Header |
1643|--------|
1644| Line 1<br>Line 2 |
1645"#
1646 .parse()
1647 .unwrap();
1648 let result = render_markdown_to_string(&markdown).unwrap();
1649 assert!(result.contains("Header"));
1650 assert!(result.contains("Line 1"));
1651 assert!(result.contains("Line 2"));
1652 }
1653}