smart-markdown 0.1.0

Parse and render Markdown to ANSI-styled terminal output with live in-place refresh
Documentation
mod elements;
mod parser;
mod renderer;
#[cfg(feature = "syntax-highlight")]
pub mod highlight;

use elements::*;
use parser::parse_document;
use renderer::render_element;

#[cfg(feature = "syntax-highlight")]
pub use highlight::ThemeMode;

#[cfg(not(feature = "syntax-highlight"))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ThemeMode { Dark, Light, Auto }

pub struct Markdown {
    elements: Vec<MarkdownElement>,
    last_width: usize,
    last_rendered_lines: usize,
    has_rendered: bool,
    theme_mode: ThemeMode,
    code_theme: Option<String>,
}

impl Markdown {
    pub fn parse(text: &str) -> Self {
        Markdown {
            elements: parse_document(text),
            last_width: 0,
            last_rendered_lines: 0,
            has_rendered: false,
            theme_mode: ThemeMode::Auto,
            code_theme: None,
        }
    }

    pub fn theme_mode(mut self, mode: ThemeMode) -> Self {
        self.theme_mode = mode;
        self
    }

    pub fn code_theme(mut self, theme: &str) -> Self {
        self.code_theme = Some(theme.to_string());
        self
    }

    pub fn has_terminal_resized(&self) -> bool {
        let current = current_terminal_width();
        current != self.last_width && self.last_width > 0
    }

    pub fn append_to_cell(&mut self, row: usize, col: usize, text: &str) {
        for elem in &mut self.elements {
            if let MarkdownElement::Table(td) = elem {
                if row < td.rows.len() && col < td.headers.len() {
                    td.rows[row][col].push_str(text);
                }
                return;
            }
        }
    }

    pub fn set_cell_content(&mut self, row: usize, col: usize, text: &str) {
        for elem in &mut self.elements {
            if let MarkdownElement::Table(td) = elem {
                if row < td.rows.len() && col < td.headers.len() {
                    td.rows[row][col] = text.to_string();
                }
                return;
            }
        }
    }

    pub fn render(&mut self) {
        let width = current_terminal_width();
        let mode = self.theme_mode;
        let mut output: Vec<String> = Vec::new();

        for elem in &self.elements {
            let lines = render_element(elem, width, mode, self.code_theme.as_deref());
            output.extend(lines);
        }

        let new_line_count = output.len();

        if self.has_rendered {
            print!("\x1b[{}A", self.last_rendered_lines);
        }

        for line in &output {
            if self.has_rendered {
                print!("\x1b[2K\r");
            }
            println!("{line}");
        }

        if self.has_rendered && new_line_count < self.last_rendered_lines {
            for _ in new_line_count..self.last_rendered_lines {
                print!("\x1b[2K\r");
                println!();
            }
            if self.last_rendered_lines > new_line_count {
                print!("\x1b[{}A", self.last_rendered_lines.saturating_sub(new_line_count));
            }
        }

        self.last_rendered_lines = new_line_count;
        self.last_width = width;
        self.has_rendered = true;
    }
}

fn current_terminal_width() -> usize {
    terminal_size::terminal_size()
        .map(|(w, _)| w.0 as usize)
        .unwrap_or(80)
}

