use std::collections::BTreeMap;
use comrak::html::Anchorizer;
use comrak::nodes::{AstNode, NodeValue};
use comrak::{Arena, Options, parse_document};
use serde::Serialize;
#[derive(Debug, Default, Clone, Serialize)]
pub struct Document {
pub sections: Vec<Section>,
pub blocks: Vec<Block>,
pub links: Vec<Link>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Section {
pub level: u8,
pub title: String,
pub anchor: String,
pub line: u32,
pub text: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct Block {
pub lang: String,
pub attrs: BTreeMap<String, String>,
pub content: String,
pub line: u32,
pub section: Option<usize>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Link {
pub target: String,
pub line: u32,
}
struct HeadingDraft {
level: u8,
title: String,
start_line: usize,
end_line: usize,
}
#[must_use]
pub fn parse(body: &str, first_line: u32) -> Document {
let arena = Arena::new();
let mut options = Options::default();
options.extension.table = true;
options.extension.strikethrough = true;
options.extension.tasklist = true;
options.extension.autolink = true;
let root = parse_document(&arena, body, &options);
let line_of = |line: usize| {
first_line
.saturating_add(u32::try_from(line).unwrap_or(u32::MAX))
.saturating_sub(1)
};
let mut headings: Vec<HeadingDraft> = Vec::new();
let mut blocks = Vec::new();
let mut links = Vec::new();
for node in root.descendants() {
let data = node.data.borrow();
let position = data.sourcepos;
match &data.value {
NodeValue::Heading(heading) => headings.push(HeadingDraft {
level: heading.level,
title: inline_text(node),
start_line: position.start.line,
end_line: position.end.line,
}),
NodeValue::CodeBlock(code) if code.fenced => {
let (lang, attrs) = parse_info(&code.info);
blocks.push(Block {
lang,
attrs,
content: code.literal.clone(),
line: line_of(position.start.line),
section: headings.len().checked_sub(1),
});
}
NodeValue::Link(link) => links.push(Link {
target: link.url.clone(),
line: line_of(position.start.line),
}),
_ => {}
}
}
let body_lines: Vec<&str> = body
.split('\n')
.map(|line| line.strip_suffix('\r').unwrap_or(line))
.collect();
let mut anchorizer = Anchorizer::new();
let sections = headings
.iter()
.enumerate()
.map(|(index, draft)| {
let end = headings[index + 1..]
.iter()
.find(|next| next.level <= draft.level)
.map_or(body_lines.len(), |next| next.start_line - 1);
let last = body_lines.len();
let text =
body_lines[draft.end_line.min(last)..end.max(draft.end_line).min(last)].join("\n");
Section {
level: draft.level,
title: draft.title.clone(),
anchor: anchorizer.anchorize(&draft.title),
line: line_of(draft.start_line),
text: text.trim().to_owned(),
}
})
.collect();
Document {
sections,
blocks,
links,
}
}
fn inline_text<'a>(node: &'a AstNode<'a>) -> String {
let mut text = String::new();
for child in node.descendants().skip(1) {
match &child.data.borrow().value {
NodeValue::Text(literal) => text.push_str(literal),
NodeValue::Code(code) => text.push_str(&code.literal),
NodeValue::SoftBreak | NodeValue::LineBreak => text.push(' '),
_ => {}
}
}
text.trim().to_owned()
}
fn parse_info(info: &str) -> (String, BTreeMap<String, String>) {
let mut words = info.split_whitespace();
let lang = words.next().unwrap_or_default().to_owned();
let attrs = words
.map(|word| match word.split_once('=') {
Some((key, value)) => (key.to_owned(), value.trim_matches('"').to_owned()),
None => (word.to_owned(), "true".to_owned()),
})
.collect();
(lang, attrs)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracts_sections_blocks_and_links() {
let body = "# Title\n\nIntro [x](a.md#b).\n\n## Part two\n\n```toml bearout=item k=\"v\"\nid = \"i\"\n```\n\n### Sub\n\ntext\n\n## Other\n";
let doc = parse(body, 5);
assert_eq!(doc.sections.len(), 4);
assert_eq!(doc.sections[0].anchor, "title");
assert_eq!(doc.sections[0].line, 5);
assert!(doc.sections[0].text.contains("Intro"));
assert!(doc.sections[0].text.contains("Sub"));
assert_eq!(doc.sections[1].anchor, "part-two");
assert!(doc.sections[1].text.contains("text"));
assert!(!doc.sections[1].text.contains("Other"));
assert_eq!(doc.blocks.len(), 1);
assert_eq!(doc.blocks[0].lang, "toml");
assert_eq!(doc.blocks[0].attrs["bearout"], "item");
assert_eq!(doc.blocks[0].attrs["k"], "v");
assert_eq!(doc.blocks[0].content, "id = \"i\"\n");
assert_eq!(doc.blocks[0].line, 11);
assert_eq!(doc.blocks[0].section, Some(1));
assert_eq!(doc.links[0].target, "a.md#b");
assert_eq!(doc.links[0].line, 7);
}
#[test]
fn anchors_follow_gfm_rules() {
let doc = parse(
"# Why it is open?\n\n# Why it is open?\n\n# Ĉu vi parolas Esperanton?\n\n# `code` & stuff\n",
1,
);
let anchors: Vec<&str> = doc
.sections
.iter()
.map(|section| section.anchor.as_str())
.collect();
assert_eq!(
anchors,
[
"why-it-is-open",
"why-it-is-open-1",
"ĉu-vi-parolas-esperanton",
"code--stuff"
]
);
}
#[test]
fn crlf_bodies_keep_line_numbers() {
let doc = parse("# A\r\n\r\ntext\r\n\r\n## B\r\n", 3);
assert_eq!(doc.sections[1].line, 7);
assert_eq!(doc.sections[0].text, "text\n\n## B");
assert_eq!(doc.sections[1].text, "");
}
}