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