use mq_markdown::Node;
use crate::block::{Block, BlockId, BlockType, DocumentId, Properties, PropertyValue, Span};
fn node_to_span(node: &Node) -> Option<Span> {
node.position().map(|p| Span {
start_line: p.start.line,
start_col: p.start.column,
end_line: p.end.line,
end_col: p.end.column,
})
}
fn heading_depth(node: &Node) -> Option<u8> {
if let Node::Heading(h) = node {
Some(h.depth)
} else {
None
}
}
fn yaml_value_to_property(v: serde_yaml::Value) -> PropertyValue {
match v {
serde_yaml::Value::Null => PropertyValue::Null,
serde_yaml::Value::Bool(b) => PropertyValue::Bool(b),
serde_yaml::Value::Number(n) => {
if let Some(i) = n.as_i64() {
PropertyValue::Int(i)
} else if let Some(f) = n.as_f64() {
PropertyValue::Float(f)
} else {
PropertyValue::String(n.to_string())
}
}
serde_yaml::Value::String(s) => PropertyValue::String(s),
serde_yaml::Value::Sequence(seq) => {
PropertyValue::Array(seq.into_iter().map(yaml_value_to_property).collect())
}
serde_yaml::Value::Mapping(_) => PropertyValue::String(format!("{v:?}")),
serde_yaml::Value::Tagged(t) => yaml_value_to_property(t.value),
}
}
fn node_to_parts(node: &Node) -> Option<(BlockType, String, Properties)> {
let mut props = Properties::new();
match node {
Node::Heading(h) => {
props.set("depth", PropertyValue::Int(h.depth as i64));
let content: String = h.values.iter().map(|n| n.value()).collect();
let slug = content
.to_lowercase()
.chars()
.map(|c| {
if c.is_alphanumeric() || c == ' ' {
c
} else {
'-'
}
})
.collect::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.join("-");
props.set("slug", PropertyValue::String(slug));
Some((BlockType::Heading, content, props))
}
Node::Code(c) => {
if let Some(lang) = &c.lang {
props.set("lang", PropertyValue::String(lang.clone()));
}
if let Some(meta) = &c.meta {
props.set("meta", PropertyValue::String(meta.clone()));
}
props.set("fence", PropertyValue::Bool(c.fence));
Some((BlockType::Code, c.value.clone(), props))
}
Node::List(l) => {
props.set("ordered", PropertyValue::Bool(l.ordered));
props.set("level", PropertyValue::Int(l.level as i64));
if let Some(checked) = l.checked {
props.set("checked", PropertyValue::Bool(checked));
}
let content: String = l.values.iter().map(|n| n.value()).collect();
Some((BlockType::List, content, props))
}
Node::TableRow(r) => {
let content: String = r.values.iter().map(|n| n.value()).collect();
Some((BlockType::TableRow, content, props))
}
Node::TableCell(c) => {
props.set("row", PropertyValue::Int(c.row as i64));
props.set("column", PropertyValue::Int(c.column as i64));
let content: String = c.values.iter().map(|n| n.value()).collect();
Some((BlockType::TableCell, content, props))
}
Node::TableAlign(_) => Some((BlockType::TableAlign, String::new(), props)),
Node::Blockquote(b) => {
let content: String = b.values.iter().map(|n| n.value()).collect();
Some((BlockType::Blockquote, content, props))
}
Node::Html(h) => Some((BlockType::Html, h.value.clone(), props)),
Node::Yaml(y) => {
if let Ok(serde_yaml::Value::Mapping(map)) =
serde_yaml::from_str::<serde_yaml::Value>(&y.value)
{
for (k, v) in map {
if let serde_yaml::Value::String(key) = k {
props.set(key, yaml_value_to_property(v));
}
}
}
Some((BlockType::Yaml, y.value.clone(), props))
}
Node::Toml(t) => Some((BlockType::Toml, t.value.clone(), props)),
Node::Math(m) => Some((BlockType::Math, m.value.clone(), props)),
Node::Definition(d) => {
props.set("url", PropertyValue::String(d.url.as_str().to_string()));
if let Some(label) = &d.label {
props.set("label", PropertyValue::String(label.clone()));
}
Some((BlockType::Definition, d.ident.clone(), props))
}
Node::Footnote(f) => {
let content: String = f.values.iter().map(|n| n.value()).collect();
props.set("ident", PropertyValue::String(f.ident.clone()));
Some((BlockType::Footnote, content, props))
}
Node::HorizontalRule(_) => Some((BlockType::HorizontalRule, String::new(), props)),
Node::Text(_)
| Node::Emphasis(_)
| Node::Strong(_)
| Node::Delete(_)
| Node::Link(_)
| Node::LinkRef(_)
| Node::Image(_)
| Node::ImageRef(_)
| Node::CodeInline(_)
| Node::MathInline(_)
| Node::FootnoteRef(_)
| Node::Break(_)
| Node::MdxFlowExpression(_)
| Node::MdxJsxFlowElement(_)
| Node::MdxJsxTextElement(_)
| Node::MdxTextExpression(_)
| Node::MdxJsEsm(_) => Some((BlockType::Paragraph, node.value(), props)),
Node::Fragment(_) | Node::Empty => None,
#[allow(unreachable_patterns)]
_ => None,
}
}
pub fn build_blocks(doc_id: DocumentId, nodes: &[Node]) -> Vec<Block> {
let n = nodes.len();
if n == 0 {
return Vec::new();
}
let mut children: Vec<Vec<usize>> = Vec::with_capacity(n + 1);
children.push(Vec::new());
let mut node_slot: Vec<usize> = Vec::with_capacity(n);
let mut stack: Vec<(usize, u8)> = vec![(0, 0)];
for node in nodes.iter() {
let slot = children.len();
children.push(Vec::new());
node_slot.push(slot);
if let Some(depth) = heading_depth(node) {
while let Some(&(_, d)) = stack.last() {
if d >= depth {
stack.pop();
} else {
break;
}
}
let parent = stack.last().map_or(0, |&(s, _)| s);
children[parent].push(slot);
stack.push((slot, depth));
} else {
let parent = stack.last().map_or(0, |&(s, _)| s);
children[parent].push(slot);
}
}
let num_slots = children.len();
let mut pre = vec![0u32; num_slots];
let mut post = vec![0u32; num_slots];
let mut counter = 0u32;
let mut dfs: Vec<(usize, usize)> = Vec::with_capacity(num_slots);
pre[0] = counter;
counter += 1;
dfs.push((0, 0));
while let Some(frame) = dfs.last_mut() {
let slot = frame.0;
let child_idx = frame.1;
if child_idx < children[slot].len() {
let child = children[slot][child_idx];
frame.1 += 1; pre[child] = counter;
counter += 1;
dfs.push((child, 0));
} else {
post[slot] = counter;
counter += 1;
dfs.pop();
}
}
let mut blocks: Vec<Block> = Vec::with_capacity(n);
let mut next_id: BlockId = 0;
for (idx, node) in nodes.iter().enumerate() {
let slot = node_slot[idx];
if let Some((block_type, content, properties)) = node_to_parts(node) {
blocks.push(Block {
id: next_id,
document_id: doc_id,
block_type,
content,
span: node_to_span(node),
pre: pre[slot],
post: post[slot],
properties,
});
next_id += 1;
}
}
blocks
}
#[cfg(test)]
mod tests {
use super::*;
use mq_markdown::Markdown;
use rstest::rstest;
fn parse_blocks(md: &str) -> Vec<Block> {
let doc = md.parse::<Markdown>().unwrap();
build_blocks(0, &doc.nodes)
}
#[test]
fn test_heading_depth_property() {
let blocks = parse_blocks("## Section\n\nParagraph\n");
let h = blocks
.iter()
.find(|b| b.block_type == BlockType::Heading)
.unwrap();
assert_eq!(h.heading_depth(), Some(2));
assert_eq!(h.content, "Section");
}
#[test]
fn test_code_lang_property() {
let blocks = parse_blocks("```rust\nfn main() {}\n```\n");
let c = blocks
.iter()
.find(|b| b.block_type == BlockType::Code)
.unwrap();
assert_eq!(c.code_lang(), Some("rust"));
}
#[test]
fn test_interval_index_ancestor_check() {
let md = "# H1\n\npara1\n\n## H2\n\npara2\n\n# H1b\n\npara3\n";
let blocks = parse_blocks(md);
let h1 = blocks
.iter()
.find(|b| {
b.block_type == BlockType::Heading
&& b.heading_depth() == Some(1)
&& b.content == "H1"
})
.unwrap();
let h2 = blocks
.iter()
.find(|b| b.block_type == BlockType::Heading && b.heading_depth() == Some(2))
.unwrap();
let para2 = blocks
.iter()
.find(|b| b.block_type == BlockType::Paragraph && b.content == "para2")
.unwrap();
let para3 = blocks
.iter()
.find(|b| b.block_type == BlockType::Paragraph && b.content == "para3")
.unwrap();
assert!(para2.is_under(h1), "para2 should be under H1");
assert!(para2.is_under(h2), "para2 should be under H2");
assert!(!para3.is_under(h1), "para3 should not be under first H1");
assert!(h2.is_under(h1), "H2 should be under H1");
}
#[test]
fn test_sibling_via_post_plus_one() {
let md = "## A\n\n## B\n\n## C\n";
let blocks = parse_blocks(md);
let a = blocks.iter().find(|b| b.content == "A").unwrap();
let b_block = blocks.iter().find(|b| b.content == "B").unwrap();
assert_eq!(
b_block.pre,
a.post + 1,
"B.pre should be A.post + 1 (next sibling)"
);
}
#[test]
fn test_first_child_via_pre_plus_one() {
let md = "## Section\n\nContent paragraph\n";
let blocks = parse_blocks(md);
let heading = blocks
.iter()
.find(|b| b.block_type == BlockType::Heading)
.unwrap();
let para = blocks
.iter()
.find(|b| b.block_type == BlockType::Paragraph)
.unwrap();
assert_eq!(
para.pre,
heading.pre + 1,
"first child pre == heading.pre + 1"
);
}
#[test]
fn test_yaml_frontmatter_properties() {
let md = "---\ntitle: My Doc\ntags:\n - rust\n - db\n---\n\n# Hello\n";
let blocks = parse_blocks(md);
let yaml = blocks
.iter()
.find(|b| b.block_type == BlockType::Yaml)
.unwrap();
assert_eq!(
yaml.properties.get("title").and_then(|v| v.as_str()),
Some("My Doc")
);
let tags = yaml
.properties
.get("tags")
.and_then(|v| v.as_array())
.unwrap();
assert_eq!(tags.len(), 2);
assert_eq!(tags[0].as_str(), Some("rust"));
}
#[rstest]
#[case(1, "# Heading\n\nparagraph\n", "Heading")]
#[case(2, "## Heading\n\nparagraph\n", "Heading")]
#[case(3, "### Heading\n\nparagraph\n", "Heading")]
#[case(4, "#### Heading\n\nparagraph\n", "Heading")]
#[case(5, "##### Heading\n\nparagraph\n", "Heading")]
#[case(6, "###### Heading\n\nparagraph\n", "Heading")]
fn test_heading_depth_param(#[case] depth: u8, #[case] md: &str, #[case] content: &str) {
let blocks = parse_blocks(md);
let h = blocks
.iter()
.find(|b| b.block_type == BlockType::Heading)
.unwrap();
assert_eq!(h.heading_depth(), Some(depth));
assert_eq!(&h.content, content);
}
#[rstest]
#[case("rust")]
#[case("python")]
#[case("go")]
#[case("javascript")]
#[case("typescript")]
#[case("bash")]
fn test_code_language_param(#[case] lang: &str) {
let md = format!("```{lang}\ncode body\n```\n");
let blocks = parse_blocks(&md);
let code = blocks
.iter()
.find(|b| b.block_type == BlockType::Code)
.unwrap();
assert_eq!(code.code_lang(), Some(lang));
}
#[rstest]
#[case("title", "My Title")]
#[case("author", "Alice")]
#[case("version", "1.0.0")]
#[case("description", "A test document")]
fn test_frontmatter_string_key_param(#[case] key: &str, #[case] value: &str) {
let md = format!("---\n{key}: {value}\n---\n\n# Doc\n");
let blocks = parse_blocks(&md);
let yaml = blocks
.iter()
.find(|b| b.block_type == BlockType::Yaml)
.unwrap();
assert_eq!(
yaml.properties.get(key).and_then(|v| v.as_str()),
Some(value),
);
}
}