use minimad::{Composite, CompositeStyle, Compound, Line, TableRow}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line as RLine, Span}; use crate::highlighter::CodeHighlighter; use crate::theme::MarkdownTheme; pub fn parse( md: &str, theme: &T, highlighter: &H, ) -> Vec> { let processed = preprocess_markdown(md); let text = minimad::Text::from(processed.as_str()); let mut lines = Vec::new(); let mut code_line_nr: usize = 0; let mut table_buffer: Vec = Vec::new(); let flush_table = |buf: &mut Vec, out: &mut Vec>, theme: &T| { if buf.is_empty() { return; } let col_widths = compute_column_widths(buf); for (row_idx, row) in buf.drain(..).enumerate() { out.push(render_table_row(row, row_idx, &col_widths, theme)); } }; for line in text.lines { match line { Line::Normal(composite) if composite.style == CompositeStyle::Code => { flush_table(&mut table_buffer, &mut lines, theme); code_line_nr += 1; let raw: String = composite .compounds .iter() .map(|c: &minimad::Compound| c.src) .collect(); let mut spans = vec![ Span::styled(format!(" {code_line_nr:>2} "), theme.code_border()), Span::styled("│ ", theme.code_border()), ]; spans.extend( highlighter .highlight(&raw) .into_iter() .map(|(style, text)| Span::styled(text, style)), ); lines.push(RLine::from(spans)); } Line::Normal(composite) => { flush_table(&mut table_buffer, &mut lines, theme); code_line_nr = 0; lines.push(composite_to_line(composite, theme)); } Line::TableRow(row) => { code_line_nr = 0; table_buffer.push(row); } Line::TableRule(_) => {} _ => { flush_table(&mut table_buffer, &mut lines, theme); code_line_nr = 0; lines.push(RLine::from("")); } } } flush_table(&mut table_buffer, &mut lines, theme); lines } pub fn preprocess_markdown(md: &str) -> String { let mut out = String::with_capacity(md.len()); for line in md.lines() { let line = convert_dash_lists(line); let mut result = String::with_capacity(line.len()); let mut chars = line.char_indices().peekable(); let bytes = line.as_bytes(); while let Some((i, c)) = chars.next() { if c == '`' { result.push(c); for (_, ch) in chars.by_ref() { result.push(ch); if ch == '`' { break; } } continue; } if c == '_' { let before_is_space = i == 0 || bytes[i - 1] == b' '; if before_is_space { if let Some(end) = line[i + 1..].find('_') { let inner = &line[i + 1..i + 1 + end]; if !inner.is_empty() { result.push('*'); result.push_str(inner); result.push('*'); for _ in 0..end { chars.next(); } chars.next(); continue; } } } } result.push(c); } out.push_str(&result); out.push('\n'); } out } pub fn convert_dash_lists(line: &str) -> String { let trimmed = line.trim_start(); if let Some(rest) = trimmed.strip_prefix("- ") { let indent = line.len() - trimmed.len(); format!("{}* {}", " ".repeat(indent), rest) } else { line.to_string() } } fn cell_text_width(cell: &Composite) -> usize { cell.compounds.iter().map(|c| c.src.chars().count()).sum() } fn compute_column_widths(rows: &[TableRow]) -> Vec { let mut widths: Vec = Vec::new(); for row in rows { for (i, cell) in row.cells.iter().enumerate() { let w = cell_text_width(cell); if i >= widths.len() { widths.push(w); } else if w > widths[i] { widths[i] = w; } } } widths } fn render_table_row( row: TableRow, row_idx: usize, col_widths: &[usize], theme: &T, ) -> RLine<'static> { let is_header = row_idx == 0; let bg = if is_header { theme.table_header_bg() } else if row_idx.is_multiple_of(2) { theme.table_row_even() } else { theme.table_row_odd() }; let base_style = if is_header { theme.text().bg(bg).add_modifier(Modifier::BOLD) } else { theme.text().bg(bg) }; let sep_style = theme.code_border().bg(bg); let mut spans: Vec> = Vec::new(); for (i, cell) in row.cells.into_iter().enumerate() { if i > 0 { spans.push(Span::styled(" │ ", sep_style)); } let target_width = col_widths.get(i).copied().unwrap_or(0); let cell_width = cell .compounds .iter() .map(|c| c.src.chars().count()) .sum::(); for compound in cell.compounds { compound_to_spans(compound, base_style, &mut spans, theme); } let padding = target_width.saturating_sub(cell_width); if padding > 0 { spans.push(Span::styled(" ".repeat(padding), base_style)); } } RLine::from(spans) } fn composite_to_line(composite: Composite, theme: &T) -> RLine<'static> { let base_style = match composite.style { CompositeStyle::Header(1) => theme.h1(), CompositeStyle::Header(2) => theme.h2(), CompositeStyle::Header(_) => theme.h3(), CompositeStyle::ListItem(_) => theme.list(), CompositeStyle::Quote => theme.quote(), CompositeStyle::Code => theme.code(), CompositeStyle::Paragraph => theme.text(), }; let prefix: String = match composite.style { CompositeStyle::ListItem(depth) => { let indent = " ".repeat(depth as usize); format!("{indent}• ") } CompositeStyle::Quote => " │ ".to_string(), _ => String::new(), }; let mut spans: Vec> = Vec::new(); if !prefix.is_empty() { spans.push(Span::styled(prefix, base_style)); } for compound in composite.compounds { compound_to_spans(compound, base_style, &mut spans, theme); } RLine::from(spans) } fn compound_to_spans( compound: Compound, base: Style, out: &mut Vec>, theme: &T, ) { let mut style = base; if compound.bold { style = style.add_modifier(Modifier::BOLD); } if compound.italic { style = style.add_modifier(Modifier::ITALIC); } if compound.code { style = theme.code(); } if compound.strikeout { style = style.add_modifier(Modifier::CROSSED_OUT); } let src = compound.src.to_string(); let link_style = theme.link(); let mut rest = src.as_str(); while let Some(start) = rest.find('[') { let after_bracket = &rest[start + 1..]; if let Some(text_end) = after_bracket.find("](") { let url_start = start + 1 + text_end + 2; if let Some(url_end) = rest[url_start..].find(')') { if start > 0 { out.push(Span::styled(rest[..start].to_string(), style)); } let text = &rest[start + 1..start + 1 + text_end]; let url = &rest[url_start..url_start + url_end]; if text == url { out.push(Span::styled(url.to_string(), link_style)); } else { out.push(Span::styled(text.to_string(), link_style)); out.push(Span::styled(format!(" ({url})"), theme.link_url())); } rest = &rest[url_start + url_end + 1..]; continue; } } out.push(Span::styled(rest[..start + 1].to_string(), style)); rest = &rest[start + 1..]; } if !rest.is_empty() { out.push(Span::styled(rest.to_string(), style)); } } #[cfg(test)] mod tests { use super::*; use crate::highlighter::NoHighlight; use crate::theme::DefaultTheme; #[test] fn test_preprocess_underscores() { assert_eq!(preprocess_markdown("_italic_"), "*italic*\n"); assert_eq!(preprocess_markdown("word_with_underscores"), "word_with_underscores\n"); assert_eq!(preprocess_markdown("hello _world_"), "hello *world*\n"); } #[test] fn test_preprocess_dash_lists() { assert_eq!(convert_dash_lists("- item"), "* item"); assert_eq!(convert_dash_lists(" - nested"), " * nested"); assert_eq!(convert_dash_lists("not-a-list"), "not-a-list"); } #[test] fn test_parse_headings() { let md = "# H1\n## H2\n### H3"; let lines = parse(md, &DefaultTheme, &NoHighlight); assert_eq!(lines.len(), 3); } #[test] fn test_parse_code_block() { let md = "```\ncode line\n```"; let lines = parse(md, &DefaultTheme, &NoHighlight); assert!(!lines.is_empty()); } #[test] fn test_parse_table() { let md = "| A | B |\n|---|---|\n| 1 | 2 |"; let lines = parse(md, &DefaultTheme, &NoHighlight); assert_eq!(lines.len(), 2); } #[test] fn test_default_theme_works() { let md = "Hello **world**"; let lines = parse(md, &DefaultTheme, &NoHighlight); assert_eq!(lines.len(), 1); } }