use std::fmt;
use pulldown_cmark::{Alignment, BlockQuoteKind, CodeBlockKind, Event, HeadingLevel, LinkType, MetadataBlockKind, Tag, TagEnd};
#[derive(Clone, Debug, PartialEq)]
pub enum OwnedEvent {
Start(OwnedTag),
End(OwnedTagEnd),
Text(String),
Code(String),
InlineHtml(String),
Html(String),
InlineMath(String),
DisplayMath(String),
FootnoteReference(String),
SoftBreak,
HardBreak,
Rule,
CheckBox(String),
}
impl OwnedEvent {
pub fn from_event(event: Event<'_>) -> Self {
match event {
Event::Start(tag) => OwnedEvent::Start(OwnedTag::from_tag(tag)),
Event::End(tag_end) => OwnedEvent::End(OwnedTagEnd::from_tag_end(tag_end)),
Event::Text(text) => OwnedEvent::Text(text.into_string()),
Event::Code(code) => OwnedEvent::Code(code.into_string()),
Event::InlineHtml(html) => OwnedEvent::InlineHtml(html.into_string()),
Event::Html(html) => OwnedEvent::Html(html.into_string()),
Event::InlineMath(math) => OwnedEvent::InlineMath(math.into_string()),
Event::DisplayMath(math) => OwnedEvent::DisplayMath(math.into_string()),
Event::FootnoteReference(name) => OwnedEvent::FootnoteReference(name.into_string()),
Event::SoftBreak => OwnedEvent::SoftBreak,
Event::HardBreak => OwnedEvent::HardBreak,
Event::Rule => OwnedEvent::Rule,
Event::TaskListMarker(checked) => OwnedEvent::CheckBox(if checked { "x" } else { " " }.into()),
}
}
pub fn to_event(&self) -> Event<'_> {
match self {
OwnedEvent::Start(tag) => Event::Start(tag.to_tag()),
OwnedEvent::End(tag_end) => Event::End(tag_end.to_tag_end()),
OwnedEvent::Text(text) => Event::Text(text.as_str().into()),
OwnedEvent::Code(code) => Event::Code(code.as_str().into()),
OwnedEvent::InlineHtml(html) => Event::InlineHtml(html.as_str().into()),
OwnedEvent::Html(html) => Event::Html(html.as_str().into()),
OwnedEvent::InlineMath(math) => Event::InlineMath(math.as_str().into()),
OwnedEvent::DisplayMath(math) => Event::DisplayMath(math.as_str().into()),
OwnedEvent::FootnoteReference(name) => Event::FootnoteReference(name.as_str().into()),
OwnedEvent::SoftBreak => Event::SoftBreak,
OwnedEvent::HardBreak => Event::HardBreak,
OwnedEvent::Rule => Event::Rule,
OwnedEvent::CheckBox(inner) => match inner.as_str() {
" " => Event::TaskListMarker(false),
"x" => Event::TaskListMarker(true),
_ => panic!("CheckBox(\"{inner}\") cannot be converted to Event directly; use prepare_for_render()"),
},
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum OwnedTag {
Paragraph,
Heading {
level: HeadingLevel,
id: Option<String>,
classes: Vec<String>,
attrs: Vec<(String, Option<String>)>,
},
BlockQuote(Option<BlockQuoteKind>),
CodeBlock(OwnedCodeBlockKind),
HtmlBlock,
List(Option<u64>),
Item,
FootnoteDefinition(String),
DefinitionList,
DefinitionListTitle,
DefinitionListDefinition,
Table(Vec<Alignment>),
TableHead,
TableRow,
TableCell,
Emphasis,
Strong,
Strikethrough,
Link {
link_type: LinkType,
dest_url: String,
title: String,
id: String,
},
Image {
link_type: LinkType,
dest_url: String,
title: String,
id: String,
},
MetadataBlock(MetadataBlockKind),
Superscript,
Subscript,
}
impl OwnedTag {
pub fn from_tag(tag: Tag<'_>) -> Self {
match tag {
Tag::Paragraph => OwnedTag::Paragraph,
Tag::Heading { level, id, classes, attrs } => OwnedTag::Heading {
level,
id: id.map(|s| s.into_string()),
classes: classes.into_iter().map(|s| s.into_string()).collect(),
attrs: attrs.into_iter().map(|(k, v)| (k.into_string(), v.map(|s| s.into_string()))).collect(),
},
Tag::BlockQuote(kind) => OwnedTag::BlockQuote(kind),
Tag::CodeBlock(kind) => OwnedTag::CodeBlock(OwnedCodeBlockKind::from_kind(kind)),
Tag::HtmlBlock => OwnedTag::HtmlBlock,
Tag::List(start) => OwnedTag::List(start),
Tag::Item => OwnedTag::Item,
Tag::FootnoteDefinition(name) => OwnedTag::FootnoteDefinition(name.into_string()),
Tag::DefinitionList => OwnedTag::DefinitionList,
Tag::DefinitionListTitle => OwnedTag::DefinitionListTitle,
Tag::DefinitionListDefinition => OwnedTag::DefinitionListDefinition,
Tag::Table(alignments) => OwnedTag::Table(alignments),
Tag::TableHead => OwnedTag::TableHead,
Tag::TableRow => OwnedTag::TableRow,
Tag::TableCell => OwnedTag::TableCell,
Tag::Emphasis => OwnedTag::Emphasis,
Tag::Strong => OwnedTag::Strong,
Tag::Strikethrough => OwnedTag::Strikethrough,
Tag::Link { link_type, dest_url, title, id } => OwnedTag::Link {
link_type,
dest_url: dest_url.into_string(),
title: title.into_string(),
id: id.into_string(),
},
Tag::Image { link_type, dest_url, title, id } => OwnedTag::Image {
link_type,
dest_url: dest_url.into_string(),
title: title.into_string(),
id: id.into_string(),
},
Tag::MetadataBlock(kind) => OwnedTag::MetadataBlock(kind),
Tag::Superscript => OwnedTag::Superscript,
Tag::Subscript => OwnedTag::Subscript,
}
}
pub fn to_tag(&self) -> Tag<'_> {
match self {
OwnedTag::Paragraph => Tag::Paragraph,
OwnedTag::Heading { level, id, classes, attrs } => Tag::Heading {
level: *level,
id: id.as_deref().map(Into::into),
classes: classes.iter().map(|s| s.as_str().into()).collect(),
attrs: attrs.iter().map(|(k, v)| (k.as_str().into(), v.as_deref().map(Into::into))).collect(),
},
OwnedTag::BlockQuote(kind) => Tag::BlockQuote(*kind),
OwnedTag::CodeBlock(kind) => Tag::CodeBlock(kind.to_kind()),
OwnedTag::HtmlBlock => Tag::HtmlBlock,
OwnedTag::List(start) => Tag::List(*start),
OwnedTag::Item => Tag::Item,
OwnedTag::FootnoteDefinition(name) => Tag::FootnoteDefinition(name.as_str().into()),
OwnedTag::DefinitionList => Tag::DefinitionList,
OwnedTag::DefinitionListTitle => Tag::DefinitionListTitle,
OwnedTag::DefinitionListDefinition => Tag::DefinitionListDefinition,
OwnedTag::Table(alignments) => Tag::Table(alignments.clone()),
OwnedTag::TableHead => Tag::TableHead,
OwnedTag::TableRow => Tag::TableRow,
OwnedTag::TableCell => Tag::TableCell,
OwnedTag::Emphasis => Tag::Emphasis,
OwnedTag::Strong => Tag::Strong,
OwnedTag::Strikethrough => Tag::Strikethrough,
OwnedTag::Link { link_type, dest_url, title, id } => Tag::Link {
link_type: *link_type,
dest_url: dest_url.as_str().into(),
title: title.as_str().into(),
id: id.as_str().into(),
},
OwnedTag::Image { link_type, dest_url, title, id } => Tag::Image {
link_type: *link_type,
dest_url: dest_url.as_str().into(),
title: title.as_str().into(),
id: id.as_str().into(),
},
OwnedTag::MetadataBlock(kind) => Tag::MetadataBlock(*kind),
OwnedTag::Superscript => Tag::Superscript,
OwnedTag::Subscript => Tag::Subscript,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum OwnedTagEnd {
Paragraph,
Heading(HeadingLevel),
BlockQuote(Option<BlockQuoteKind>),
CodeBlock,
HtmlBlock,
List(bool),
Item,
FootnoteDefinition,
DefinitionList,
DefinitionListTitle,
DefinitionListDefinition,
Table,
TableHead,
TableRow,
TableCell,
Emphasis,
Strong,
Strikethrough,
Link,
Image,
MetadataBlock(MetadataBlockKind),
Superscript,
Subscript,
}
impl OwnedTagEnd {
pub fn from_tag_end(tag_end: TagEnd) -> Self {
match tag_end {
TagEnd::Paragraph => OwnedTagEnd::Paragraph,
TagEnd::Heading(level) => OwnedTagEnd::Heading(level),
TagEnd::BlockQuote(kind) => OwnedTagEnd::BlockQuote(kind),
TagEnd::CodeBlock => OwnedTagEnd::CodeBlock,
TagEnd::HtmlBlock => OwnedTagEnd::HtmlBlock,
TagEnd::List(ordered) => OwnedTagEnd::List(ordered),
TagEnd::Item => OwnedTagEnd::Item,
TagEnd::FootnoteDefinition => OwnedTagEnd::FootnoteDefinition,
TagEnd::DefinitionList => OwnedTagEnd::DefinitionList,
TagEnd::DefinitionListTitle => OwnedTagEnd::DefinitionListTitle,
TagEnd::DefinitionListDefinition => OwnedTagEnd::DefinitionListDefinition,
TagEnd::Table => OwnedTagEnd::Table,
TagEnd::TableHead => OwnedTagEnd::TableHead,
TagEnd::TableRow => OwnedTagEnd::TableRow,
TagEnd::TableCell => OwnedTagEnd::TableCell,
TagEnd::Emphasis => OwnedTagEnd::Emphasis,
TagEnd::Strong => OwnedTagEnd::Strong,
TagEnd::Strikethrough => OwnedTagEnd::Strikethrough,
TagEnd::Link => OwnedTagEnd::Link,
TagEnd::Image => OwnedTagEnd::Image,
TagEnd::MetadataBlock(kind) => OwnedTagEnd::MetadataBlock(kind),
TagEnd::Superscript => OwnedTagEnd::Superscript,
TagEnd::Subscript => OwnedTagEnd::Subscript,
}
}
pub fn to_tag_end(&self) -> TagEnd {
match self {
OwnedTagEnd::Paragraph => TagEnd::Paragraph,
OwnedTagEnd::Heading(level) => TagEnd::Heading(*level),
OwnedTagEnd::BlockQuote(kind) => TagEnd::BlockQuote(*kind),
OwnedTagEnd::CodeBlock => TagEnd::CodeBlock,
OwnedTagEnd::HtmlBlock => TagEnd::HtmlBlock,
OwnedTagEnd::List(ordered) => TagEnd::List(*ordered),
OwnedTagEnd::Item => TagEnd::Item,
OwnedTagEnd::FootnoteDefinition => TagEnd::FootnoteDefinition,
OwnedTagEnd::DefinitionList => TagEnd::DefinitionList,
OwnedTagEnd::DefinitionListTitle => TagEnd::DefinitionListTitle,
OwnedTagEnd::DefinitionListDefinition => TagEnd::DefinitionListDefinition,
OwnedTagEnd::Table => TagEnd::Table,
OwnedTagEnd::TableHead => TagEnd::TableHead,
OwnedTagEnd::TableRow => TagEnd::TableRow,
OwnedTagEnd::TableCell => TagEnd::TableCell,
OwnedTagEnd::Emphasis => TagEnd::Emphasis,
OwnedTagEnd::Strong => TagEnd::Strong,
OwnedTagEnd::Strikethrough => TagEnd::Strikethrough,
OwnedTagEnd::Link => TagEnd::Link,
OwnedTagEnd::Image => TagEnd::Image,
OwnedTagEnd::MetadataBlock(kind) => TagEnd::MetadataBlock(*kind),
OwnedTagEnd::Superscript => TagEnd::Superscript,
OwnedTagEnd::Subscript => TagEnd::Subscript,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum OwnedCodeBlockKind {
Indented,
Fenced(String),
}
impl OwnedCodeBlockKind {
pub fn from_kind(kind: CodeBlockKind<'_>) -> Self {
match kind {
CodeBlockKind::Indented => OwnedCodeBlockKind::Indented,
CodeBlockKind::Fenced(info) => OwnedCodeBlockKind::Fenced(info.into_string()),
}
}
pub fn to_kind(&self) -> CodeBlockKind<'_> {
match self {
OwnedCodeBlockKind::Indented => CodeBlockKind::Indented,
OwnedCodeBlockKind::Fenced(info) => CodeBlockKind::Fenced(info.as_str().into()),
}
}
}
#[derive(Clone, Debug, Default, derive_more::Deref, derive_more::IntoIterator, PartialEq)]
pub struct Events(Vec<OwnedEvent>);
impl Events {
pub fn parse(content: &str) -> Self {
use pulldown_cmark::Parser;
let parser = Parser::new_ext(content, parser_options());
let raw: Vec<OwnedEvent> = parser.map(OwnedEvent::from_event).collect();
let mut events = Vec::with_capacity(raw.len());
let mut i = 0;
while i < raw.len() {
if !matches!(&raw[i], OwnedEvent::Start(OwnedTag::Item)) {
events.push(raw[i].clone());
i += 1;
continue;
}
events.push(raw[i].clone());
i += 1;
let had_paragraph = i < raw.len() && matches!(&raw[i], OwnedEvent::Start(OwnedTag::Paragraph));
if had_paragraph {
events.push(raw[i].clone());
i += 1;
}
if i < raw.len() && matches!(&raw[i], OwnedEvent::CheckBox(_)) {
continue;
}
if i + 3 < raw.len()
&& let (OwnedEvent::Text(open), OwnedEvent::Text(inner), OwnedEvent::Text(close), OwnedEvent::Text(rest)) = (&raw[i], &raw[i + 1], &raw[i + 2], &raw[i + 3])
&& open == "["
&& close == "]"
&& (rest.is_empty() || rest.starts_with(' '))
{
events.push(OwnedEvent::CheckBox(inner.clone()));
let rest = rest.strip_prefix(' ').unwrap_or(rest);
if !rest.is_empty() {
events.push(OwnedEvent::Text(rest.to_string()));
}
i += 4;
continue;
}
if i + 2 < raw.len() {
let (a, b, c) = (&raw[i], &raw[i + 1], &raw[i + 2]);
let matched = if let (OwnedEvent::Text(open), OwnedEvent::Text(inner), OwnedEvent::Text(rest)) = (a, b, c)
&& open == "[" && let Some(rest) = rest.strip_prefix(']')
&& (rest.is_empty() || rest.starts_with(' '))
{
Some((inner.clone(), rest.strip_prefix(' ').unwrap_or(rest).to_string()))
} else if let (OwnedEvent::Text(first), OwnedEvent::Text(close), OwnedEvent::Text(rest)) = (a, b, c)
&& let Some(inner) = first.strip_prefix('[')
&& close == "]" && (rest.is_empty() || rest.starts_with(' '))
{
Some((inner.to_string(), rest.strip_prefix(' ').unwrap_or(rest).to_string()))
} else {
None
};
if let Some((inner, rest)) = matched {
events.push(OwnedEvent::CheckBox(inner));
if !rest.is_empty() {
events.push(OwnedEvent::Text(rest));
}
i += 3;
continue;
}
}
if i + 1 < raw.len()
&& let (OwnedEvent::Text(first), OwnedEvent::Text(second)) = (&raw[i], &raw[i + 1])
&& let Some(inner) = first.strip_prefix('[')
&& let Some(rest) = second.strip_prefix(']')
&& (rest.is_empty() || rest.starts_with(' '))
{
events.push(OwnedEvent::CheckBox(inner.to_string()));
let rest = rest.strip_prefix(' ').unwrap_or(rest);
if !rest.is_empty() {
events.push(OwnedEvent::Text(rest.to_string()));
}
i += 2;
continue;
}
}
let events = normalize_list_items_tight(events);
let events = preserve_paragraph_spacing(events);
Self(split_blockers_from_checkboxes(events))
}
}
fn normalize_list_items_tight(events: Vec<OwnedEvent>) -> Vec<OwnedEvent> {
#[derive(Clone, Copy)]
enum State {
Pending,
InFirst,
Done,
}
let mut out = Vec::with_capacity(events.len());
let mut stack: Vec<State> = Vec::new();
let mut i = 0;
while i < events.len() {
match &events[i] {
OwnedEvent::Start(OwnedTag::Item) => {
stack.push(State::Pending);
out.push(events[i].clone());
i += 1;
}
OwnedEvent::End(OwnedTagEnd::Item) => {
stack.pop();
out.push(events[i].clone());
i += 1;
}
OwnedEvent::Start(OwnedTag::Paragraph) if matches!(stack.last(), Some(State::Pending)) => {
*stack.last_mut().unwrap() = State::InFirst;
i += 1; }
OwnedEvent::End(OwnedTagEnd::Paragraph) if matches!(stack.last(), Some(State::InFirst)) => {
*stack.last_mut().unwrap() = State::Done;
if matches!(events.get(i + 1), Some(OwnedEvent::Start(OwnedTag::Paragraph))) {
out.push(OwnedEvent::SoftBreak);
}
i += 1; }
_ => {
out.push(events[i].clone());
i += 1;
}
}
}
out
}
pub(super) fn preserve_paragraph_spacing(events: Vec<OwnedEvent>) -> Vec<OwnedEvent> {
let mut out = Vec::with_capacity(events.len());
let mut item_depth = 0usize;
for i in 0..events.len() {
match &events[i] {
OwnedEvent::Start(OwnedTag::Item) => item_depth += 1,
OwnedEvent::End(OwnedTagEnd::Item) => item_depth -= 1,
_ => {}
}
out.push(events[i].clone());
if item_depth == 0 && matches!(&events[i], OwnedEvent::End(OwnedTagEnd::Paragraph)) && matches!(events.get(i + 1), Some(OwnedEvent::Start(_))) {
out.push(OwnedEvent::Html("\n".to_string()));
}
}
out
}
fn split_blockers_from_checkboxes(events: Vec<OwnedEvent>) -> Vec<OwnedEvent> {
let mut out = Vec::with_capacity(events.len());
let mut i = 0;
while i < events.len() {
if !matches!(&events[i], OwnedEvent::Start(OwnedTag::List(_))) {
out.push(events[i].clone());
i += 1;
continue;
}
let list_start_event = events[i].clone();
let list_end = find_matching_end_list(&events, i);
if !preceded_by_blockers_heading(&out) {
out.push(events[i].clone());
i += 1;
continue;
}
let mut items: Vec<(usize, usize, bool)> = Vec::new();
let mut depth = 0;
let mut j = i;
while j < list_end {
match &events[j] {
OwnedEvent::Start(OwnedTag::List(_)) => depth += 1,
OwnedEvent::End(OwnedTagEnd::List(_)) => depth -= 1,
OwnedEvent::Start(OwnedTag::Item) if depth == 1 => {
let item_start = j;
let item_end = find_matching_end_item(&events, j);
let has_cb = item_has_checkbox(&events[item_start..item_end]);
items.push((item_start, item_end, has_cb));
j = item_end;
continue;
}
_ => {}
}
j += 1;
}
let has_blocker = items.iter().any(|(_, _, has_cb)| !*has_cb);
let has_checkbox = items.iter().any(|(_, _, has_cb)| *has_cb);
if !has_blocker || !has_checkbox {
out.push(events[i].clone());
i += 1;
continue;
}
out.push(list_start_event.clone());
for &(item_start, item_end, has_cb) in &items {
if !has_cb {
out.extend(events[item_start..item_end].iter().cloned());
}
}
out.push(OwnedEvent::End(OwnedTagEnd::List(false)));
out.push(list_start_event.clone());
for &(item_start, item_end, has_cb) in &items {
if has_cb {
out.extend(events[item_start..item_end].iter().cloned());
}
}
out.push(OwnedEvent::End(OwnedTagEnd::List(false)));
i = list_end;
}
out
}
fn preceded_by_blockers_heading(out: &[OwnedEvent]) -> bool {
let Some(last) = out.last() else { return false };
let OwnedEvent::End(OwnedTagEnd::Heading(level)) = last else { return false };
let mut heading_text = String::new();
let mut found_start = false;
for ev in out[..out.len() - 1].iter().rev() {
match ev {
OwnedEvent::Start(OwnedTag::Heading { level: start_level, .. }) if start_level == level => {
found_start = true;
break;
}
OwnedEvent::Text(t) => {
heading_text.insert_str(0, t);
}
_ => break, }
}
if !found_start {
return false;
}
let hashes: String = "#".repeat(match level {
HeadingLevel::H1 => 1,
HeadingLevel::H2 => 2,
HeadingLevel::H3 => 3,
HeadingLevel::H4 => 4,
HeadingLevel::H5 => 5,
HeadingLevel::H6 => 6,
});
let line = format!("{hashes} {heading_text}");
matches!(super::Marker::decode(&line), Some(super::Marker::BlockersSection(_)))
}
fn find_matching_end_list(events: &[OwnedEvent], start: usize) -> usize {
let mut depth = 0;
for (j, ev) in events[start..].iter().enumerate() {
match ev {
OwnedEvent::Start(OwnedTag::List(_)) => depth += 1,
OwnedEvent::End(OwnedTagEnd::List(_)) => {
depth -= 1;
if depth == 0 {
return start + j + 1;
}
}
_ => {}
}
}
events.len()
}
fn find_matching_end_item(events: &[OwnedEvent], start: usize) -> usize {
let mut depth = 0;
for (j, ev) in events[start..].iter().enumerate() {
match ev {
OwnedEvent::Start(OwnedTag::Item) => depth += 1,
OwnedEvent::End(OwnedTagEnd::Item) => {
depth -= 1;
if depth == 0 {
return start + j + 1;
}
}
_ => {}
}
}
events.len()
}
fn item_has_checkbox(item_events: &[OwnedEvent]) -> bool {
let mut i = 0;
if matches!(item_events.get(i), Some(OwnedEvent::Start(OwnedTag::Item))) {
i += 1;
}
matches!(item_events.get(i), Some(OwnedEvent::CheckBox(_)))
}
fn parser_options() -> pulldown_cmark::Options {
pulldown_cmark::Options::ENABLE_TASKLISTS | pulldown_cmark::Options::ENABLE_STRIKETHROUGH
}
pub(crate) fn cmark_options() -> pulldown_cmark_to_cmark::Options<'static> {
pulldown_cmark_to_cmark::Options {
list_token: '-',
newlines_after_headline: 1,
newlines_after_paragraph: 1,
..Default::default()
}
}
pub(crate) fn prepare_for_render(events: &[OwnedEvent]) -> Vec<Event<'_>> {
let mut out = Vec::with_capacity(events.len());
struct ListInfo {
start_idx: usize,
has_checkbox: bool,
}
let mut list_stack: Vec<ListInfo> = Vec::new();
let mut checkbox_lists: Vec<(usize, usize)> = Vec::new();
for (i, ev) in events.iter().enumerate() {
match ev {
OwnedEvent::Start(OwnedTag::List(_)) => {
list_stack.push(ListInfo { start_idx: i, has_checkbox: false });
}
OwnedEvent::CheckBox(_) =>
if let Some(info) = list_stack.last_mut() {
info.has_checkbox = true;
},
OwnedEvent::End(OwnedTagEnd::List(_)) =>
if let Some(info) = list_stack.pop()
&& info.has_checkbox
{
checkbox_lists.push((info.start_idx, i));
},
_ => {}
}
}
let mut loose_item_ends: std::collections::HashSet<usize> = std::collections::HashSet::new();
for (list_start, list_end) in &checkbox_lists {
let mut depth = 0;
let mut item_ends = Vec::new();
for (i, ev) in events[*list_start..=*list_end].iter().enumerate() {
let abs_i = list_start + i;
match ev {
OwnedEvent::Start(OwnedTag::List(_)) => depth += 1,
OwnedEvent::End(OwnedTagEnd::List(_)) => depth -= 1,
OwnedEvent::End(OwnedTagEnd::Item) if depth == 1 => item_ends.push(abs_i),
_ => {}
}
}
if item_ends.len() > 1 {
for &idx in &item_ends[..item_ends.len() - 1] {
loose_item_ends.insert(idx);
}
}
}
for (i, ev) in events.iter().enumerate() {
match ev {
OwnedEvent::CheckBox(inner) => match inner.as_str() {
" " => out.push(Event::TaskListMarker(false)),
"x" => out.push(Event::TaskListMarker(true)),
_ => out.push(Event::Text(format!("[{inner}] ").into())),
},
_ => out.push(ev.to_event()),
}
if loose_item_ends.contains(&i) {
out.push(Event::Html("\n".into()));
}
}
out
}
pub(super) fn indent_into(out: &mut String, content: &str, prefix: &str) {
for line in content.lines() {
if line.is_empty() {
out.push('\n');
} else {
out.push_str(prefix);
out.push_str(line);
out.push('\n');
}
}
}
pub(super) fn wrap_inline_in_paragraphs(events: Vec<OwnedEvent>) -> Vec<OwnedEvent> {
if events.is_empty() {
return events;
}
let mut out = Vec::with_capacity(events.len() + 2);
let mut in_inline = false;
for ev in events {
let is_inline = matches!(
&ev,
OwnedEvent::Text(_)
| OwnedEvent::Code(_)
| OwnedEvent::InlineHtml(_)
| OwnedEvent::InlineMath(_)
| OwnedEvent::SoftBreak
| OwnedEvent::HardBreak
| OwnedEvent::Start(OwnedTag::Emphasis)
| OwnedEvent::End(OwnedTagEnd::Emphasis)
| OwnedEvent::Start(OwnedTag::Strong)
| OwnedEvent::End(OwnedTagEnd::Strong)
| OwnedEvent::Start(OwnedTag::Strikethrough)
| OwnedEvent::End(OwnedTagEnd::Strikethrough)
| OwnedEvent::Start(OwnedTag::Link { .. })
| OwnedEvent::End(OwnedTagEnd::Link)
| OwnedEvent::Start(OwnedTag::Image { .. })
| OwnedEvent::End(OwnedTagEnd::Image)
);
if is_inline && !in_inline {
out.push(OwnedEvent::Start(OwnedTag::Paragraph));
in_inline = true;
} else if !is_inline && in_inline {
out.push(OwnedEvent::End(OwnedTagEnd::Paragraph));
in_inline = false;
}
out.push(ev);
}
if in_inline {
out.push(OwnedEvent::End(OwnedTagEnd::Paragraph));
}
out
}
fn render_events(events: &[OwnedEvent]) -> String {
let prepared = prepare_for_render(events);
let mut output = String::new();
pulldown_cmark_to_cmark::cmark_with_options(prepared.into_iter(), &mut output, cmark_options()).expect("markdown rendering should not fail");
output
}
impl From<Events> for String {
fn from(events: Events) -> Self {
render_events(&events)
}
}
impl fmt::Display for Events {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", render_events(self))
}
}
impl From<Vec<OwnedEvent>> for Events {
fn from(events: Vec<OwnedEvent>) -> Self {
Self(events)
}
}
impl From<String> for Events {
fn from(s: String) -> Self {
Self::parse(&s)
}
}
impl From<&str> for Events {
fn from(s: &str) -> Self {
Self::parse(s)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_simple_text() {
let events = Events::parse("Hello world");
assert!(!events.is_empty());
let rendered: String = events.into();
assert!(rendered.contains("Hello world"));
}
#[test]
fn test_parse_with_formatting() {
let events = Events::parse("Hello **bold** and `code`");
let rendered: String = events.into();
assert!(rendered.contains("Hello"));
assert!(rendered.contains("bold"));
assert!(rendered.contains("code"));
}
#[test]
fn test_roundtrip_simple() {
let original = "Simple paragraph.";
let events = Events::parse(original);
let rendered: String = events.into();
assert!(rendered.contains("Simple paragraph"));
}
#[test]
fn test_empty() {
let events = Events::default();
assert!(events.is_empty());
assert_eq!(events.len(), 0);
}
#[test]
fn blockers_list_split_from_child_issues() {
let events = Events::parse("# Blockers\n- a\n- b\n- [ ] item <!-- https://github.com/o/r/issues/1 -->\n");
let list_count = events.iter().filter(|e| matches!(e, OwnedEvent::Start(OwnedTag::List(_)))).count();
assert_eq!(list_count, 2, "blockers + child issue tight should split into 2 lists");
let events = Events::parse("# Blockers\n- a\n- b\n\n- [ ] item <!-- https://github.com/o/r/issues/1 -->\n");
let list_count = events.iter().filter(|e| matches!(e, OwnedEvent::Start(OwnedTag::List(_)))).count();
assert_eq!(list_count, 2, "blockers + child issue loose should split into 2 lists");
}
#[test]
fn blockers_with_checkbox_no_marker_splits() {
let events = Events::parse("# Blockers\n- a\n- b\n- [ ] just a checkbox item\n");
let list_count = events.iter().filter(|e| matches!(e, OwnedEvent::Start(OwnedTag::List(_)))).count();
assert_eq!(list_count, 2, "checkbox items after blockers heading must split into separate list");
}
#[test]
fn milestone_mixed_list_stays_unified() {
let events = Events::parse("- [ ] one item\n- ref in the same list\n");
let list_count = events.iter().filter(|e| matches!(e, OwnedEvent::Start(OwnedTag::List(_)))).count();
assert_eq!(list_count, 1, "milestone mixed tight should stay as 1 list");
let events = Events::parse("- [ ] one item\n\n- ref in the same list\n");
let list_count = events.iter().filter(|e| matches!(e, OwnedEvent::Start(OwnedTag::List(_)))).count();
assert_eq!(list_count, 1, "milestone mixed loose should stay as 1 list");
}
#[test]
fn non_blockers_mixed_list_stays_unified() {
let events = Events::parse("- a\n- [ ] item\n");
let list_count = events.iter().filter(|e| matches!(e, OwnedEvent::Start(OwnedTag::List(_)))).count();
assert_eq!(list_count, 1, "mixed list without heading should stay as 1 list");
}
#[test]
fn blockers_all_checkbox_stays_unified() {
let events = Events::parse("# Blockers\n- [ ] a\n- [ ] b\n");
let list_count = events.iter().filter(|e| matches!(e, OwnedEvent::Start(OwnedTag::List(_)))).count();
assert_eq!(list_count, 1, "all-checkbox list after blockers heading should stay as 1 list");
}
#[test]
fn loose_list_no_padding_before_body() {
let input = "- [ ] item\n\n body text\n";
let rendered: String = Events::parse(input).into();
insta::assert_snapshot!(rendered, @"
- [ ] item
body text
");
}
#[test]
fn rule_inside_list_item_survives_normalization() {
let loose = "- item\n\n body\n\n ---\n\n after rule\n";
let rendered: String = Events::parse(loose).into();
assert!(rendered.contains("---"), "Rule must survive normalization");
}
#[test]
fn paragraph_spacing_preserved_at_top_level() {
let input = "First paragraph\n\nSecond paragraph\n";
let rendered: String = Events::parse(input).into();
insta::assert_snapshot!(rendered, @r"
First paragraph
Second paragraph
");
}
#[test]
fn paragraph_spacing_not_added_inside_list_items() {
let input = "- [ ] item\n\n body text\n\n more body\n";
let rendered: String = Events::parse(input).into();
insta::assert_snapshot!(rendered, @r"
- [ ] item
body text
more body
");
}
#[test]
fn blockers_starts_with_checkbox_splits() {
let events = Events::parse("# Blockers\n- [ ] a\n- b\n");
let list_count = events.iter().filter(|e| matches!(e, OwnedEvent::Start(OwnedTag::List(_)))).count();
assert_eq!(list_count, 2, "checkbox-first blockers list should split");
}
}