rusty-rich 0.1.0

Rich text and beautiful formatting in the terminal — a Rust port of Python's Rich library
Documentation
//! Markdown rendering — equivalent to Rich's `markdown.py`.
//!
//! Uses `pulldown-cmark` for parsing and renders headings, code blocks,
//! lists, tables, blockquotes, and inline formatting.

use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};

use crate::console::{ConsoleOptions, RenderResult, Renderable};
use crate::rule::Rule;
use crate::segment::Segment;
use crate::style::Style;

/// Render markdown text.
pub fn render_markdown(md: &str) -> MarkdownRender {
    MarkdownRender {
        source: md.to_string(),
        width: None,
        code_theme: "default".to_string(),
        hyperlinks: true,
    }
}

#[derive(Debug, Clone)]
pub struct MarkdownRender {
    source: String,
    width: Option<usize>,
    code_theme: String,
    hyperlinks: bool,
}

impl MarkdownRender {
    pub fn width(mut self, w: usize) -> Self { self.width = Some(w); self }

    fn get_style(name: &str) -> Style {
        use crate::theme::default_theme;
        let theme = default_theme();
        theme.get(name).cloned().unwrap_or(Style::new())
    }
}

impl Renderable for MarkdownRender {
    fn render(&self, options: &ConsoleOptions) -> RenderResult {
        let width = self.width.unwrap_or(options.max_width);
        let parser = Parser::new_ext(&self.source, Options::all());

        let mut lines: Vec<Vec<Segment>> = Vec::new();
        let mut current_line: Vec<Segment> = Vec::new();
        let mut in_code_block = false;
        let mut in_heading = false;
        let mut heading_level = 0u8;
        let mut in_paragraph = false;
        let mut list_depth = 0usize;
        let mut current_link: Option<String> = None;
        let mut link_text: Option<String> = None;

        for event in parser {
            match event {
                Event::Start(Tag::Heading { level, .. }) => {
                    in_heading = true;
                    heading_level = level as u8;
                    let style = match level {
                        HeadingLevel::H1 => Self::get_style("markdown.h1"),
                        HeadingLevel::H2 => Self::get_style("markdown.h2"),
                        _ => Style::new().bold(true),
                    };
                    let prefix = "#".repeat(level as usize);
                    current_line.push(Segment::styled(
                        format!("{prefix} "),
                        style.clone(),
                    ));
                }
                Event::End(TagEnd::Heading(_)) => {
                    in_heading = false;
                    lines.push(current_line.clone());
                    current_line.clear();
                    // Add a rule under H1/H2
                    if heading_level <= 2 {
                        let rule_char = if heading_level == 1 { '' } else { '' };
                        let rule_line = rule_char.to_string().repeat(width);
                        lines.push(vec![Segment::new(rule_line), Segment::line()]);
                    }
                }
                Event::Start(Tag::Paragraph) => {
                    in_paragraph = true;
                }
                Event::End(TagEnd::Paragraph) => {
                    in_paragraph = false;
                    if !current_line.is_empty() {
                        current_line.push(Segment::line());
                        lines.push(current_line.clone());
                        current_line.clear();
                    }
                    // Add blank line after paragraph
                    lines.push(vec![Segment::line()]);
                }
                Event::Start(Tag::CodeBlock(kind)) => {
                    in_code_block = true;
                    let lang = match kind {
                        CodeBlockKind::Fenced(lang) => {
                            if lang.is_empty() { String::new() } else { lang.to_string() }
                        }
                        CodeBlockKind::Indented => String::new(),
                    };
                    let title = if lang.is_empty() {
                        "Code".to_string()
                    } else {
                        format!("Code: {lang}")
                    };
                    // Code block opening
                    let code_style = Self::get_style("markdown.code");
                    current_line.push(Segment::styled(
                        format!("┌─ {title} "),
                        code_style.clone(),
                    ));
                    current_line.push(Segment::line());
                    lines.push(current_line.clone());
                    current_line.clear();
                }
                Event::End(TagEnd::CodeBlock) => {
                    in_code_block = false;
                    if !current_line.is_empty() {
                        lines.push(current_line.clone());
                        current_line.clear();
                    }
                    let code_style = Self::get_style("markdown.code");
                    lines.push(vec![Segment::styled(
                        format!("{}", "".repeat(width.saturating_sub(2))),
                        code_style,
                    ), Segment::line()]);
                }
                Event::Start(Tag::List(_)) => {
                    list_depth += 1;
                }
                Event::End(TagEnd::List(_)) => {
                    list_depth = list_depth.saturating_sub(1);
                }
                Event::Start(Tag::Item) => {
                    let indent = "  ".repeat(list_depth.saturating_sub(1));
                    let bullet = if list_depth > 1 { "" } else { "" };
                    current_line.push(Segment::new(format!("{indent}{bullet} ")));
                }
                Event::End(TagEnd::Item) => {
                    lines.push(current_line.clone());
                    current_line.clear();
                }
                Event::Start(Tag::BlockQuote) => {
                    let quote_style = Self::get_style("markdown.blockquote");
                    current_line.push(Segment::styled("", quote_style));
                }
                Event::End(TagEnd::BlockQuote) => {
                    lines.push(current_line.clone());
                    current_line.clear();
                }
                Event::Start(Tag::Emphasis) => {
                    current_line.push(Segment::styled("", Style::new().italic(true)));
                }
                Event::End(TagEnd::Emphasis) => {
                    // Inline — handled via style stack
                }
                Event::Start(Tag::Strong) => {
                    // handled inline
                }
                Event::End(TagEnd::Strong) => {}
                Event::Start(Tag::Link { dest_url, .. }) => {
                    current_link = Some(dest_url.to_string());
                    link_text = Some(String::new());
                }
                Event::End(TagEnd::Link) => {
                    if let (Some(url), Some(text)) = (current_link.take(), link_text.take()) {
                        let link_style = Self::get_style("markdown.link");
                        let display = if text.is_empty() { url.clone() } else { text };
                        current_line.push(Segment::styled(
                            format!("{display} ({url})"),
                            link_style,
                        ));
                    }
                }
                Event::Text(text) | Event::Code(text) => {
                    let s: &str = &text;
                    // Collect link text if we're inside a link
                    if current_link.is_some() {
                        if let Some(ref mut lt) = link_text {
                            lt.push_str(s);
                        }
                    }
                    if in_code_block {
                        // Indent code
                        for line in s.lines() {
                            current_line.push(Segment::new(format!("{line}")));
                            current_line.push(Segment::line());
                            lines.push(current_line.clone());
                            current_line.clear();
                        }
                    } else {
                        current_line.push(Segment::new(s));
                    }
                }
                Event::SoftBreak => {
                    current_line.push(Segment::new(" "));
                }
                Event::HardBreak => {
                    current_line.push(Segment::line());
                    lines.push(current_line.clone());
                    current_line.clear();
                }
                Event::Rule => {
                    let rule = Rule::new().characters("");
                    let res = rule.render(options);
                    lines.extend(res.lines);
                }
                _ => {}
            }
        }

        // Flush remaining
        if !current_line.is_empty() {
            current_line.push(Segment::line());
            lines.push(current_line);
        }

        RenderResult { lines, items: Vec::new() }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_markdown_heading() {
        let md = render_markdown("# Hello\n\nWorld");
        let opts = ConsoleOptions::default();
        let result = md.render(&opts);
        let ansi = result.to_ansi();
        assert!(ansi.contains("Hello"));
    }
}