use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
use ratatui::{
style::{Modifier, Style},
text::Line,
};
use crate::{text::wrap::wrap_lines, theme::Theme};
#[derive(Clone, Copy, Debug)]
enum BlockKind {
Normal,
Heading,
Code,
}
#[derive(Clone, Debug)]
struct ParsedBlock {
kind: BlockKind,
text: String,
}
#[derive(Debug, Default)]
pub struct StreamingMarkdown {
frozen_blocks: Vec<ParsedBlock>,
checkpoint: usize,
pending_tail: String,
full_text: String,
}
impl StreamingMarkdown {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn push_token(&mut self, token: &str) {
self.pending_tail.push_str(token);
self.full_text.push_str(token);
self.advance_checkpoint();
}
fn advance_checkpoint(&mut self) {
let paragraph_boundary = self.pending_tail.rfind("\n\n").map(|offset| offset + 2);
let code_boundary = last_closed_fence(&self.pending_tail);
let Some(boundary) = paragraph_boundary.max(code_boundary) else {
return;
};
let stable = self.pending_tail[..boundary].to_owned();
self.pending_tail.drain(..boundary);
self.checkpoint += boundary;
self.frozen_blocks.extend(parse_markdown_blocks(&stable));
}
#[must_use]
pub fn lines(&self, width: u16, theme: &Theme) -> Vec<Line<'static>> {
let mut blocks = self.frozen_blocks.clone();
blocks.extend(parse_markdown_blocks(&self.pending_tail));
render_blocks(&blocks, width, theme)
}
pub fn invalidate(&mut self) {
let full = std::mem::take(&mut self.full_text);
self.frozen_blocks.clear();
self.pending_tail = full;
self.checkpoint = 0;
}
}
fn last_closed_fence(text: &str) -> Option<usize> {
let mut in_block = false;
let mut last_close = None;
let mut search_from = 0;
while let Some(relative) = text[search_from..].find("```") {
let offset = search_from + relative;
let line_start = text[..offset].rfind('\n').map_or(0, |index| index + 1);
if text[line_start..offset].trim().is_empty() {
if in_block {
last_close = Some(offset + 3);
in_block = false;
} else {
in_block = true;
}
}
search_from = offset + 3;
}
last_close
}
fn parse_markdown_blocks(md: &str) -> Vec<ParsedBlock> {
let mut blocks: Vec<ParsedBlock> = Vec::new();
let mut current: Option<ParsedBlock> = None;
let mut list_depth = 0_u16;
for event in Parser::new_ext(md, Options::all()) {
match event {
Event::Start(Tag::Paragraph) => {
if current.is_none() {
current = Some(ParsedBlock {
kind: BlockKind::Normal,
text: String::new(),
});
}
}
Event::End(TagEnd::Paragraph | TagEnd::Heading(_) | TagEnd::CodeBlock) => {
flush_block(&mut current, &mut blocks);
}
Event::Start(Tag::Heading { .. }) => {
flush_block(&mut current, &mut blocks);
current = Some(ParsedBlock {
kind: BlockKind::Heading,
text: String::new(),
});
}
Event::Start(Tag::CodeBlock(_)) => {
flush_block(&mut current, &mut blocks);
current = Some(ParsedBlock {
kind: BlockKind::Code,
text: String::new(),
});
}
Event::Start(Tag::Item) => {
flush_block(&mut current, &mut blocks);
list_depth = list_depth.saturating_add(1);
current = Some(ParsedBlock {
kind: BlockKind::Normal,
text: format!("{}- ", " ".repeat(list_depth as usize - 1)),
});
}
Event::End(TagEnd::Item) => {
flush_block(&mut current, &mut blocks);
list_depth = list_depth.saturating_sub(1);
}
Event::Text(text) | Event::Code(text) | Event::Html(text) | Event::InlineHtml(text) => {
if let Some(block) = current.as_mut() {
block.text.push_str(&text);
} else {
current = Some(ParsedBlock {
kind: BlockKind::Normal,
text: text.into_string(),
});
}
}
Event::SoftBreak | Event::HardBreak => {
if let Some(block) = current.as_mut() {
block.text.push('\n');
}
}
_ => {}
}
}
flush_block(&mut current, &mut blocks);
blocks
}
fn flush_block(current: &mut Option<ParsedBlock>, blocks: &mut Vec<ParsedBlock>) {
if let Some(block) = current.take()
&& (!block.text.is_empty() || blocks.is_empty())
{
blocks.push(block);
}
}
fn block_style(kind: BlockKind, theme: &Theme) -> Style {
match kind {
BlockKind::Normal => theme.styles.normal,
BlockKind::Heading => theme.styles.primary.add_modifier(Modifier::BOLD),
BlockKind::Code => theme.styles.normal.patch(theme.styles.code_bg),
}
}
fn render_blocks(blocks: &[ParsedBlock], width: u16, theme: &Theme) -> Vec<Line<'static>> {
let mut lines = Vec::new();
for (index, block) in blocks.iter().enumerate() {
if index > 0 {
lines.push(Line::default());
}
let style = block_style(block.kind, theme);
lines.extend(
wrap_lines(&block.text, width as usize)
.into_iter()
.map(|line| line.style(style)),
);
}
if lines.is_empty() {
lines.push(Line::default());
}
lines
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::text::Line;
fn text(lines: &[Line<'static>]) -> String {
lines
.iter()
.map(Line::to_string)
.collect::<Vec<_>>()
.join("|")
}
#[test]
fn push_token_accumulates() {
let mut markdown = StreamingMarkdown::new();
markdown.push_token("hello");
markdown.push_token(" world");
assert_eq!(markdown.full_text, "hello world");
assert_eq!(markdown.pending_tail, "hello world");
}
#[test]
fn paragraph_break_advances_checkpoint() {
let mut markdown = StreamingMarkdown::new();
markdown.push_token("one\n\ntwo");
assert_eq!(markdown.checkpoint, 5);
assert_eq!(markdown.pending_tail, "two");
assert!(!markdown.frozen_blocks.is_empty());
}
#[test]
fn closed_code_block_advances_checkpoint() {
let mut markdown = StreamingMarkdown::new();
markdown.push_token("```rust\nlet x = 1;\n```tail");
assert_eq!(markdown.checkpoint, "```rust\nlet x = 1;\n```".len());
assert_eq!(markdown.pending_tail, "tail");
}
#[test]
fn lines_returns_frozen_plus_tail() {
let mut markdown = StreamingMarkdown::new();
markdown.push_token("first\n\nsecond");
let lines = markdown.lines(80, &Theme::default());
assert_eq!(text(&lines), "first||second");
}
#[test]
fn invalidate_clears_frozen() {
let mut markdown = StreamingMarkdown::new();
markdown.push_token("first\n\nsecond");
markdown.invalidate();
assert!(markdown.frozen_blocks.is_empty());
assert_eq!(markdown.checkpoint, 0);
assert_eq!(markdown.pending_tail, "first\n\nsecond");
}
#[test]
fn lines_adapts_to_narrow_width() {
let mut markdown = StreamingMarkdown::new();
markdown.push_token("alpha\n\nbeta gamma delta");
let lines = markdown.lines(5, &Theme::default());
let text = text(&lines);
for line in &lines {
for span in &line.spans {
assert!(
unicode_width::UnicodeWidthStr::width(span.content.as_ref()) <= 5,
"line overflowed 5 columns: {span:?}",
);
}
}
assert!(text.starts_with("alpha|"));
assert!(text.contains("beta"));
}
}