mod elements;
mod parser;
mod renderer;
mod stream_renderer;
#[cfg(feature = "syntax-highlight")]
pub mod highlight;
use elements::*;
use parser::parse_document;
use renderer::render_element;
pub use stream_renderer::StreamRenderer;
#[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;
}
}
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")
}
fn current_terminal_width() -> usize {
terminal_size::terminal_size()
.map(|(w, _)| w.0 as usize)
.unwrap_or(80)
}
pub fn is_light_terminal() -> bool {
if let Ok(colorfgbg) = std::env::var("COLORFGBG") {
let parts: Vec<&str> = colorfgbg.split(';').collect();
if let Some(bg) = parts.get(1)
&& let Ok(bg_num) = bg.parse::<u8>()
{
return bg_num >= 7;
}
}
false
}
#[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"));
}
}