pub fn render_to_string(markdown: &str, width: usize) -> String {
    let elements = parse_document(markdown);
    let mut output: Vec<String> = Vec::new();
    for elem in &elements {
        output.extend(render_element(elem, width, ThemeMode::Auto, None));
    }
    output.join("\n")
}

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

    #[test]
    fn parse_heading_setext_level_1() {
        let result = render_to_string("Hello\n=====\n", 40);
        assert!(result.contains("Hello"));
        assert!(result.contains(""));
    }

    #[test]
    fn parse_heading_setext_level_2() {
        let result = render_to_string("Hello\n-----\n", 40);
        assert!(result.contains("Hello"));
        assert!(result.contains(""));
    }

    #[test]
    fn parse_heading_atx() {
        let result = render_to_string("### Level 3\n", 40);
        assert!(result.contains("Level 3"));
        assert!(result.contains(""));
    }

    #[test]
    fn parse_bold_and_italic() {
        let result = render_to_string("**bold** and *italic*\n", 40);
        assert!(result.contains("\x1b[1mbold\x1b[0m"));
        assert!(result.contains("\x1b[3mitalic\x1b[0m"));
    }

    #[test]
    fn parse_strikethrough() {
        let result = render_to_string("~~deleted~~\n", 40);
        assert!(result.contains("\x1b[9mdeleted\x1b[0m"));
    }

    #[test]
    fn parse_inline_code() {
        let result = render_to_string("`code`\n", 40);
        assert!(result.contains("\x1b[7m code \x1b[0m"));
    }

    #[test]
    fn parse_link() {
        let result = render_to_string("[example](https://example.com)\n", 40);
        assert!(result.contains("\x1b[4mexample\x1b[0m"));
    }

    #[test]
    fn parse_unordered_list() {
        let result = render_to_string("- one\n- two\n- three\n", 40);
        assert_eq!(result.lines().filter(|l| l.starts_with("")).count(), 3);
    }

    #[test]
    fn parse_ordered_list() {
        let result = render_to_string("1. first\n2. second\n3. third\n", 40);
        assert_eq!(result.lines().filter(|l| l.starts_with("1.")).count(), 1);
        assert_eq!(result.lines().filter(|l| l.starts_with("2.")).count(), 1);
    }

    #[test]
    fn parse_table() {
        let result = render_to_string("| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n", 80);
        assert!(result.contains("│ a"));
        assert!(result.contains("│ 1"));
    }

    #[test]
    fn parse_code_block() {
        let result = render_to_string("```\nlet x = 1;\n```\n", 80);
        assert!(result.contains("let x = 1;"));
    }

    #[test]
    fn parse_blockquote() {
        let result = render_to_string("> quoted text here\n", 40);
        assert!(result.contains("quoted text here"));
    }

    #[test]
    fn parse_horizontal_rule() {
        let result = render_to_string("---\n", 40);
        assert!(result.starts_with(""));
    }

    #[test]
    fn markdown_parse_and_streaming() {
        let mut md = Markdown::parse("| col |\n|-----|\n| a   |\n");
        md.append_to_cell(0, 0, "ppended");
        let after = render_to_string("| col |\n|-----|\n| appended |\n", 80);
        assert!(after.contains("appended"));
    }

    #[test]
    fn set_cell_content_replaces() {
        let mut md = Markdown::parse("| col |\n|-----|\n| old |\n");
        md.set_cell_content(0, 0, "new");
        let result = render_to_string("| col |\n|-----|\n| new |\n", 80);
        assert!(result.contains("new"));
        assert!(!result.contains("old"));
    }

    #[test]
    fn table_fill_column() {
        let result = render_to_string("| a |  |\n|---|---|\n| 1 |  |\n", 100);
        assert!(result.contains("│ a"));
    }

    #[test]
    fn table_alignment_center() {
        let result = render_to_string("| a |\n|:---:|\n| 1 |\n", 80);
        assert!(result.contains(""));
    }

    #[test]
    fn table_alignment_right() {
        let result = render_to_string("| a |\n|---:|\n| 1 |\n", 80);
        assert!(result.contains(""));
    }

    #[test]
    fn paragraph_soft_wrap() {
        let long = "a ".repeat(50);
        let result = render_to_string(&format!("{long}\n"), 40);
        assert!(result.contains('\n'));
    }

    #[test]
    fn blank_line_preserved() {
        let result = render_to_string("para 1\n\npara 2\n", 40);
        let empties = result.lines().filter(|l| l.is_empty()).count();
        assert!(empties >= 1);
    }

    #[test]
    fn parse_reference_link() {
        let result = render_to_string("[text][ref]\n\n[ref]: https://example.com\n", 80);
        assert!(result.contains("\x1b[4mtext\x1b[0m"));
    }

    #[test]
    fn parse_reference_link_implicit() {
        let result = render_to_string("[text][]\n\n[text]: https://example.com\n", 80);
        assert!(result.contains("\x1b[4mtext\x1b[0m"));
    }

    #[test]
    fn reference_link_case_insensitive() {
        let result = render_to_string("[text][REF]\n\n[ref]: https://example.com\n", 80);
        assert!(result.contains("\x1b[4mtext\x1b[0m"));
    }
}