use super::cell::Attrs;
use super::rich::{RichLine, RichText, Span};
use super::style::Style;
#[path = "md_stream.rs"]
mod stream;
pub use stream::StreamSession;
#[path = "md_doc.rs"]
mod doc;
pub use doc::{parse_doc, CellAlign, DocBlock, ImageBlock, TableBlock, TaskBlock};
#[path = "md_doc_stream.rs"]
mod doc_stream;
pub use doc_stream::DocStreamSession;
#[path = "md_outline.rs"]
mod outline_impl;
pub use outline_impl::{outline, slugify, Heading};
#[derive(Clone, Debug, PartialEq)]
pub enum Block {
Paragraph(RichLine),
Heading {
level: u8,
content: RichLine,
},
ListItem {
depth: u8,
marker: Marker,
content: RichLine,
},
Blockquote(RichLine),
CodeFence {
lang: String,
lines: Vec<String>,
},
Rule,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum Marker {
Bullet,
Number(u32),
}
#[derive(Clone, Debug)]
pub struct MdStyles {
pub base: Style,
pub bold: Style,
pub italic: Style,
pub code: Style,
pub link: Style,
pub heading: Style,
}
impl Default for MdStyles {
fn default() -> Self {
MdStyles {
base: Style::EMPTY,
bold: Style::new().attrs(Attrs::BOLD),
italic: Style::new().attrs(Attrs::ITALIC),
code: Style::new().attrs(Attrs::DIM),
link: Style::new().attrs(Attrs::UNDERLINE),
heading: Style::new().attrs(Attrs::BOLD),
}
}
}
impl MdStyles {
pub fn with_ink(
code_fg: crate::base::Rgba,
code_bg: crate::base::Rgba,
link_fg: crate::base::Rgba,
) -> MdStyles {
MdStyles {
base: Style::EMPTY,
bold: Style::new().attrs(Attrs::BOLD),
italic: Style::new().attrs(Attrs::ITALIC),
code: Style::new().fg(code_fg).bg(code_bg),
link: Style::new().fg(link_fg).attrs(Attrs::UNDERLINE),
heading: Style::new().attrs(Attrs::BOLD),
}
}
}
pub fn parse(src: &str, styles: &MdStyles) -> Vec<Block> {
let mut blocks = Vec::new();
let mut lines = src.lines().peekable();
let mut para: Option<RichLine> = None;
while let Some(raw) = lines.next() {
let line = raw.trim_end();
let trimmed = line.trim_start();
if let Some(rest) = trimmed.strip_prefix("```") {
flush_para(&mut para, &mut blocks);
let lang = rest.trim().to_string();
let mut body = Vec::new();
for l in lines.by_ref() {
if l.trim_start().starts_with("```") {
break; }
body.push(l.to_string());
}
blocks.push(Block::CodeFence { lang, lines: body });
continue;
}
if trimmed.is_empty() {
flush_para(&mut para, &mut blocks);
continue;
}
if trimmed == "---" || trimmed == "***" {
flush_para(&mut para, &mut blocks);
blocks.push(Block::Rule);
continue;
}
if let Some(h) = heading_level(trimmed) {
flush_para(&mut para, &mut blocks);
let content = &trimmed[h as usize + 1..];
let block_style = styles.base.merge(styles.heading);
blocks.push(Block::Heading {
level: h,
content: parse_inline(content.trim_start(), styles, block_style),
});
continue;
}
if trimmed.starts_with('>') {
flush_para(&mut para, &mut blocks);
let mut rest = trimmed;
while let Some(r) = rest.strip_prefix('>') {
rest = r.trim_start();
}
blocks.push(Block::Blockquote(parse_inline(rest, styles, styles.base)));
continue;
}
if let Some((marker, rest)) = list_marker(trimmed) {
flush_para(&mut para, &mut blocks);
let indent = line.len() - trimmed.len();
blocks.push(Block::ListItem {
depth: (indent / 2).min(8) as u8,
marker,
content: parse_inline(rest, styles, styles.base),
});
continue;
}
let inline = parse_inline(trimmed, styles, styles.base);
match &mut para {
Some(acc) => {
acc.push(Span::new(" ", styles.base));
for s in inline.spans {
acc.push(s);
}
}
None => para = Some(inline),
}
}
flush_para(&mut para, &mut blocks);
blocks
}
pub fn to_rich_text(blocks: &[Block], styles: &MdStyles) -> RichText {
let mut lines = Vec::new();
for b in blocks {
match b {
Block::Paragraph(l) | Block::Heading { content: l, .. } => lines.push(l.clone()),
Block::Blockquote(l) => {
let mut line = RichLine::new();
line.push(Span::new("│ ", styles.base));
for s in &l.spans {
line.push(s.clone());
}
lines.push(line);
}
Block::ListItem {
depth,
marker,
content,
} => {
let mut line = RichLine::new();
let mut prefix = " ".repeat(*depth as usize);
match marker {
Marker::Bullet => prefix.push_str("• "),
Marker::Number(n) => prefix.push_str(&format!("{n}. ")),
}
line.push(Span::new(prefix, styles.base));
for s in &content.spans {
line.push(s.clone());
}
lines.push(line);
}
Block::CodeFence { lines: body, .. } => {
for l in body {
lines.push(RichLine::from_spans(vec![Span::new(
l.clone(),
styles.base.merge(styles.code),
)]));
}
}
Block::Rule => {
lines.push(RichLine::from_spans(vec![Span::new("───", styles.base)]));
}
}
}
RichText::from_lines(lines)
}
fn flush_para(para: &mut Option<RichLine>, blocks: &mut Vec<Block>) {
if let Some(l) = para.take() {
blocks.push(Block::Paragraph(l));
}
}
fn heading_level(line: &str) -> Option<u8> {
let hashes = line.bytes().take_while(|b| *b == b'#').count();
if (1..=6).contains(&hashes) && line.as_bytes().get(hashes) == Some(&b' ') {
Some(hashes as u8)
} else {
None
}
}
fn list_marker(line: &str) -> Option<(Marker, &str)> {
for bullet in ["- ", "* ", "+ "] {
if let Some(rest) = line.strip_prefix(bullet) {
return Some((Marker::Bullet, rest));
}
}
let digits = line.bytes().take_while(|b| b.is_ascii_digit()).count();
if digits > 0 && digits <= 9 {
let rest = &line[digits..];
if let Some(rest) = rest.strip_prefix(". ") {
let n: u32 = line[..digits].parse().unwrap_or(0);
return Some((Marker::Number(n), rest));
}
}
None
}
fn parse_inline(src: &str, styles: &MdStyles, block_style: Style) -> RichLine {
let mut out = RichLine::new();
let b = src.as_bytes();
let mut plain_start = 0;
let mut i = 0;
let flush_plain = |out: &mut RichLine, upto: usize, from: usize| {
if upto > from {
out.push(Span::new(&src[from..upto], block_style));
}
};
while i < b.len() {
match b[i] {
b'\\' if i + 1 < b.len() && is_escapable(b[i + 1]) => {
flush_plain(&mut out, i, plain_start);
out.push(Span::new(&src[i + 1..i + 2], block_style));
i += 2;
plain_start = i;
}
b'*' => {
let bold_marker = b.get(i + 1) == Some(&b'*');
let (open_len, style) = if bold_marker {
(2, styles.bold)
} else {
(1, styles.italic)
};
let close: &[u8] = if bold_marker { b"**" } else { b"*" };
match find_close(b, i + open_len, close) {
Some(end) if end > i + open_len => {
flush_plain(&mut out, i, plain_start);
out.push(Span::new(&src[i + open_len..end], block_style.merge(style)));
i = end + open_len;
plain_start = i;
}
_ => i += open_len, }
}
b'~' if b.get(i + 1) == Some(&b'~') => {
match find_close(b, i + 2, b"~~") {
Some(end) if end > i + 2 => {
flush_plain(&mut out, i, plain_start);
out.push(Span::new(
&src[i + 2..end],
block_style.merge(Style::new().attrs(Attrs::STRIKE)),
));
i = end + 2;
plain_start = i;
}
_ => i += 2, }
}
b'`' => match find_close(b, i + 1, b"`") {
Some(end) if end > i + 1 => {
flush_plain(&mut out, i, plain_start);
out.push(Span::new(&src[i + 1..end], block_style.merge(styles.code)));
i = end + 1;
plain_start = i;
}
_ => i += 1,
},
b'[' => match parse_link(src, i) {
Some((text, url, consumed)) => {
flush_plain(&mut out, i, plain_start);
out.push(Span::new(text, block_style.merge(styles.link)).with_link(url));
i += consumed;
plain_start = i;
}
None => i += 1,
},
_ => i += 1,
}
}
flush_plain(&mut out, b.len(), plain_start);
out
}
fn is_escapable(b: u8) -> bool {
matches!(
b,
b'*' | b'~' | b'`' | b'[' | b']' | b'(' | b')' | b'\\' | b'#'
)
}
fn find_close(b: &[u8], from: usize, close: &[u8]) -> Option<usize> {
let mut i = from;
while i + close.len() <= b.len() {
if b[i] == b'\\' {
i += 2;
continue;
}
if &b[i..i + close.len()] == close {
return Some(i);
}
i += 1;
}
None
}
fn parse_link(src: &str, at: usize) -> Option<(&str, &str, usize)> {
let b = src.as_bytes();
let text_end = find_close(b, at + 1, b"]")?;
if b.get(text_end + 1) != Some(&b'(') {
return None;
}
let url_end = find_close(b, text_end + 2, b")")?;
let text = &src[at + 1..text_end];
let url = &src[text_end + 2..url_end];
if text.is_empty() || url.is_empty() {
return None;
}
Some((text, url, url_end + 1 - at))
}
#[cfg(test)]
#[path = "md_tests.rs"]
mod tests;