use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
use ratatui_core::style::{Modifier, Style};
use crate::components::code_block::code_block_lines;
use crate::highlight::CodeHighlighter;
use crate::style::{StyleBundle, StyleSheet, Theme};
use super::flatten::trim_spans;
use super::image::ImageResolver;
use super::item::{Cell, INDENT, MdItem, RichSpan, TableData};
pub(super) fn parse(
source: &str,
theme: &Theme,
sheet: &StyleSheet,
highlighter: CodeHighlighter,
) -> Vec<MdItem> {
parse_with(source, theme, sheet, highlighter, None)
}
pub(super) fn parse_with(
source: &str,
theme: &Theme,
sheet: &StyleSheet,
highlighter: CodeHighlighter,
resolver: Option<&dyn ImageResolver>,
) -> Vec<MdItem> {
let mut b = Builder::new(theme, sheet, highlighter, resolver);
let mut opts = Options::empty();
opts.insert(Options::ENABLE_TABLES);
opts.insert(Options::ENABLE_STRIKETHROUGH);
opts.insert(Options::ENABLE_TASKLISTS);
for event in Parser::new_ext(source, opts) {
b.event(event);
}
b.finish();
b.items
}
struct Builder<'a> {
theme: &'a Theme,
sheet: &'a StyleSheet,
highlighter: CodeHighlighter<'a>,
items: Vec<MdItem>,
inline: Vec<RichSpan>,
style_stack: Vec<Style>,
link_stack: Vec<String>,
lists: Vec<Option<u64>>, quote_depth: u16,
pending_marker: Option<Vec<RichSpan>>,
code: Option<(String, String)>,
table: Option<TableData>,
cur_row: Vec<Cell>,
image: Option<(String, String)>,
resolver: Option<&'a dyn ImageResolver>,
}
impl<'a> Builder<'a> {
fn new(
theme: &'a Theme,
sheet: &'a StyleSheet,
highlighter: CodeHighlighter<'a>,
resolver: Option<&'a dyn ImageResolver>,
) -> Self {
Self {
theme,
sheet,
highlighter,
items: Vec::new(),
inline: Vec::new(),
style_stack: vec![Style::default().fg(theme.text)],
link_stack: Vec::new(),
lists: Vec::new(),
quote_depth: 0,
pending_marker: None,
code: None,
table: None,
cur_row: Vec::new(),
image: None,
resolver,
}
}
fn cur_style(&self) -> Style {
*self.style_stack.last().unwrap()
}
fn cur_href(&self) -> Option<String> {
self.link_stack.last().cloned()
}
fn indent(&self) -> u16 {
(self.lists.len().saturating_sub(1) as u16 + self.quote_depth) * INDENT
}
fn nested(&self) -> bool {
!self.lists.is_empty() || self.quote_depth > 0
}
fn separate(&mut self) {
if !self.inline.is_empty() {
self.flush();
}
if self.nested() {
return;
}
if matches!(self.items.last(), None | Some(MdItem::Blank)) {
return;
}
self.items.push(MdItem::Blank);
}
fn flush(&mut self) {
if self.inline.is_empty() && self.pending_marker.is_none() {
return;
}
let indent = self.indent();
let mut spans = self.pending_marker.take().unwrap_or_default();
spans.append(&mut self.inline);
self.items.push(MdItem::Prose { spans, indent });
}
fn push_text(&mut self, text: &str) {
if let Some((_, body)) = self.code.as_mut() {
body.push_str(text);
return;
}
if let Some((_, alt)) = self.image.as_mut() {
alt.push_str(text);
return;
}
let style = self.cur_style();
let href = self.cur_href();
if href.is_some() || style.add_modifier.contains(Modifier::UNDERLINED) {
self.inline
.push(RichSpan::styled(text.to_string(), style, href));
} else {
for span in linkify(text, style, self.sheet.link) {
self.inline.push(span);
}
}
}
fn push_image_placeholder(&mut self, url: &str, alt: &str) {
let label = if alt.trim().is_empty() { url } else { alt };
self.inline.push(RichSpan::styled(
"🖼 ",
self.sheet.image_marker.to_style(),
None,
));
self.inline.push(RichSpan::styled(
label.to_string(),
self.sheet.link.to_style(),
Some(url.to_string()),
));
}
fn event(&mut self, event: Event<'a>) {
match event {
Event::Start(tag) => self.start(tag),
Event::End(tag) => self.end(tag),
Event::Text(t) => self.push_text(&t),
Event::Code(t) => {
self.inline.push(RichSpan::styled(
t.to_string(),
self.sheet.inline_code.to_style(),
self.cur_href(),
));
}
Event::SoftBreak => {
if self.code.is_none() {
self.inline
.push(RichSpan::styled(" ", self.cur_style(), self.cur_href()));
}
}
Event::HardBreak if self.table.is_some() => {
self.inline
.push(RichSpan::styled(" ", self.cur_style(), self.cur_href()))
}
Event::HardBreak => self.flush(),
Event::Rule => {
self.separate();
self.items.push(MdItem::Prose {
spans: vec![RichSpan::styled(
"─".repeat(24),
self.sheet.rule.to_style(),
None,
)],
indent: self.indent(),
});
}
Event::TaskListMarker(done) => {
let glyph = if done { "[x] " } else { "[ ] " };
self.inline.push(RichSpan::styled(
glyph.to_string(),
self.sheet.task_marker.to_style(),
None,
));
}
_ => {}
}
}
fn start(&mut self, tag: Tag<'a>) {
match tag {
Tag::Paragraph => self.separate(),
Tag::Heading { level, .. } => {
self.separate();
let mut style = self.sheet.heading.to_style();
if level > HeadingLevel::H2 {
style = style.add_modifier(Modifier::ITALIC);
}
self.style_stack.push(style);
}
Tag::BlockQuote(_) => {
self.separate();
self.quote_depth += 1;
}
Tag::CodeBlock(kind) => {
self.separate();
let lang = match kind {
CodeBlockKind::Fenced(info) => {
info.split_whitespace().next().unwrap_or("").to_string()
}
CodeBlockKind::Indented => String::new(),
};
self.code = Some((lang, String::new()));
}
Tag::List(start) => {
self.separate();
self.lists.push(start);
}
Tag::Item => {
let marker = match self.lists.last_mut() {
Some(Some(n)) => {
let m = format!("{n}. ");
*self.lists.last_mut().unwrap() = Some(n.saturating_add(1));
m
}
_ => "• ".to_string(),
};
self.pending_marker = Some(vec![RichSpan::styled(
marker,
self.sheet.list_marker.to_style(),
None,
)]);
}
Tag::Emphasis => self.push_style_bundle(self.sheet.emphasis),
Tag::Strong => self.push_style_bundle(self.sheet.strong),
Tag::Strikethrough => self.push_style_bundle(self.sheet.strikethrough),
Tag::Link { dest_url, .. } => {
self.link_stack.push(dest_url.to_string());
self.style_stack.push(self.sheet.link.to_style());
}
Tag::Image { dest_url, .. } => {
self.image = Some((dest_url.to_string(), String::new()));
}
Tag::Table(aligns) => {
self.separate();
self.table = Some(TableData {
aligns,
header: Vec::new(),
rows: Vec::new(),
});
}
Tag::TableHead => {
self.cur_row.clear();
self.style_stack.push(self.sheet.heading.to_style());
}
Tag::TableRow => self.cur_row.clear(),
Tag::TableCell => self.inline.clear(),
_ => {}
}
}
fn end(&mut self, tag: TagEnd) {
match tag {
TagEnd::Paragraph => self.flush(),
TagEnd::Heading(_) => {
self.flush();
self.style_stack.pop();
}
TagEnd::BlockQuote(_) => {
self.flush();
self.quote_depth = self.quote_depth.saturating_sub(1);
}
TagEnd::CodeBlock => {
if let Some((lang, body)) = self.code.take() {
self.push_code_block(lang, body);
}
}
TagEnd::List(_) => {
self.lists.pop();
}
TagEnd::Item => self.flush(),
TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough => {
self.style_stack.pop();
}
TagEnd::Link => {
self.style_stack.pop();
self.link_stack.pop();
}
TagEnd::Image => {
if let Some((url, alt)) = self.image.take() {
match self.resolver.and_then(|r| r.resolve(&url)) {
Some(data) => {
self.flush();
let indent = self.indent();
self.items.push(MdItem::Image { data, alt, indent });
}
None => self.push_image_placeholder(&url, &alt),
}
}
}
TagEnd::TableCell => {
let cell = trim_spans(std::mem::take(&mut self.inline));
self.cur_row.push(cell);
}
TagEnd::TableHead => {
self.style_stack.pop();
if let Some(t) = self.table.as_mut() {
t.header = std::mem::take(&mut self.cur_row);
}
}
TagEnd::TableRow => {
if let Some(t) = self.table.as_mut() {
t.rows.push(std::mem::take(&mut self.cur_row));
}
}
TagEnd::Table => {
if let Some(table) = self.table.take() {
let indent = self.indent();
self.items.push(MdItem::Table { table, indent });
}
}
_ => {}
}
}
fn push_style_bundle(&mut self, bundle: StyleBundle) {
let s = bundle.apply(self.cur_style());
self.style_stack.push(s);
}
fn finish(&mut self) {
if self.table.is_some() {
self.inline.clear();
}
self.flush();
if let Some((lang, body)) = self.code.take() {
self.push_code_block(lang, body);
}
}
fn push_code_block(&mut self, language: String, body: String) {
let source = body.strip_suffix('\n').unwrap_or(&body).to_string();
let lines: Vec<&str> = source.split('\n').collect();
let fallback =
code_block_lines(&language, &lines, self.theme, self.highlighter, true, None);
self.items.push(MdItem::CodeBlock {
language,
source,
fallback,
indent: self.indent(),
});
}
}
pub(super) fn linkify(text: &str, base: Style, link: StyleBundle) -> Vec<RichSpan> {
let mut spans = Vec::new();
let mut rest = text;
while let Some(start) = rest.find("http://").or_else(|| rest.find("https://")) {
let (before, from) = rest.split_at(start);
if !before.is_empty() {
spans.push(RichSpan::styled(before.to_string(), base, None));
}
let raw = from.find(char::is_whitespace).unwrap_or(from.len());
let end = from[..raw]
.trim_end_matches(['.', ',', ';', ':', '!', '?', ')', ']'])
.len();
let (url, after) = from.split_at(end.max(1));
spans.push(RichSpan::styled(
url.to_string(),
link.apply(base),
Some(url.to_string()),
));
rest = after;
}
if !rest.is_empty() {
spans.push(RichSpan::styled(rest.to_string(), base, None));
}
if spans.is_empty() {
spans.push(RichSpan::styled(text.to_string(), base, None));
}
spans
}