use pulldown_cmark::{
Alignment as CmarkAlignment, CodeBlockKind, Event, Options, Parser, Tag, TagEnd,
};
use regex::Regex;
use std::sync::LazyLock;
use turbovault_core::{ContentBlock, InlineElement, ListItem, TableAlignment};
static WIKILINK_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\[\[([^\]|]+)(?:\|([^\]]+))?\]\]").unwrap());
fn preprocess_wikilinks(markdown: &str) -> String {
replace_outside_code(markdown, &WIKILINK_RE, |caps| {
let target = caps.get(1).map(|m| m.as_str().trim()).unwrap_or("");
let alias = caps.get(2).map(|m| m.as_str().trim());
let display_text = alias.unwrap_or(target);
format!("[{}](wikilink:{})", display_text, target)
})
}
fn block_options() -> Options {
Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TASKLISTS
}
fn replace_outside_code(
markdown: &str,
re: &Regex,
replace: impl Fn(®ex::Captures) -> String,
) -> String {
if !re.is_match(markdown) {
return markdown.to_string();
}
let code: Vec<std::ops::Range<usize>> = Parser::new_ext(markdown, block_options())
.into_offset_iter()
.filter_map(|(event, range)| match event {
Event::Start(Tag::CodeBlock(_)) | Event::Code(_) => Some(range),
_ => None,
})
.collect();
let in_code = |offset: usize| {
let after = code.partition_point(|range| range.start <= offset);
after > 0 && offset < code[after - 1].end
};
let mut out = String::with_capacity(markdown.len());
let mut copied = 0;
for caps in re.captures_iter(markdown) {
let Some(whole) = caps.get(0) else {
continue;
};
if in_code(whole.start()) {
continue;
}
out.push_str(&markdown[copied..whole.start()]);
out.push_str(&replace(&caps));
copied = whole.end();
}
out.push_str(&markdown[copied..]);
out
}
static LINK_WITH_SPACES_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\[([^\]]+)\]\(([^)<>]+\s[^)<>]*)\)").unwrap());
fn format_image(alt: &str, src: &str, title: Option<&str>) -> String {
let dest = if src.contains(char::is_whitespace) {
format!("<{src}>")
} else {
src.to_string()
};
match title {
Some(title) if !title.contains('"') => format!(""),
_ => format!(""),
}
}
fn split_link_title(target: &str) -> (&str, Option<&str>) {
let trimmed = target.trim_end();
let quote = match trimmed.chars().last() {
Some(c @ ('"' | '\'')) => c,
_ => return (target, None),
};
let body = &trimmed[..trimmed.len() - quote.len_utf8()];
let Some(open) = body.rfind(quote) else {
return (target, None);
};
if !body[..open].ends_with(char::is_whitespace) {
return (target, None);
}
let url = body[..open].trim_end();
if url.is_empty() {
return (target, None);
}
(url, Some(&trimmed[open..]))
}
fn preprocess_links_with_spaces(markdown: &str) -> String {
replace_outside_code(markdown, &LINK_WITH_SPACES_RE, |caps| {
let text = &caps[1];
let (url, title) = split_link_title(&caps[2]);
if !url.contains(' ') {
return caps[0].to_string();
}
match title {
Some(title) => format!("[{text}](<{url}> {title})"),
None => format!("[{text}](<{url}>)"),
}
})
}
fn extract_details_blocks(markdown: &str) -> (String, Vec<ContentBlock>) {
let mut details_blocks = Vec::new();
let mut result = String::new();
let mut current_pos = 0;
while current_pos < markdown.len() {
if markdown[current_pos..].starts_with("<details")
&& let Some(tag_end) = markdown[current_pos..].find('>')
&& let details_start = current_pos + tag_end + 1
&& let Some(details_end_pos) = markdown[details_start..].find("</details>")
{
let details_end = details_start + details_end_pos;
let details_content = &markdown[details_start..details_end];
let summary = extract_summary(details_content);
let content_start = if let Some(summary_end_pos) = details_content.find("</summary>") {
let summary_tag_end = summary_end_pos + "</summary>".len();
&details_content[summary_tag_end..]
} else {
details_content
};
let content_trimmed = content_start.trim();
let nested_blocks = if !content_trimmed.is_empty() {
parse_blocks(content_trimmed)
} else {
Vec::new()
};
details_blocks.push(ContentBlock::Details {
summary,
content: content_trimmed.to_string(),
blocks: nested_blocks,
});
let consumed_end = details_end + "</details>".len();
let placeholder = format!("\n[DETAILS_BLOCK_{}]\n", details_blocks.len() - 1);
let consumed_lines = markdown[current_pos..consumed_end].matches('\n').count();
let placeholder_lines = placeholder.matches('\n').count();
result.push_str(&placeholder);
for _ in 0..consumed_lines.saturating_sub(placeholder_lines) {
result.push('\n');
}
current_pos = consumed_end;
continue;
}
if let Some(ch) = markdown[current_pos..].chars().next() {
result.push(ch);
current_pos += ch.len_utf8();
} else {
break;
}
}
(result, details_blocks)
}
fn extract_summary(details_content: &str) -> String {
if let Some(summary_start_pos) = details_content.find("<summary")
&& let Some(summary_tag_end) = details_content[summary_start_pos..].find('>')
&& let summary_content_start = summary_start_pos + summary_tag_end + 1
&& let Some(summary_end_pos) = details_content[summary_content_start..].find("</summary>")
{
let summary_end = summary_content_start + summary_end_pos;
return details_content[summary_content_start..summary_end]
.trim()
.to_string();
}
String::new()
}
pub const MAX_LIST_DEPTH: usize = 32;
struct QuotedList {
next_number: Option<u64>,
indent: String,
}
struct ListFrame {
ordered: bool,
items: Vec<ListItem>,
}
struct ItemFrame {
checked: Option<bool>,
blocks: Vec<ContentBlock>,
start_line: usize,
saved_paragraph_buffer: String,
saved_inline_buffer: Vec<InlineElement>,
saved_in_paragraph: bool,
}
struct BlockParserState {
current_line: usize,
current_end_line: usize,
blockquote_start_line: usize,
outer_item_line: Option<usize>,
paragraph_buffer: String,
inline_buffer: Vec<InlineElement>,
list_stack: Vec<ListFrame>,
item_stack: Vec<ItemFrame>,
list_overflow: usize,
code_buffer: String,
code_language: Option<String>,
code_start_line: usize,
image_title: String,
blockquote_buffer: String,
blockquote_depth: usize,
quoted_newlines: usize,
quoted_scanned: usize,
quoted_lists: Vec<QuotedList>,
quoted_list_overflow: usize,
quoted_item_indents: Vec<String>,
table_headers: Vec<String>,
table_alignments: Vec<TableAlignment>,
table_rows: Vec<Vec<String>>,
current_row: Vec<String>,
heading_level: Option<usize>,
heading_buffer: String,
heading_inline: Vec<InlineElement>,
in_paragraph: bool,
in_code: bool,
in_blockquote: bool,
in_table: bool,
in_heading: bool,
in_strong: bool,
in_emphasis: bool,
in_strikethrough: bool,
in_code_inline: bool,
in_link: bool,
link_url: String,
link_text: String,
image_in_link: bool,
in_image: bool,
saved_link_url: String,
}
impl BlockParserState {
fn new(start_line: usize, outer_item_line: Option<usize>) -> Self {
Self {
current_line: start_line,
current_end_line: start_line,
blockquote_start_line: start_line,
outer_item_line,
paragraph_buffer: String::new(),
inline_buffer: Vec::new(),
list_stack: Vec::new(),
item_stack: Vec::new(),
list_overflow: 0,
code_buffer: String::new(),
code_language: None,
code_start_line: 0,
image_title: String::new(),
blockquote_buffer: String::new(),
blockquote_depth: 0,
quoted_newlines: 0,
quoted_scanned: 0,
quoted_lists: Vec::new(),
quoted_list_overflow: 0,
quoted_item_indents: Vec::new(),
table_headers: Vec::new(),
table_alignments: Vec::new(),
table_rows: Vec::new(),
current_row: Vec::new(),
heading_level: None,
heading_buffer: String::new(),
heading_inline: Vec::new(),
in_paragraph: false,
in_code: false,
in_blockquote: false,
in_table: false,
in_heading: false,
in_strong: false,
in_emphasis: false,
in_strikethrough: false,
in_code_inline: false,
in_link: false,
link_url: String::new(),
link_text: String::new(),
image_in_link: false,
in_image: false,
saved_link_url: String::new(),
}
}
fn finalize(&mut self, blocks: &mut Vec<ContentBlock>) {
self.flush_paragraph(blocks);
self.flush_list(blocks);
self.flush_code(blocks);
self.flush_blockquote(blocks);
self.flush_table(blocks);
}
fn flush_paragraph(&mut self, blocks: &mut Vec<ContentBlock>) {
if self.in_paragraph && !self.paragraph_buffer.is_empty() {
blocks.push(ContentBlock::Paragraph {
content: self.paragraph_buffer.clone(),
inline: self.inline_buffer.clone(),
});
self.paragraph_buffer.clear();
self.inline_buffer.clear();
self.in_paragraph = false;
}
}
fn close_list(&mut self, blocks: &mut Vec<ContentBlock>) {
let Some(frame) = self.list_stack.pop() else {
return;
};
if frame.items.is_empty() {
return;
}
let list_block = ContentBlock::List {
ordered: frame.ordered,
items: frame.items,
};
self.push_to_container(blocks, list_block);
}
fn push_to_container(&mut self, blocks: &mut Vec<ContentBlock>, block: ContentBlock) {
match self.item_stack.last_mut() {
Some(item) => item.blocks.push(block),
None => blocks.push(block),
}
}
fn flush_list(&mut self, blocks: &mut Vec<ContentBlock>) {
while !self.list_stack.is_empty() {
self.close_list(blocks);
}
}
fn flush_code(&mut self, blocks: &mut Vec<ContentBlock>) {
if self.in_code {
blocks.push(ContentBlock::Code {
language: self.code_language.clone(),
content: self.code_buffer.trim_end().to_string(),
start_line: self.code_start_line,
end_line: self.current_end_line,
});
self.code_buffer.clear();
self.code_language = None;
self.in_code = false;
}
}
fn take_blockquote(&mut self) -> Option<ContentBlock> {
if !(self.in_blockquote && !self.blockquote_buffer.is_empty()) {
return None;
}
let content = self.blockquote_buffer.trim_end().to_string();
let nested_blocks =
parse_blocks_anchored(&content, self.blockquote_start_line, self.item_anchor());
self.blockquote_buffer.clear();
self.quoted_newlines = 0;
self.quoted_scanned = 0;
self.in_blockquote = false;
Some(ContentBlock::Blockquote {
content,
blocks: nested_blocks,
})
}
fn flush_blockquote(&mut self, blocks: &mut Vec<ContentBlock>) {
if let Some(quote) = self.take_blockquote() {
blocks.push(quote);
}
}
fn next_quoted_line(&mut self) -> usize {
let unscanned = &self.blockquote_buffer.as_bytes()[self.quoted_scanned..];
self.quoted_newlines += unscanned.iter().filter(|&&b| b == b'\n').count();
self.quoted_scanned = self.blockquote_buffer.len();
self.blockquote_start_line + self.quoted_newlines
}
fn sync_quoted_line(&mut self, source_line: usize) {
if !self.blockquote_buffer.is_empty() && !self.blockquote_buffer.ends_with('\n') {
self.blockquote_buffer.push('\n');
}
while self.next_quoted_line() < source_line {
self.blockquote_buffer.push('\n');
}
}
fn quoted_item_indent(&self) -> String {
self.quoted_item_indents.last().cloned().unwrap_or_default()
}
fn open_quoted_block(&mut self, source_line: usize) {
if !self.quoted_lists.is_empty() && source_line <= self.next_quoted_line() {
return;
}
self.sync_quoted_line(source_line);
if !self.quoted_lists.is_empty() {
let indent = self.quoted_item_indent();
self.blockquote_buffer.push_str(&indent);
}
}
fn open_quoted_list(&mut self, start_number: Option<u64>, source_line: usize) {
if self.quoted_list_overflow > 0 || self.quoted_lists.len() >= MAX_LIST_DEPTH {
self.quoted_list_overflow += 1;
return;
}
let indent = self.quoted_item_indents.last().cloned().unwrap_or_default();
if self.quoted_lists.is_empty() {
self.sync_quoted_line(source_line);
} else if !self.blockquote_buffer.ends_with('\n') {
self.blockquote_buffer.push('\n');
}
self.quoted_lists.push(QuotedList {
next_number: start_number,
indent,
});
}
fn close_quoted_list(&mut self) {
if self.quoted_list_overflow > 0 {
self.quoted_list_overflow -= 1;
} else {
self.quoted_lists.pop();
}
}
fn open_quoted_item(&mut self, source_line: usize) {
if self.quoted_list_overflow > 0 {
self.open_quoted_block(source_line);
return;
}
if self.quoted_lists.is_empty() {
return;
}
self.sync_quoted_line(source_line);
let Some(list) = self.quoted_lists.last_mut() else {
return;
};
let indent = list.indent.clone();
let marker = match &mut list.next_number {
Some(number) => {
let marker = format!("{number}. ");
*number += 1;
marker
}
None => "- ".to_string(),
};
self.quoted_item_indents
.push(" ".repeat(indent.len() + marker.len()));
self.blockquote_buffer.push_str(&indent);
self.blockquote_buffer.push_str(&marker);
}
fn close_quoted_item(&mut self) {
if self.quoted_list_overflow > 0 {
return;
}
self.quoted_item_indents.pop();
if !self.blockquote_buffer.ends_with('\n') {
self.blockquote_buffer.push('\n');
}
}
fn write_quoted_table(&mut self) {
if !self.table_headers.is_empty() {
self.open_quoted_block(self.current_line);
let indent = self.quoted_item_indent();
let row = |cells: &[String]| {
let cells: Vec<String> = cells.iter().map(|c| c.replace('|', "\\|")).collect();
format!("| {} |\n", cells.join(" | "))
};
let rule: Vec<String> = self
.table_alignments
.iter()
.map(|alignment| {
match alignment {
TableAlignment::Left => ":---",
TableAlignment::Center => ":---:",
TableAlignment::Right => "---:",
TableAlignment::None => "---",
}
.to_string()
})
.collect();
let mut table = row(&self.table_headers);
table.push_str(&indent);
table.push_str(&format!("| {} |\n", rule.join(" | ")));
for cells in &self.table_rows {
table.push_str(&indent);
table.push_str(&row(cells));
}
self.blockquote_buffer.push_str(&table);
}
self.table_headers.clear();
self.table_alignments.clear();
self.table_rows.clear();
self.current_row.clear();
self.paragraph_buffer.clear();
self.inline_buffer.clear();
self.in_table = false;
}
fn flush_table(&mut self, blocks: &mut Vec<ContentBlock>) {
if self.in_table && !self.table_headers.is_empty() {
blocks.push(ContentBlock::Table {
headers: self.table_headers.clone(),
alignments: self.table_alignments.clone(),
rows: self.table_rows.clone(),
});
self.table_headers.clear();
self.table_alignments.clear();
self.table_rows.clear();
self.current_row.clear();
self.paragraph_buffer.clear();
self.inline_buffer.clear();
self.in_table = false;
}
}
fn item_line_offset(&self) -> Option<usize> {
self.item_anchor()
.map(|anchor| self.current_line.saturating_sub(anchor))
}
fn item_anchor(&self) -> Option<usize> {
self.outer_item_line
.or_else(|| self.item_stack.first().map(|frame| frame.start_line))
}
fn inline_elements(&mut self) -> &mut Vec<InlineElement> {
if self.in_heading {
&mut self.heading_inline
} else {
&mut self.inline_buffer
}
}
fn push_inline(&mut self, element: InlineElement, source: &str, rendered: &str) {
if self.in_heading {
self.heading_inline.push(element);
self.heading_buffer.push_str(rendered);
} else {
self.inline_buffer.push(element);
self.paragraph_buffer.push_str(source);
}
}
fn add_inline_text(&mut self, text: &str) {
if text.is_empty() {
return;
}
let element = if self.in_code_inline {
InlineElement::Code {
value: text.to_string(),
}
} else if self.in_strong {
InlineElement::Strong {
value: text.to_string(),
}
} else if self.in_emphasis {
InlineElement::Emphasis {
value: text.to_string(),
}
} else if self.in_strikethrough {
InlineElement::Strikethrough {
value: text.to_string(),
}
} else {
InlineElement::Text {
value: text.to_string(),
}
};
self.inline_buffer.push(element);
self.paragraph_buffer.push_str(text);
}
}
fn fence_language(info: &str) -> Option<String> {
let word = info
.trim()
.trim_start_matches('{')
.trim_start_matches('.')
.split(|c: char| c.is_whitespace() || c == ',' || c == '}')
.next()
.unwrap_or("");
(!word.is_empty()).then(|| word.to_string())
}
#[allow(clippy::too_many_lines)]
fn process_event(event: Event, state: &mut BlockParserState, blocks: &mut Vec<ContentBlock>) {
match event {
Event::Start(Tag::Paragraph) => {
if state.in_blockquote {
state.open_quoted_block(state.current_line);
}
state.in_paragraph = true;
}
Event::End(TagEnd::Paragraph) => {
if state.in_blockquote {
state.in_paragraph = false;
} else if let Some(item) = state.item_stack.last_mut()
&& state.in_paragraph
&& !state.paragraph_buffer.is_empty()
{
item.blocks.push(ContentBlock::Paragraph {
content: state.paragraph_buffer.clone(),
inline: state.inline_buffer.clone(),
});
state.paragraph_buffer.clear();
state.inline_buffer.clear();
state.in_paragraph = false;
} else {
state.flush_paragraph(blocks);
}
}
Event::Start(Tag::CodeBlock(kind)) => {
state.in_code = true;
state.code_start_line = state.current_line;
state.code_language = match kind {
CodeBlockKind::Fenced(info) => fence_language(&info),
CodeBlockKind::Indented => None,
};
}
Event::End(TagEnd::CodeBlock) => {
if state.in_blockquote && state.in_code {
state.open_quoted_block(state.current_line);
let indent = state.quoted_item_indent();
match &state.code_language {
Some(lang) => state.blockquote_buffer.push_str(&format!("```{lang}\n")),
None => state.blockquote_buffer.push_str("```\n"),
}
let body = std::mem::take(&mut state.code_buffer);
for line in body.trim_end().lines() {
state.blockquote_buffer.push_str(&indent);
state.blockquote_buffer.push_str(line);
state.blockquote_buffer.push('\n');
}
state.blockquote_buffer.push_str(&indent);
state.blockquote_buffer.push_str("```\n");
state.code_language = None;
state.in_code = false;
} else if let Some(item) = state.item_stack.last_mut()
&& state.in_code
{
item.blocks.push(ContentBlock::Code {
language: state.code_language.clone(),
content: state.code_buffer.trim_end().to_string(),
start_line: state.code_start_line,
end_line: state.current_end_line,
});
state.code_buffer.clear();
state.code_language = None;
state.in_code = false;
} else {
state.flush_code(blocks);
}
}
Event::Start(Tag::List(start_number)) if state.in_blockquote => {
state.open_quoted_list(start_number, state.current_line);
}
Event::End(TagEnd::List(_)) if state.in_blockquote => {
state.close_quoted_list();
}
Event::Start(Tag::Item) if state.in_blockquote => {
state.open_quoted_item(state.current_line);
}
Event::End(TagEnd::Item) if state.in_blockquote => {
state.close_quoted_item();
}
Event::TaskListMarker(_) if state.in_blockquote && state.quoted_list_overflow > 0 => {}
Event::TaskListMarker(checked) if state.in_blockquote => {
state
.blockquote_buffer
.push_str(if checked { "[x] " } else { "[ ] " });
}
Event::Start(Tag::List(_))
if state.list_overflow > 0 || state.list_stack.len() >= MAX_LIST_DEPTH =>
{
state.list_overflow += 1;
}
Event::End(TagEnd::List(_)) if state.list_overflow > 0 => {
state.list_overflow -= 1;
}
Event::Start(Tag::Item) if state.list_overflow > 0 => {
if !state.paragraph_buffer.is_empty() {
state.add_inline_text(" ");
}
}
Event::End(TagEnd::Item) | Event::TaskListMarker(_) if state.list_overflow > 0 => {}
Event::Start(Tag::List(start_number)) => {
state.list_stack.push(ListFrame {
ordered: start_number.is_some(),
items: Vec::new(),
});
}
Event::End(TagEnd::List(_)) => {
state.close_list(blocks);
}
Event::Start(Tag::Item) => {
state.item_stack.push(ItemFrame {
checked: None,
blocks: Vec::new(),
start_line: state.current_line,
saved_paragraph_buffer: std::mem::take(&mut state.paragraph_buffer),
saved_inline_buffer: std::mem::take(&mut state.inline_buffer),
saved_in_paragraph: state.in_paragraph,
});
state.in_paragraph = false;
}
Event::End(TagEnd::Item) => {
let Some(frame) = state.item_stack.pop() else {
return;
};
let mut remaining_blocks = frame.blocks;
let (content, mut inline) = if !state.paragraph_buffer.is_empty() {
(
std::mem::take(&mut state.paragraph_buffer),
std::mem::take(&mut state.inline_buffer),
)
} else if matches!(
remaining_blocks.first(),
Some(ContentBlock::Paragraph { .. })
) {
match remaining_blocks.remove(0) {
ContentBlock::Paragraph { content, inline } => (content, inline),
_ => (String::new(), Vec::new()),
}
} else {
(String::new(), Vec::new())
};
collect_inline_elements(&remaining_blocks, &mut inline);
let item = ListItem {
checked: frame.checked,
content,
inline,
blocks: remaining_blocks,
};
if let Some(list) = state.list_stack.last_mut() {
list.items.push(item);
}
state.paragraph_buffer = frame.saved_paragraph_buffer;
state.inline_buffer = frame.saved_inline_buffer;
state.in_paragraph = frame.saved_in_paragraph;
}
Event::TaskListMarker(checked) => {
if let Some(item) = state.item_stack.last_mut() {
item.checked = Some(checked);
}
}
Event::Start(Tag::BlockQuote(_)) => {
if !state.in_blockquote {
state.blockquote_start_line = state.current_line;
}
state.in_blockquote = true;
state.blockquote_depth += 1;
}
Event::End(TagEnd::BlockQuote(_)) if state.blockquote_depth > 1 => {
state.blockquote_depth -= 1;
}
Event::End(TagEnd::BlockQuote(_)) => {
state.blockquote_depth = 0;
if let Some(quote) = state.take_blockquote() {
state.push_to_container(blocks, quote);
}
state.in_blockquote = false;
state.quoted_lists.clear();
state.quoted_list_overflow = 0;
state.quoted_item_indents.clear();
}
Event::Start(Tag::Table(alignments)) => {
state.in_table = true;
state.table_alignments = alignments
.iter()
.map(|a| match a {
CmarkAlignment::Left => TableAlignment::Left,
CmarkAlignment::Center => TableAlignment::Center,
CmarkAlignment::Right => TableAlignment::Right,
CmarkAlignment::None => TableAlignment::None,
})
.collect();
}
Event::End(TagEnd::Table) => {
if state.in_blockquote {
state.write_quoted_table();
} else {
state.flush_table(blocks);
}
}
Event::Start(Tag::TableHead) => {}
Event::End(TagEnd::TableHead) => {
state.table_headers = state.current_row.clone();
state.current_row.clear();
}
Event::Start(Tag::TableRow) => {}
Event::End(TagEnd::TableRow) => {
state.table_rows.push(state.current_row.clone());
state.current_row.clear();
}
Event::Start(Tag::TableCell) => {
state.paragraph_buffer.clear();
state.inline_buffer.clear();
}
Event::End(TagEnd::TableCell) => {
state.current_row.push(state.paragraph_buffer.clone());
state.paragraph_buffer.clear();
state.inline_buffer.clear();
}
Event::Start(Tag::Strong) => {
state.in_strong = true;
}
Event::End(TagEnd::Strong) => {
state.in_strong = false;
}
Event::Start(Tag::Emphasis) => {
state.in_emphasis = true;
}
Event::End(TagEnd::Emphasis) => {
state.in_emphasis = false;
}
Event::Start(Tag::Strikethrough) => {
state.in_strikethrough = true;
}
Event::End(TagEnd::Strikethrough) => {
state.in_strikethrough = false;
}
Event::Code(text) => {
if state.in_heading {
state.heading_buffer.push_str(&text);
state.heading_inline.push(InlineElement::Code {
value: text.to_string(),
});
} else if state.in_blockquote && !state.in_table {
state.blockquote_buffer.push('`');
state.blockquote_buffer.push_str(&text);
state.blockquote_buffer.push('`');
} else if state.in_table {
state.paragraph_buffer.push('`');
state.paragraph_buffer.push_str(&text);
state.paragraph_buffer.push('`');
} else {
state.in_code_inline = true;
state.add_inline_text(&text);
state.in_code_inline = false;
}
}
Event::Start(Tag::Link { dest_url, .. }) => {
state.in_link = true;
state.link_url = dest_url.to_string();
state.link_text.clear();
}
Event::End(TagEnd::Link) => {
state.in_link = false;
if state.in_blockquote && !state.in_table {
let label = if state.image_in_link {
format_image(&state.link_text, &state.link_url, None)
} else {
state.link_text.clone()
};
let url = if state.image_in_link {
state.saved_link_url.clone()
} else {
state.link_url.clone()
};
state
.blockquote_buffer
.push_str(&format!("[{label}]({url})"));
state.link_text.clear();
state.link_url.clear();
state.saved_link_url.clear();
state.image_in_link = false;
return;
}
let line_offset = state.item_line_offset();
let url = if state.image_in_link {
state.saved_link_url.clone()
} else {
state.link_url.clone()
};
let element = InlineElement::Link {
text: state.link_text.clone(),
url: url.clone(),
title: None,
line_offset,
};
let source = format!("[{}]({})", state.link_text, url);
let rendered = if state.image_in_link {
String::new()
} else {
state.link_text.clone()
};
state.push_inline(element, &source, &rendered);
state.link_text.clear();
state.link_url.clear();
state.saved_link_url.clear();
state.image_in_link = false;
}
Event::Start(Tag::Image {
dest_url, title, ..
}) => {
if state.in_link {
state.image_in_link = true;
state.saved_link_url = state.link_url.clone();
}
state.in_image = true;
state.link_url = dest_url.to_string();
state.link_text.clear();
state.image_title = title.to_string();
}
Event::End(TagEnd::Image) => {
state.in_image = false;
let title = if state.image_title.is_empty() {
None
} else {
Some(std::mem::take(&mut state.image_title))
};
let line_offset = state.item_line_offset();
if state.in_blockquote && !state.in_table && !state.image_in_link {
state.blockquote_buffer.push_str(&format_image(
&state.link_text,
&state.link_url,
title.as_deref(),
));
state.link_text.clear();
state.link_url.clear();
return;
}
if state.image_in_link {
let element = InlineElement::Image {
alt: state.link_text.clone(),
src: state.link_url.clone(),
title,
line_offset,
};
state.inline_elements().push(element);
return;
}
if state.in_heading || state.in_paragraph || !state.item_stack.is_empty() {
let element = InlineElement::Image {
alt: state.link_text.clone(),
src: state.link_url.clone(),
title,
line_offset,
};
let source = format!("", state.link_text, state.link_url);
state.push_inline(element, &source, "");
} else {
state.flush_paragraph(blocks);
blocks.push(ContentBlock::Image {
alt: state.link_text.clone(),
src: state.link_url.clone(),
title,
});
state.paragraph_buffer.clear();
}
state.link_text.clear();
state.link_url.clear();
}
Event::Text(text) => {
if state.in_code {
state.code_buffer.push_str(&text);
} else if state.in_blockquote && !state.in_table && (state.in_image || state.in_link) {
state.link_text.push_str(&text);
} else if state.in_blockquote && !state.in_table {
state.blockquote_buffer.push_str(&text);
} else if state.in_heading && (state.in_image || state.in_link) {
state.link_text.push_str(&text);
} else if state.in_heading {
state.heading_buffer.push_str(&text);
let element = if state.in_code_inline {
InlineElement::Code {
value: text.to_string(),
}
} else if state.in_strong {
InlineElement::Strong {
value: text.to_string(),
}
} else if state.in_emphasis {
InlineElement::Emphasis {
value: text.to_string(),
}
} else {
InlineElement::Text {
value: text.to_string(),
}
};
state.heading_inline.push(element);
} else if state.in_link || state.in_image {
state.link_text.push_str(&text);
} else {
state.add_inline_text(&text);
}
}
Event::SoftBreak | Event::HardBreak if state.in_blockquote => {
state.blockquote_buffer.push('\n');
}
Event::SoftBreak if state.in_paragraph => {
state.paragraph_buffer.push(' ');
state.inline_buffer.push(InlineElement::Text {
value: " ".to_string(),
});
}
Event::HardBreak if state.in_paragraph => {
state.paragraph_buffer.push('\n');
state.inline_buffer.push(InlineElement::Text {
value: "\n".to_string(),
});
}
Event::SoftBreak | Event::HardBreak if state.in_heading => {
if state.in_image || state.in_link {
state.link_text.push(' ');
} else {
state.heading_buffer.push(' ');
state.heading_inline.push(InlineElement::Text {
value: " ".to_string(),
});
}
}
Event::SoftBreak if !state.item_stack.is_empty() => {
if state.in_link || state.in_image {
state.link_text.push(' ');
} else {
state.add_inline_text(" ");
}
}
Event::Rule => {
state.flush_paragraph(blocks);
blocks.push(ContentBlock::HorizontalRule);
}
Event::Start(Tag::Heading { level, .. }) => {
state.flush_paragraph(blocks);
state.in_heading = true;
state.heading_level = Some(level as usize);
state.heading_buffer.clear();
state.heading_inline.clear();
}
Event::End(TagEnd::Heading(_)) => {
if state.in_heading
&& !(state.heading_buffer.is_empty() && state.heading_inline.is_empty())
&& let Some(level) = state.heading_level
{
let content = normalize_heading_text(&state.heading_buffer);
let slug = slugify(&content);
blocks.push(ContentBlock::Heading {
level,
content,
inline: state.heading_inline.clone(),
anchor: (!slug.is_empty()).then_some(slug),
});
}
state.in_heading = false;
state.heading_level = None;
state.heading_buffer.clear();
state.heading_inline.clear();
}
_ => {}
}
}
fn normalize_heading_text(text: &str) -> String {
text.split_whitespace().collect::<Vec<_>>().join(" ")
}
pub fn slugify(text: &str) -> String {
text.to_lowercase()
.chars()
.map(|c| {
if c.is_alphanumeric() {
c
} else if c.is_whitespace() || c == '-' {
'-'
} else {
'\0'
}
})
.filter(|&c| c != '\0')
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-")
}
fn collect_inline_elements(blocks: &[ContentBlock], output: &mut Vec<InlineElement>) {
for block in blocks {
match block {
ContentBlock::Paragraph { inline, .. } => {
output.extend(inline.iter().cloned());
}
ContentBlock::List { items, .. } => {
for item in items {
output.extend(item.inline.iter().cloned());
}
}
ContentBlock::Blockquote { blocks, .. } => {
collect_inline_elements(blocks, output);
}
ContentBlock::Details { blocks, .. } => {
collect_inline_elements(blocks, output);
}
_ => {}
}
}
}
pub fn parse_blocks(markdown: &str) -> Vec<ContentBlock> {
parse_blocks_from_line(markdown, 1)
}
fn line_start_offsets(text: &str) -> Vec<usize> {
let mut starts = Vec::with_capacity(text.len() / 32 + 1);
starts.push(0);
starts.extend(text.match_indices('\n').map(|(index, _)| index + 1));
starts
}
fn line_index_of(starts: &[usize], offset: usize) -> usize {
match starts.binary_search(&offset) {
Ok(index) => index,
Err(index) => index.saturating_sub(1),
}
}
pub fn parse_blocks_from_line(markdown: &str, start_line: usize) -> Vec<ContentBlock> {
parse_blocks_anchored(markdown, start_line, None)
}
fn parse_blocks_anchored(
markdown: &str,
start_line: usize,
outer_item_line: Option<usize>,
) -> Vec<ContentBlock> {
let preprocessed = preprocess_wikilinks(markdown);
let preprocessed = preprocess_links_with_spaces(&preprocessed);
let (processed_markdown, details_blocks) = extract_details_blocks(&preprocessed);
let line_starts = line_start_offsets(&processed_markdown);
let parser = Parser::new_ext(&processed_markdown, block_options()).into_offset_iter();
let mut blocks = Vec::new();
let mut state = BlockParserState::new(start_line, outer_item_line);
for (event, span) in parser {
state.current_line = start_line + line_index_of(&line_starts, span.start);
state.current_end_line =
start_line + line_index_of(&line_starts, span.end.saturating_sub(1));
process_event(event, &mut state, &mut blocks);
}
state.finalize(&mut blocks);
let mut final_blocks = Vec::new();
for block in blocks {
let replaced = if let ContentBlock::Paragraph { content, .. } = &block {
let trimmed = content.trim();
trimmed
.strip_prefix("[DETAILS_BLOCK_")
.and_then(|s| s.strip_suffix(']'))
.and_then(|s| s.parse::<usize>().ok())
.and_then(|idx| details_blocks.get(idx).cloned())
} else {
None
};
final_blocks.push(replaced.unwrap_or(block));
}
final_blocks
}
pub fn to_plain_text(markdown: &str) -> String {
let blocks = parse_blocks(markdown);
blocks
.iter()
.map(ContentBlock::to_plain_text)
.collect::<Vec<_>>()
.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_tight_list_item_keeps_the_space_at_its_line_breaks() {
let blocks = parse_blocks("- first line\n continued\n- next\n");
let ContentBlock::List { items, .. } = &blocks[0] else {
panic!("expected a list, got {blocks:?}");
};
assert_eq!(items[0].content, "first line continued");
let text: String = items[0]
.inline
.iter()
.map(|e| match e {
InlineElement::Text { value } => value.as_str(),
_ => "",
})
.collect();
assert_eq!(text, "first line continued");
assert_eq!(items[1].content, "next");
}
#[test]
fn a_setext_heading_spanning_lines_is_one_spaced_heading() {
let blocks = parse_blocks("Line one\nline two\n===\n");
let ContentBlock::Heading { content, .. } = &blocks[0] else {
panic!("expected a heading, got {blocks:?}");
};
assert_eq!(content, "Line one line two");
}
#[test]
fn test_parse_paragraph() {
let markdown = "This is a simple paragraph.";
let blocks = parse_blocks(markdown);
assert_eq!(blocks.len(), 1);
assert!(matches!(blocks[0], ContentBlock::Paragraph { .. }));
if let ContentBlock::Paragraph { content, .. } = &blocks[0] {
assert_eq!(content, "This is a simple paragraph.");
}
}
#[test]
fn test_parse_heading() {
let markdown = "# Hello World";
let blocks = parse_blocks(markdown);
assert_eq!(blocks.len(), 1);
if let ContentBlock::Heading {
level,
content,
anchor,
..
} = &blocks[0]
{
assert_eq!(*level, 1);
assert_eq!(content, "Hello World");
assert_eq!(anchor.as_deref(), Some("hello-world"));
} else {
panic!("Expected Heading block");
}
}
#[test]
fn test_parse_code_block() {
let markdown = "```rust\nfn main() {}\n```";
let blocks = parse_blocks(markdown);
assert_eq!(blocks.len(), 1);
if let ContentBlock::Code {
language, content, ..
} = &blocks[0]
{
assert_eq!(language.as_deref(), Some("rust"));
assert_eq!(content, "fn main() {}");
} else {
panic!("Expected Code block");
}
}
#[test]
fn test_parse_unordered_list() {
let markdown = "- Item 1\n- Item 2\n- Item 3";
let blocks = parse_blocks(markdown);
assert_eq!(blocks.len(), 1);
if let ContentBlock::List { ordered, items } = &blocks[0] {
assert!(!ordered);
assert_eq!(items.len(), 3);
assert_eq!(items[0].content, "Item 1");
assert_eq!(items[1].content, "Item 2");
assert_eq!(items[2].content, "Item 3");
} else {
panic!("Expected List block");
}
}
#[test]
fn test_parse_ordered_list() {
let markdown = "1. First\n2. Second\n3. Third";
let blocks = parse_blocks(markdown);
assert_eq!(blocks.len(), 1);
if let ContentBlock::List { ordered, items } = &blocks[0] {
assert!(ordered);
assert_eq!(items.len(), 3);
} else {
panic!("Expected List block");
}
}
#[test]
fn test_parse_task_list() {
let markdown = "- [ ] Todo\n- [x] Done";
let blocks = parse_blocks(markdown);
assert_eq!(blocks.len(), 1);
if let ContentBlock::List { items, .. } = &blocks[0] {
assert_eq!(items.len(), 2);
assert_eq!(items[0].checked, Some(false));
assert_eq!(items[0].content, "Todo");
assert_eq!(items[1].checked, Some(true));
assert_eq!(items[1].content, "Done");
} else {
panic!("Expected List block");
}
}
#[test]
fn test_parse_table() {
let markdown = "| A | B |\n|---|---|\n| 1 | 2 |";
let blocks = parse_blocks(markdown);
assert_eq!(blocks.len(), 1);
if let ContentBlock::Table { headers, rows, .. } = &blocks[0] {
assert_eq!(headers.len(), 2);
assert_eq!(headers[0], "A");
assert_eq!(headers[1], "B");
assert_eq!(rows.len(), 1);
assert_eq!(rows[0][0], "1");
assert_eq!(rows[0][1], "2");
} else {
panic!("Expected Table block");
}
}
#[test]
fn test_parse_blockquote() {
let markdown = "> This is a quote";
let blocks = parse_blocks(markdown);
assert_eq!(blocks.len(), 1);
if let ContentBlock::Blockquote { content, .. } = &blocks[0] {
assert!(content.contains("This is a quote"));
} else {
panic!("Expected Blockquote block");
}
}
#[test]
fn test_parse_horizontal_rule() {
let markdown = "Before\n\n---\n\nAfter";
let blocks = parse_blocks(markdown);
assert_eq!(blocks.len(), 3);
assert!(matches!(blocks[1], ContentBlock::HorizontalRule));
}
#[test]
fn test_parse_inline_formatting() {
let markdown = "This has **bold** and *italic* and `code`.";
let blocks = parse_blocks(markdown);
assert_eq!(blocks.len(), 1);
if let ContentBlock::Paragraph { inline, .. } = &blocks[0] {
assert!(
inline
.iter()
.any(|e| matches!(e, InlineElement::Strong { .. }))
);
assert!(
inline
.iter()
.any(|e| matches!(e, InlineElement::Emphasis { .. }))
);
assert!(
inline
.iter()
.any(|e| matches!(e, InlineElement::Code { .. }))
);
} else {
panic!("Expected Paragraph block");
}
}
#[test]
fn test_parse_link() {
let markdown = "See [example](https://example.com) for more.";
let blocks = parse_blocks(markdown);
assert_eq!(blocks.len(), 1);
if let ContentBlock::Paragraph { inline, .. } = &blocks[0] {
let link = inline
.iter()
.find(|e| matches!(e, InlineElement::Link { .. }));
assert!(link.is_some());
if let Some(InlineElement::Link { text, url, .. }) = link {
assert_eq!(text, "example");
assert_eq!(url, "https://example.com");
}
} else {
panic!("Expected Paragraph block");
}
}
#[test]
fn test_wikilink_preprocessing() {
let markdown = "See [[Note]] and [[Other|display]] for info.";
let blocks = parse_blocks(markdown);
assert_eq!(blocks.len(), 1);
if let ContentBlock::Paragraph { inline, .. } = &blocks[0] {
let links: Vec<_> = inline
.iter()
.filter(|e| matches!(e, InlineElement::Link { .. }))
.collect();
assert_eq!(links.len(), 2);
if let InlineElement::Link { text, url, .. } = &links[0] {
assert_eq!(text, "Note");
assert_eq!(url, "wikilink:Note");
}
if let InlineElement::Link { text, url, .. } = &links[1] {
assert_eq!(text, "display");
assert_eq!(url, "wikilink:Other");
}
} else {
panic!("Expected Paragraph block");
}
}
#[test]
fn test_wikilinks_in_code_stay_text() {
let markdown = "```\n[[Note|alias]] and [a](my file.md)\n```\n\nRun `[[Code]]` here.\n";
let blocks = parse_blocks(markdown);
let ContentBlock::Code { content, .. } = &blocks[0] else {
panic!("expected a code block: {blocks:?}");
};
assert_eq!(content, "[[Note|alias]] and [a](my file.md)");
let ContentBlock::Paragraph { inline, .. } = &blocks[1] else {
panic!("expected a paragraph: {blocks:?}");
};
assert!(
inline
.iter()
.any(|e| matches!(e, InlineElement::Code { value } if value == "[[Code]]")),
"{inline:?}"
);
assert!(
!inline
.iter()
.any(|e| matches!(e, InlineElement::Link { .. })),
"{inline:?}"
);
}
#[test]
fn test_list_with_nested_code() {
let markdown = r#"1. First item
```rust
code here
```
2. Second item"#;
let blocks = parse_blocks(markdown);
assert_eq!(blocks.len(), 1);
if let ContentBlock::List { items, .. } = &blocks[0] {
assert_eq!(items.len(), 2);
assert!(!items[0].blocks.is_empty());
assert!(matches!(items[0].blocks[0], ContentBlock::Code { .. }));
} else {
panic!("Expected List block");
}
}
#[test]
fn test_parse_image() {
let markdown = "";
let blocks = parse_blocks(markdown);
assert_eq!(blocks.len(), 1);
if let ContentBlock::Paragraph { inline, .. } = &blocks[0] {
let img = inline
.iter()
.find(|e| matches!(e, InlineElement::Image { .. }));
assert!(img.is_some(), "Should have inline image");
} else {
panic!("Expected Paragraph block with inline image");
}
}
#[test]
fn test_parse_block_image() {
let markdown = "Some text\n\n";
let blocks = parse_blocks(markdown);
assert!(blocks.len() >= 2);
}
#[test]
fn test_parse_details_block() {
let markdown = r#"<details>
<summary>Click to expand</summary>
Inner content here.
</details>"#;
let blocks = parse_blocks(markdown);
assert_eq!(blocks.len(), 1);
if let ContentBlock::Details {
summary,
blocks: inner,
..
} = &blocks[0]
{
assert_eq!(summary, "Click to expand");
assert!(!inner.is_empty());
} else {
panic!("Expected Details block");
}
}
#[test]
fn test_slugify() {
assert_eq!(slugify("Hello World"), "hello-world");
assert_eq!(slugify("API Reference"), "api-reference");
assert_eq!(slugify("1. Getting Started"), "1-getting-started");
assert_eq!(slugify("What's New?"), "whats-new");
}
#[test]
fn test_strikethrough() {
let markdown = "This is ~~deleted~~ text.";
let blocks = parse_blocks(markdown);
assert_eq!(blocks.len(), 1);
if let ContentBlock::Paragraph { inline, .. } = &blocks[0] {
assert!(
inline
.iter()
.any(|e| matches!(e, InlineElement::Strikethrough { .. }))
);
}
}
#[test]
fn test_indented_code_blocks_in_list_items() {
let markdown = r#"## Installation
1. Install from crates.io:
```bash
cargo install treemd
```
2. Or build from source:
```bash
git clone https://github.com/example/repo
cd repo
cargo install --path .
```"#;
let blocks = parse_blocks(markdown);
assert_eq!(blocks.len(), 2, "Expected 2 blocks (heading + list)");
assert!(
matches!(blocks[0], ContentBlock::Heading { level: 2, .. }),
"First block should be H2"
);
if let ContentBlock::List { ordered, items } = &blocks[1] {
assert!(ordered, "Should be an ordered list");
assert_eq!(items.len(), 2, "Should have 2 list items");
assert!(
!items[0].blocks.is_empty(),
"First item should have nested blocks"
);
assert!(
matches!(items[0].blocks[0], ContentBlock::Code { .. }),
"First item's nested block should be Code"
);
if let ContentBlock::Code {
language, content, ..
} = &items[0].blocks[0]
{
assert_eq!(language.as_deref(), Some("bash"));
assert!(content.contains("cargo install treemd"));
}
assert!(
!items[1].blocks.is_empty(),
"Second item should have nested blocks"
);
assert!(
matches!(items[1].blocks[0], ContentBlock::Code { .. }),
"Second item's nested block should be Code"
);
if let ContentBlock::Code {
language, content, ..
} = &items[1].blocks[0]
{
assert_eq!(language.as_deref(), Some("bash"));
assert!(content.contains("git clone"));
}
} else {
panic!("Expected List block");
}
}
#[test]
fn test_to_plain_text_simple_paragraph() {
let markdown = "This is a simple paragraph.";
let plain = to_plain_text(markdown);
assert_eq!(plain, "This is a simple paragraph.");
}
#[test]
fn test_to_plain_text_with_link() {
let markdown = "[Overview](#overview) and more text";
let plain = to_plain_text(markdown);
assert_eq!(plain, "Overview and more text");
}
#[test]
fn test_to_plain_text_with_bold_and_italic() {
let markdown = "This has **bold** and *italic* text.";
let plain = to_plain_text(markdown);
assert_eq!(plain, "This has bold and italic text.");
}
#[test]
fn test_to_plain_text_with_inline_code() {
let markdown = "Use the `println!` macro.";
let plain = to_plain_text(markdown);
assert_eq!(plain, "Use the println! macro.");
}
#[test]
fn test_to_plain_text_with_strikethrough() {
let markdown = "This is ~~deleted~~ text.";
let plain = to_plain_text(markdown);
assert_eq!(plain, "This is deleted text.");
}
#[test]
fn test_to_plain_text_wikilinks() {
let markdown = "See [[Note]] and [[Other|display]] for info.";
let plain = to_plain_text(markdown);
assert_eq!(plain, "See Note and display for info.");
}
#[test]
fn test_to_plain_text_heading() {
let markdown = "# Hello World";
let plain = to_plain_text(markdown);
assert_eq!(plain, "Hello World");
}
#[test]
fn test_to_plain_text_code_block() {
let markdown = "```rust\nfn main() {}\n```";
let plain = to_plain_text(markdown);
assert_eq!(plain, "fn main() {}");
}
#[test]
fn test_to_plain_text_list() {
let markdown = "- Item 1\n- Item 2\n- Item 3";
let plain = to_plain_text(markdown);
assert_eq!(plain, "Item 1\nItem 2\nItem 3");
}
#[test]
fn test_to_plain_text_table() {
let markdown = "| A | B |\n|---|---|\n| 1 | 2 |";
let plain = to_plain_text(markdown);
assert!(plain.contains("A\tB"));
assert!(plain.contains("1\t2"));
}
#[test]
fn test_to_plain_text_blockquote() {
let markdown = "> This is a quote";
let plain = to_plain_text(markdown);
assert!(plain.contains("This is a quote"));
}
#[test]
fn test_to_plain_text_image() {
let markdown = "";
let plain = to_plain_text(markdown);
assert_eq!(plain, "Alt text");
}
#[test]
fn test_to_plain_text_horizontal_rule() {
let markdown = "Before\n\n---\n\nAfter";
let plain = to_plain_text(markdown);
assert!(plain.contains("Before"));
assert!(plain.contains("After"));
}
#[test]
fn test_to_plain_text_complex_document() {
let markdown = r#"# Document Title
This is a paragraph with **bold** and *italic* text.
- [Link One](#one)
- [Link Two](#two)
- [Link Three](#three)
See [[WikiNote]] for more info."#;
let plain = to_plain_text(markdown);
assert!(plain.contains("Document Title"));
assert!(plain.contains("bold"));
assert!(plain.contains("italic"));
assert!(plain.contains("Link One"));
assert!(plain.contains("Link Two"));
assert!(plain.contains("WikiNote"));
assert!(!plain.contains("#one"));
assert!(!plain.contains("#two"));
}
#[test]
fn test_to_plain_text_treemd_use_case() {
let markdown = "[Overview](#overview)";
let plain = to_plain_text(markdown);
assert_eq!(plain, "Overview");
let o_count = plain.chars().filter(|c| *c == 'o' || *c == 'O').count();
assert_eq!(
o_count, 1,
"Should only count 'o' in visible text, not hidden anchor"
);
assert!(!plain.contains("#overview"));
assert!(!plain.contains("overview")); }
#[test]
fn test_to_plain_text_nested_formatting() {
let markdown = "**[bold link](url)** and *[italic link](url2)*";
let plain = to_plain_text(markdown);
assert!(plain.contains("bold link"));
assert!(plain.contains("italic link"));
assert!(!plain.contains("url"));
}
#[test]
fn test_nested_list_item_inline_elements() {
let markdown = r#"- [Features](#features)
- [Interactive TUI](#interactive-tui)
- [CLI Mode](#cli-mode)"#;
let blocks = parse_blocks(markdown);
assert_eq!(blocks.len(), 1);
if let ContentBlock::List { items, .. } = &blocks[0] {
assert_eq!(items.len(), 1, "Should have 1 top-level item");
let item = &items[0];
let links: Vec<_> = item
.inline
.iter()
.filter_map(|e| {
if let InlineElement::Link { text, url, .. } = e {
Some((text.as_str(), url.as_str()))
} else {
None
}
})
.collect();
assert_eq!(links.len(), 3, "Should have 3 links total");
assert!(
links.iter().any(|(text, _)| *text == "Features"),
"Should have Features link"
);
assert!(
links.iter().any(|(text, _)| *text == "Interactive TUI"),
"Should have Interactive TUI link"
);
assert!(
links.iter().any(|(text, _)| *text == "CLI Mode"),
"Should have CLI Mode link"
);
} else {
panic!("Expected List block");
}
}
#[test]
fn test_deeply_nested_list_inline_elements() {
let markdown = r#"- Level 1 [link1](url1)
- Level 2 [link2](url2)
- Level 3 [link3](url3)"#;
let blocks = parse_blocks(markdown);
if let ContentBlock::List { items, .. } = &blocks[0] {
let item = &items[0];
let links: Vec<_> = item
.inline
.iter()
.filter(|e| matches!(e, InlineElement::Link { .. }))
.collect();
assert_eq!(links.len(), 3, "Should collect all 3 nested links");
} else {
panic!("Expected List block");
}
}
#[test]
fn test_inline_element_line_offset() {
let markdown = r#"- [Features](#features)
- [Interactive TUI](#interactive-tui)
- [CLI Mode](#cli-mode)"#;
let blocks = parse_blocks(markdown);
if let ContentBlock::List { items, .. } = &blocks[0] {
let item = &items[0];
let links: Vec<_> = item
.inline
.iter()
.filter_map(|e| {
if let InlineElement::Link {
text, line_offset, ..
} = e
{
Some((text.as_str(), *line_offset))
} else {
None
}
})
.collect();
assert_eq!(links.len(), 3);
let features = links.iter().find(|(t, _)| *t == "Features").unwrap();
assert_eq!(features.1, Some(0), "Features should be on line 0");
let tui = links.iter().find(|(t, _)| *t == "Interactive TUI").unwrap();
assert_eq!(tui.1, Some(1), "Interactive TUI should be on line 1");
let cli = links.iter().find(|(t, _)| *t == "CLI Mode").unwrap();
assert_eq!(cli.1, Some(2), "CLI Mode should be on line 2");
} else {
panic!("Expected List block");
}
}
#[test]
fn test_line_offset_not_set_outside_lists() {
let markdown = "See [example](url) for more.";
let blocks = parse_blocks(markdown);
if let ContentBlock::Paragraph { inline, .. } = &blocks[0] {
let link = inline
.iter()
.find(|e| matches!(e, InlineElement::Link { .. }));
if let Some(InlineElement::Link { line_offset, .. }) = link {
assert_eq!(
*line_offset, None,
"line_offset should be None outside lists"
);
}
} else {
panic!("Expected Paragraph block");
}
}
#[test]
fn test_inline_code_in_heading_does_not_leak() {
let markdown = "# Use `foo()` carefully\n\nThis is the body.";
let blocks = parse_blocks(markdown);
assert_eq!(blocks.len(), 2);
let ContentBlock::Heading {
content, inline, ..
} = &blocks[0]
else {
panic!("Expected Heading block, got {:?}", blocks[0]);
};
assert_eq!(content, "Use foo() carefully");
assert!(
inline
.iter()
.any(|e| matches!(e, InlineElement::Code { value } if value == "foo()")),
"heading inline elements should include the Code element"
);
let ContentBlock::Paragraph { content, .. } = &blocks[1] else {
panic!("Expected Paragraph block, got {:?}", blocks[1]);
};
assert_eq!(
content, "This is the body.",
"inline code from heading must not leak into the following paragraph"
);
}
#[test]
fn test_inline_code_in_blockquote_preserved() {
let markdown = "> Run `cargo test` before committing";
let blocks = parse_blocks(markdown);
assert_eq!(blocks.len(), 1);
let ContentBlock::Blockquote { content, .. } = &blocks[0] else {
panic!("Expected Blockquote block, got {:?}", blocks[0]);
};
assert!(
content.contains("`cargo test`"),
"blockquote content should preserve inline code with backticks, got: {content:?}"
);
}
#[test]
fn test_inline_code_in_table_cell_preserved() {
let markdown = "| Command | Effect |\n|---|---|\n| `ls` | list files |";
let blocks = parse_blocks(markdown);
assert_eq!(blocks.len(), 1);
let ContentBlock::Table { rows, .. } = &blocks[0] else {
panic!("Expected Table block, got {:?}", blocks[0]);
};
assert_eq!(rows.len(), 1);
assert!(
rows[0][0].contains("`ls`"),
"table cell should preserve inline code with backticks, got: {:?}",
rows[0][0]
);
assert_eq!(rows[0][1], "list files");
}
#[test]
fn an_empty_fence_closes() {
for markdown in [
"```\n```\n\ntext\n\n```py\nreal\n```\n",
"~~~\n~~~\n\ntext\n\n~~~py\nreal\n~~~\n",
"- item\n\n ```\n ```\n\ntext\n\n```py\nreal\n```\n",
] {
let blocks = parse_blocks(markdown);
let codes: Vec<&str> = blocks
.iter()
.flat_map(|block| match block {
ContentBlock::Code { content, .. } => vec![content.as_str()],
ContentBlock::List { items, .. } => items
.iter()
.flat_map(|item| item.blocks.iter())
.filter_map(|block| match block {
ContentBlock::Code { content, .. } => Some(content.as_str()),
_ => None,
})
.collect(),
_ => vec![],
})
.collect();
assert_eq!(codes, ["", "real"], "{markdown:?}: {blocks:?}");
assert!(
blocks.iter().any(
|b| matches!(b, ContentBlock::Paragraph { content, .. } if content == "text")
),
"{markdown:?}: {blocks:?}"
);
}
}
#[test]
fn a_fence_language_is_the_first_word_of_its_info_string() {
for (info, language) in [
("rust,no_run", Some("rust")),
("python title=\"x\"", Some("python")),
("{.python .numberLines}", Some("python")),
(" js ", Some("js")),
("", None),
] {
let blocks = parse_blocks(&format!("```{info}\nx\n```\n"));
let ContentBlock::Code {
language: found, ..
} = &blocks[0]
else {
panic!("{blocks:?}");
};
assert_eq!(found.as_deref(), language, "{info:?}");
}
}
#[test]
fn a_table_in_a_quote_keeps_its_cells() {
let blocks = parse_blocks("> | q | r |\n> |:--|--:|\n> | 1 | [two](t.md) |\n");
let ContentBlock::Blockquote { blocks: inner, .. } = &blocks[0] else {
panic!("{blocks:?}");
};
let ContentBlock::Table {
headers,
rows,
alignments,
} = &inner[0]
else {
panic!("{inner:?}");
};
assert_eq!(headers, &["q", "r"]);
assert_eq!(rows, &[vec".to_string()]]);
assert_eq!(alignments, &[TableAlignment::Left, TableAlignment::Right]);
}
}