328 lines
10 KiB
Rust
328 lines
10 KiB
Rust
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<T: MarkdownTheme, H: CodeHighlighter>(
|
|
md: &str,
|
|
theme: &T,
|
|
highlighter: &H,
|
|
) -> Vec<RLine<'static>> {
|
|
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<TableRow> = Vec::new();
|
|
|
|
let flush_table = |buf: &mut Vec<TableRow>, out: &mut Vec<RLine<'static>>, 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<usize> {
|
|
let mut widths: Vec<usize> = 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<T: MarkdownTheme>(
|
|
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<Span<'static>> = 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::<usize>();
|
|
|
|
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<T: MarkdownTheme>(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<Span<'static>> = 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<T: MarkdownTheme>(
|
|
compound: Compound,
|
|
base: Style,
|
|
out: &mut Vec<Span<'static>>,
|
|
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);
|
|
}
|
|
}
|