Skip to main content

mq_view/
renderer.rs

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/// Configuration for rendering markdown
11#[derive(Debug, Clone)]
12pub struct RenderConfig {
13    /// Enable full-width background highlighting for headers
14    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
25/// Unicode bullet symbols for lists
26const 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/// GitHub-style callout definitions
38#[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
88/// Create a clickable link using ANSI escape sequences (OSC 8)
89/// Format: ESC ] 8 ; params ; URI ST display_text ESC ] 8 ; ; ST
90fn make_clickable_link(url: &str, display_text: &str) -> String {
91    // Using ST (String Terminator) \x1b\\ instead of BEL \x07 for better compatibility
92    format!("\x1b]8;;{}\x1b\\{}\x1b]8;;\x1b\\", url, display_text)
93}
94
95/// Render a Markdown document to a writer with syntax highlighting and rich text formatting.
96///
97/// # Errors
98///
99/// Returns an `io::Error` if writing to the output fails.
100///
101/// # Examples
102///
103/// ```rust
104/// use mq_view::render_markdown;
105/// use mq_markdown::Markdown;
106/// use std::io::BufWriter;
107///
108/// let markdown: Markdown = "# Hello\n\nWorld".parse().unwrap();
109/// let mut output = Vec::new();
110/// {
111///     let mut writer = BufWriter::new(&mut output);
112///     render_markdown(&markdown, &mut writer).unwrap();
113/// }
114/// ```
115pub fn render_markdown<W: Write>(markdown: &Markdown, writer: &mut W) -> io::Result<()> {
116    render_markdown_with_config(markdown, writer, &RenderConfig::default())
117}
118
119/// Render a Markdown document to a writer with custom configuration.
120///
121/// # Errors
122///
123/// Returns an `io::Error` if writing to the output fails.
124pub 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            // Collect consecutive table-related nodes
137            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
156/// Render a Markdown document to a String with syntax highlighting and rich text formatting.
157///
158/// # Examples
159///
160/// ```rust
161/// use mq_view::render_markdown_to_string;
162/// use mq_markdown::Markdown;
163///
164/// let markdown: Markdown = "# Hello\n\nWorld".parse().unwrap();
165/// let rendered = render_markdown_to_string(&markdown).unwrap();
166/// println!("{}", rendered);
167/// ```
168pub 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
174/// Visible column width of a string, ignoring ANSI escape sequences
175/// (SGR color codes and OSC 8 hyperlinks) so that box borders and wrapping
176/// stay aligned even when the content contains colored or clickable text.
177/// Visible runs are measured with their real terminal column width (not a
178/// raw `char` count), so wide CJK text and emoji - including multi-codepoint
179/// sequences like an emoji + variation selector - line up correctly too.
180pub(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
219/// Greedily word-wrap `s` so each line's visible width fits within `width`
220/// columns (ANSI escapes don't count toward the width).
221fn 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
253/// Render `lines` inside a bordered box. Lines are never wrapped (so
254/// syntax-highlighted code keeps its original structure); a line wider than
255/// the box just overflows past the right border instead of being cut.
256fn 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            // Repeat the marker once per heading level (▶, ▶▶, ▶▶▶, ...) so
341            // depth stays legible even in fonts that render circled digits
342            // (①②③) too small to read at a glance.
343            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                // Full-width background highlighting
353                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                // Simple header without full-width highlighting
405                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                // Apply syntax highlighting if language is specified
474                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                // If no link text, just make the URL clickable
515                write!(
516                    writer,
517                    " {} {}",
518                    "🔗".bright_blue(),
519                    make_clickable_link(url, url)
520                )?;
521            } else {
522                // Make the title clickable without showing URL
523                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            // Always show the text description as well
539            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            // Check if this is a GitHub-style callout
568            let is_callout = {
569                let mut found_callout = false;
570                // Check all nodes in blockquote for callout pattern
571                for value in &blockquote.values {
572                    match value {
573                        Node::Fragment(para) => {
574                            for child in &para.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            // Apply syntax highlighting to HTML
607            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            // Render paragraph as inline content on one line
621            for child in &fragment.values {
622                render_node_inline(child, depth, true, highlighter, config, writer)?;
623            }
624            // Add newline after paragraph unless we're inline
625            if !inline {
626                writeln!(writer)?;
627            }
628        }
629
630        Node::TableAlign(_) | Node::TableRow(_) => {
631            // These should be handled by render_table in render_markdown
632            // If we encounter them here, skip them
633        }
634
635        Node::TableCell(cell) => {
636            // Individual table cells outside of tables
637            // Calculate column widths for this cell
638            let column_widths = calculate_column_widths(&[Node::TableCell(cell.clone())]);
639            render_table_cell(cell, &column_widths, highlighter, config, writer)?;
640        }
641
642        // Handle other node types recursively if they have children
643        _ => {
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    // Handle checkbox lists
671    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)?; // New line before nested list only if we had content
685                }
686                render_list(nested_list, depth + 1, highlighter, config, writer)?;
687            }
688            Node::Fragment(fragment) => {
689                // Handle paragraph content inline
690                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)?; // Add line break after list item
703    Ok(())
704}
705
706/// mq-markdown doesn't consistently wrap inline blockquote content in a
707/// `Fragment`: a single-line callout comes through as flat `Text`/`Link`/...
708/// nodes directly under `Blockquote`, while other inputs nest them inside a
709/// `Fragment`. Flatten one level so callers can treat both shapes the same.
710fn 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
722/// Render a single inline node's textual content for use inside a callout
723/// box (where everything gets re-wrapped to the box width, so embedded
724/// line breaks from the original markdown source are normalized to spaces).
725fn 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    // Find the marker node and the callout type it declares.
749    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    // Build one continuous string for the body: the part of the marker text
763    // after `]`, followed by every later inline node's text. Soft line
764    // breaks inside source text are normalized to spaces; explicit `Break`
765    // nodes become paragraph separators (kept as `\n`) for re-wrapping.
766    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        // Add space between inline elements if needed
810        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
850/// Render a complete table with proper column alignment
851fn 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    // Tables don't use full-width highlighting, use default config
861    let config = RenderConfig::default();
862
863    // Calculate column widths from all cells
864    let all_nodes: Vec<Node> = table_nodes.iter().map(|n| (*n).clone()).collect();
865    let column_widths = calculate_column_widths(&all_nodes);
866
867    // Find table header to determine column count
868    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 top border
882    render_table_top_border(&column_widths, col_count, writer)?;
883
884    // Render cells row by row
885    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                // Pad with spaces to align columns
898                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                // Check if this is the last cell in its row
906                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                    // Check if next node is the header separator or another cell
914                    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                            // After header, if there's another cell, start a new row
918                            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                            // Start new row
925                            write!(writer, "{}", "│ ".bright_cyan())?;
926                        }
927                    }
928                }
929            }
930            Node::TableAlign(_) => {
931                // Already handled in the TableCell last_cell_in_row logic
932            }
933            Node::TableRow(row) => {
934                render_table_row(row, &column_widths, highlighter, &config, writer)?;
935            }
936            _ => {}
937        }
938    }
939
940    // Render bottom border
941    render_table_bottom_border(&column_widths, col_count, writer)?;
942
943    writeln!(writer)?;
944    Ok(())
945}
946
947/// Calculate column widths for a table
948fn 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
982/// Render table top border
983fn 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
1000/// Render table bottom border
1001fn 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
1018/// Render table header with alignment and column widths
1019fn 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
1046/// Render table row with column widths
1047fn 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            // Pad with spaces to align columns
1065            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
1077/// Render table cell with column width
1078fn 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    // Pad with spaces to align columns
1095    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
1105/// Render an image to the terminal if possible
1106fn render_image_to_terminal(path: &str) -> io::Result<()> {
1107    // Check if the path is a local file
1108    if path.starts_with("http://") || path.starts_with("https://") {
1109        // For remote images, we would need to download them first
1110        // For now, skip rendering remote images
1111        return Ok(());
1112    }
1113
1114    let image_path = Path::new(path);
1115    if !image_path.exists() {
1116        return Ok(());
1117    }
1118
1119    // Use viuer to display the image with default configuration
1120    // This will auto-detect the best protocol (Kitty, iTerm2, Sixel, or blocks)
1121    let conf = viuer::Config {
1122        width: Some(60),
1123        height: None,
1124        absolute_offset: false,
1125        ..Default::default()
1126    };
1127
1128    // Try to open and display the image
1129    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        // Regression test: the full-width background bar must reach exactly
1163        // `WIDTH` visible columns regardless of heading depth (the "▶"
1164        // marker is repeated per level, so its width isn't always 1) and
1165        // regardless of embedded OSC 8 hyperlink escapes inflating the raw
1166        // string length without affecting what's actually printed.
1167        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        // Code blocks may be syntax highlighted, so just check for the function name
1187        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        // Check that some separator is rendered
1230        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        // Test with actual parsed markdown to avoid manual construction
1326        let markdown: Markdown = "[link](url) **bold** *italic* `code` text".parse().unwrap();
1327
1328        // Extract nodes from parsed markdown
1329        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); // "Short"
1367        assert_eq!(widths[1], 14); // "Very Long Text"
1368    }
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        // The callout icon and name should be present
1428        assert!(result.contains("ℹ️"));
1429        assert!(result.contains("Note"));
1430        assert!(result.contains("This is a note callout"));
1431        assert!(result.contains("Additional info"));
1432        // Should have box drawing characters
1433        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        // Should contain the HTML content
1500        assert!(result.contains("Hello HTML"));
1501        // Should contain some syntax highlighting (colored output)
1502        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 = "![Alt text](image.png)".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 = "![](image.png)".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 = "![Remote](https://example.com/image.png)".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        // Check for alignment markers in header border
1561        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) | ![Alt](img.png) |
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        // Markdown tables do not support rowspan/colspan, but test for cells with multiline content
1641        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}