use std::{collections::BTreeMap, ops::Range};
use pulldown_cmark::{CodeBlockKind, Event, MetadataBlockKind, Options, Parser, Tag};
use crate::document::{DocumentError, DocumentResult, Value};
pub fn load(content: &str) -> DocumentResult<Value> {
reject_lone_carriage_return(content)?;
let events: Vec<SpannedEvent<'_>> = Parser::new_ext(content, options())
.into_offset_iter()
.collect();
reject_deep_nesting(&events)?;
let lines = LineIndex::new(content);
let mut cursor = 0;
Ok(fold_sections(read_blocks(&events, &lines, &mut cursor)))
}
type SpannedEvent<'a> = (Event<'a>, Range<usize>);
struct LineIndex<'a> {
content: &'a str,
starts: Vec<usize>,
}
impl<'a> LineIndex<'a> {
fn new(content: &'a str) -> Self {
let mut starts = vec![0];
for (offset, byte) in content.bytes().enumerate() {
if byte == b'\n' {
starts.push(offset + 1);
}
}
Self { content, starts }
}
fn number_at(&self, offset: usize) -> i64 {
let line = self.starts.partition_point(|start| *start <= offset);
i64::try_from(line).unwrap_or(i64::MAX)
}
fn range(&self, source: &Range<usize>) -> SourceLines {
let content_end = self
.content
.get(..source.end)
.map_or(source.end, |head| head.trim_end().len())
.max(source.start);
let end_offset = content_end.saturating_sub(1).max(source.start);
SourceLines {
start: self.number_at(source.start),
end: self.number_at(end_offset),
}
}
}
#[derive(Clone, Copy)]
struct SourceLines {
start: i64,
end: i64,
}
fn reject_lone_carriage_return(content: &str) -> DocumentResult<()> {
let bytes = content.as_bytes();
for (offset, byte) in bytes.iter().enumerate() {
if *byte == b'\r' && bytes.get(offset + 1) != Some(&b'\n') {
return Err(DocumentError::SourceRefused {
format: "Markdown".to_string(),
detail: format!(
"a bare carriage return at byte {offset} ends a line for CommonMark but not \
for the tools these line numbers are meant to feed; convert the file to LF \
or CRLF line endings"
),
});
}
}
Ok(())
}
const MAX_BLOCK_DEPTH: usize = 48;
fn reject_deep_nesting(events: &[SpannedEvent<'_>]) -> DocumentResult<()> {
let mut depth = 0usize;
for (event, _) in events {
match event {
Event::Start(_) => {
depth += 1;
if depth > MAX_BLOCK_DEPTH {
return Err(DocumentError::SourceRefused {
format: "Markdown".to_string(),
detail: format!(
"blocks and inline spans nest deeper than {MAX_BLOCK_DEPTH} levels"
),
});
}
}
Event::End(_) => depth = depth.saturating_sub(1),
_ => {}
}
}
Ok(())
}
fn options() -> Options {
Options::ENABLE_YAML_STYLE_METADATA_BLOCKS | Options::ENABLE_PLUSES_DELIMITED_METADATA_BLOCKS
}
struct Section {
level: i64,
text: String,
inlines: Value,
heading_lines: SourceLines,
blocks: Vec<Value>,
children: Vec<Section>,
}
fn fold_sections(blocks: Vec<Value>) -> Value {
let mut preamble = Vec::new();
let mut roots: Vec<Section> = Vec::new();
let mut open: Vec<Section> = Vec::new();
for block in blocks {
let heading_level = (block.get("type").and_then(Value::as_str) == Some("heading"))
.then(|| block.get("level").and_then(Value::as_integer))
.flatten();
match heading_level {
Some(level) => {
while open.last().is_some_and(|section| section.level >= level) {
close_section(&mut open, &mut roots);
}
open.push(Section {
level,
text: block
.get("text")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
inlines: block
.get("inlines")
.cloned()
.unwrap_or_else(|| Value::Array(Vec::new())),
heading_lines: SourceLines {
start: block
.get("source_start_line")
.and_then(Value::as_integer)
.unwrap_or_default(),
end: block
.get("source_end_line")
.and_then(Value::as_integer)
.unwrap_or_default(),
},
blocks: Vec::new(),
children: Vec::new(),
});
}
None => match open.last_mut() {
Some(section) => section.blocks.push(block),
None => preamble.push(block),
},
}
}
while !open.is_empty() {
close_section(&mut open, &mut roots);
}
let mut root = BTreeMap::from([("preamble".to_string(), Value::Object(block_views(preamble)))]);
insert_sections(&mut root, roots);
Value::Object(root)
}
fn close_section(open: &mut Vec<Section>, roots: &mut Vec<Section>) {
let Some(done) = open.pop() else { return };
match open.last_mut() {
Some(parent) => parent.children.push(done),
None => roots.push(done),
}
}
fn insert_sections(target: &mut BTreeMap<String, Value>, sections: Vec<Section>) {
for section in sections {
let key = format!("h{}", section.level);
let group = target
.entry(key)
.or_insert_with(|| Value::Array(Vec::new()));
if let Some(items) = group.as_array_mut() {
items.push(section.into_value());
}
}
}
fn block_views(blocks: Vec<Value>) -> BTreeMap<String, Value> {
let of_kind = |kind: &str| {
Value::Array(
blocks
.iter()
.filter(|block| block.get("type").and_then(Value::as_str) == Some(kind))
.cloned()
.collect::<Vec<Value>>(),
)
};
let paragraph = of_kind("paragraph");
let blockquote = of_kind("blockquote");
BTreeMap::from([
("paragraph".to_string(), paragraph),
("blockquote".to_string(), blockquote),
("blocks".to_string(), Value::Array(blocks)),
])
}
impl Section {
fn into_value(self) -> Value {
let source_end_line = self.source_end_line();
let mut fields = block_views(self.blocks);
fields.insert("level".to_string(), Value::Integer(self.level));
fields.insert("text".to_string(), Value::String(self.text));
fields.insert("inlines".to_string(), self.inlines);
fields.insert(
"source_start_line".to_string(),
Value::Integer(self.heading_lines.start),
);
fields.insert(
"source_end_line".to_string(),
Value::Integer(source_end_line),
);
fields.insert(
"heading_end_line".to_string(),
Value::Integer(self.heading_lines.end),
);
insert_sections(&mut fields, self.children);
Value::Object(fields)
}
fn source_end_line(&self) -> i64 {
self.blocks
.iter()
.filter_map(|block| block.get("source_end_line"))
.filter_map(Value::as_integer)
.chain(self.children.iter().map(Section::source_end_line))
.max()
.unwrap_or(self.heading_lines.end)
}
}
fn read_blocks(
events: &[SpannedEvent<'_>],
lines: &LineIndex<'_>,
cursor: &mut usize,
) -> Vec<Value> {
let mut blocks = Vec::new();
while let Some((event, source)) = events.get(*cursor) {
match event {
Event::End(_) => break,
Event::Rule => {
let source = source.clone();
*cursor += 1;
blocks.push(block("rule", String::new(), &source, lines, vec![]));
}
Event::Start(tag) if !is_inline_tag(tag) => {
let source = source.clone();
*cursor += 1;
blocks.extend(read_block(tag, &source, events, lines, cursor));
}
_ => {
let first = *cursor;
let (text, inlines) = read_loose_inline(events, cursor);
if !text.is_empty() {
let source = covered_range(events, first, *cursor);
blocks.push(block(
"paragraph",
text,
&source,
lines,
vec![("inlines", inlines)],
));
}
}
}
}
blocks
}
fn is_inline_tag(tag: &Tag<'_>) -> bool {
matches!(
tag,
Tag::Emphasis
| Tag::Strong
| Tag::Strikethrough
| Tag::Superscript
| Tag::Subscript
| Tag::Link { .. }
| Tag::Image { .. }
)
}
fn read_block(
tag: &Tag<'_>,
source: &Range<usize>,
events: &[SpannedEvent<'_>],
lines: &LineIndex<'_>,
cursor: &mut usize,
) -> Option<Value> {
match tag {
Tag::Paragraph => {
let (text, inlines) = read_inline(events, cursor);
Some(block(
"paragraph",
text,
source,
lines,
vec![("inlines", inlines)],
))
}
Tag::Heading { level, .. } => {
let (text, inlines) = read_inline(events, cursor);
Some(block(
"heading",
text,
source,
lines,
vec![
("level", Value::Integer(*level as i64)),
("inlines", inlines),
],
))
}
Tag::CodeBlock(kind) => {
let info = match kind {
CodeBlockKind::Fenced(info) => info.trim().to_string(),
CodeBlockKind::Indented => String::new(),
};
Some(block(
"code",
read_verbatim(events, cursor),
source,
lines,
vec![("language", Value::String(info))],
))
}
Tag::HtmlBlock => Some(block(
"html",
read_verbatim(events, cursor),
source,
lines,
vec![],
)),
Tag::MetadataBlock(kind) => {
skip_subtree(events, cursor);
Some(block(
"frontmatter",
String::new(),
source,
lines,
vec![(
"format",
Value::String(
match kind {
MetadataBlockKind::PlusesStyle => "toml",
MetadataBlockKind::YamlStyle => "yaml",
}
.to_string(),
),
)],
))
}
Tag::BlockQuote(_) => {
let (text, children) = read_container(events, lines, cursor);
Some(block(
"blockquote",
text,
source,
lines,
vec![("blocks", children)],
))
}
Tag::List(first_number) => {
let (text, children) = read_container(events, lines, cursor);
Some(block(
"list",
text,
source,
lines,
vec![
("ordered", Value::Bool(first_number.is_some())),
("blocks", children),
],
))
}
Tag::Item => {
let (text, children) = read_container(events, lines, cursor);
Some(block(
"item",
text,
source,
lines,
vec![("blocks", children)],
))
}
_ => {
skip_subtree(events, cursor);
None
}
}
}
fn read_container(
events: &[SpannedEvent<'_>],
lines: &LineIndex<'_>,
cursor: &mut usize,
) -> (String, Value) {
let children = read_blocks(events, lines, cursor);
*cursor += 1;
let text = children
.iter()
.filter_map(|child| child.get("text").and_then(Value::as_str))
.filter(|text| !text.is_empty())
.collect::<Vec<_>>()
.join("\n");
(text, Value::Array(children))
}
fn read_inline(events: &[SpannedEvent<'_>], cursor: &mut usize) -> (String, Value) {
let read = read_loose_inline(events, cursor);
if matches!(events.get(*cursor), Some((Event::End(_), _))) {
*cursor += 1;
}
read
}
fn read_loose_inline(events: &[SpannedEvent<'_>], cursor: &mut usize) -> (String, Value) {
let mut reader = InlineReader::default();
let mut depth = 0usize;
while let Some((event, _)) = events.get(*cursor) {
match event {
Event::Start(tag) if depth == 0 && !is_inline_tag(tag) => break,
Event::End(_) if depth == 0 => break,
Event::Rule if depth == 0 => break,
Event::Start(tag) => {
depth += 1;
reader.open(tag);
}
Event::End(_) => {
depth -= 1;
reader.close();
}
Event::Text(chunk) => reader.text(chunk),
Event::Code(chunk) => reader.leaf("code", chunk, chunk),
Event::InlineHtml(chunk) | Event::Html(chunk) => reader.leaf("html", chunk, ""),
Event::SoftBreak => reader.text(" "),
Event::HardBreak => reader.leaf("line_break", "", " "),
_ => {}
}
*cursor += 1;
}
reader.finish()
}
struct Inline {
kind: &'static str,
text: String,
destination_url: Option<String>,
children: Option<Vec<Inline>>,
}
impl Inline {
fn into_value(self) -> Value {
let text = if self.children.is_some() || self.destination_url.is_some() {
self.text.trim().to_string()
} else {
self.text
};
let mut fields = BTreeMap::from([
("type".to_string(), Value::String(self.kind.to_string())),
("text".to_string(), Value::String(text)),
]);
if let Some(url) = self.destination_url {
fields.insert("destination_url".to_string(), Value::String(url));
}
if let Some(children) = self.children {
fields.insert("inlines".to_string(), inline_values(children));
}
Value::Object(fields)
}
}
fn inline_values(inlines: Vec<Inline>) -> Value {
Value::Array(inlines.into_iter().map(Inline::into_value).collect())
}
#[derive(Default)]
struct InlineReader {
text: String,
roots: Vec<Inline>,
open: Vec<Inline>,
opened: Vec<bool>,
}
impl InlineReader {
fn siblings(&mut self) -> &mut Vec<Inline> {
match self.open.last_mut() {
Some(span) => span.children.get_or_insert_with(Vec::new),
None => &mut self.roots,
}
}
fn flatten(&mut self, chunk: &str) {
self.text.push_str(chunk);
for span in &mut self.open {
span.text.push_str(chunk);
}
}
fn in_image(&self) -> bool {
self.open.iter().any(|span| span.kind == "image")
}
fn text(&mut self, chunk: &str) {
self.flatten(chunk);
if self.in_image() {
return;
}
let siblings = self.siblings();
match siblings.last_mut() {
Some(last) if last.kind == "text" => last.text.push_str(chunk),
_ => siblings.push(Inline {
kind: "text",
text: chunk.to_string(),
destination_url: None,
children: None,
}),
}
}
fn leaf(&mut self, kind: &'static str, node_text: &str, flat: &str) {
self.flatten(flat);
if self.in_image() {
return;
}
self.siblings().push(Inline {
kind,
text: node_text.to_string(),
destination_url: None,
children: None,
});
}
fn open(&mut self, tag: &Tag<'_>) {
let span = match tag {
Tag::Emphasis => Some(("emphasis", None, true)),
Tag::Strong => Some(("strong", None, true)),
Tag::Link { dest_url, .. } => Some(("link", Some(dest_url.to_string()), true)),
Tag::Image { dest_url, .. } => Some(("image", Some(dest_url.to_string()), false)),
_ => None,
};
let span = span.filter(|_| !self.in_image());
self.opened.push(span.is_some());
if let Some((kind, destination_url, structured)) = span {
self.open.push(Inline {
kind,
text: String::new(),
destination_url,
children: structured.then(Vec::new),
});
}
}
fn close(&mut self) {
if self.opened.pop() == Some(true)
&& let Some(done) = self.open.pop()
{
self.siblings().push(done);
}
}
fn finish(mut self) -> (String, Value) {
while !self.open.is_empty() {
self.opened.push(true);
self.close();
}
(self.text.trim().to_string(), inline_values(self.roots))
}
}
fn read_verbatim(events: &[SpannedEvent<'_>], cursor: &mut usize) -> String {
let mut text = String::new();
while let Some((event, _)) = events.get(*cursor) {
*cursor += 1;
match event {
Event::End(_) => break,
Event::Text(chunk) | Event::Html(chunk) => text.push_str(chunk),
_ => {}
}
}
let unterminated = text.strip_suffix('\n').unwrap_or(text.as_str());
unterminated
.strip_suffix('\r')
.unwrap_or(unterminated)
.to_string()
}
fn skip_subtree(events: &[SpannedEvent<'_>], cursor: &mut usize) {
let mut depth = 0usize;
while let Some((event, _)) = events.get(*cursor) {
*cursor += 1;
match event {
Event::Start(_) => depth += 1,
Event::End(_) => {
if depth == 0 {
break;
}
depth -= 1;
}
_ => {}
}
}
}
fn covered_range(events: &[SpannedEvent<'_>], start: usize, end: usize) -> Range<usize> {
let mut covered = events
.get(start)
.map(|(_, source)| source.clone())
.unwrap_or(0..0);
for (_, source) in events.get(start..end).unwrap_or_default() {
covered.start = covered.start.min(source.start);
covered.end = covered.end.max(source.end);
}
covered
}
fn block(
kind: &str,
text: String,
source: &Range<usize>,
lines: &LineIndex<'_>,
extra: Vec<(&str, Value)>,
) -> Value {
let source = lines.range(source);
let mut fields = BTreeMap::from([
("type".to_string(), Value::String(kind.to_string())),
("text".to_string(), Value::String(text)),
(
"source_start_line".to_string(),
Value::Integer(source.start),
),
("source_end_line".to_string(), Value::Integer(source.end)),
]);
for (name, value) in extra {
fields.insert(name.to_string(), value);
}
Value::Object(fields)
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::panic, clippy::expect_used)]
use super::*;
use crate::document::{Addressing, Format, get_path};
fn at(source: &str, path: &str) -> DocumentResult<Value> {
get_path(
&load(source).unwrap(),
path,
Addressing::INDEX_ONLY.with_array_rule(Format::Markdown.array_rule()),
)
}
fn text(source: &str, path: &str) -> String {
at(source, path)
.unwrap_or_else(|error| panic!("{path}: {error}"))
.as_str()
.unwrap_or_else(|| panic!("{path} is not a string"))
.to_string()
}
fn integer(source: &str, path: &str) -> i64 {
at(source, path)
.unwrap_or_else(|error| panic!("{path}: {error}"))
.as_integer()
.unwrap_or_else(|| panic!("{path} is not an integer"))
}
fn shape(source: &str, path: &str) -> Vec<(String, String)> {
at(source, path)
.unwrap_or_else(|error| panic!("{path}: {error}"))
.as_array()
.unwrap_or_else(|| panic!("{path} is not an array"))
.iter()
.map(|block| {
let field = |name: &str| {
block
.get(name)
.and_then(Value::as_str)
.unwrap_or_default()
.to_string()
};
(field("type"), field("text"))
})
.collect()
}
fn types(source: &str, path: &str) -> Vec<String> {
shape(source, path).into_iter().map(|(k, _)| k).collect()
}
#[test]
fn probe_1_setext_heading_is_a_heading() {
let source = "Title\n=====\n\nThe lead.\n";
assert_eq!(text(source, "h1.0.text"), "Title");
assert_eq!(text(source, "h1.0.paragraph.0.text"), "The lead.");
}
#[test]
fn probe_2_html_comment_before_the_heading_is_its_own_block() {
let source = "<!-- generated -->\n\n# Real\n\nThe lead.\n";
assert_eq!(
shape(source, "preamble.blocks"),
[("html".to_string(), "<!-- generated -->".to_string())]
);
assert_eq!(text(source, "h1.0.text"), "Real");
}
#[test]
fn probe_3_badge_line_before_the_heading_is_a_paragraph() {
let source = "[](b)\n\n# Real\n\nThe lead.\n";
assert_eq!(
shape(source, "preamble.blocks"),
[("paragraph".to_string(), "CI".to_string())]
);
assert_eq!(text(source, "h1.0.text"), "Real");
assert_eq!(text(source, "h1.0.paragraph.0.text"), "The lead.");
}
#[test]
fn probe_4_fenced_code_in_the_lead_position_is_code() {
let source = "# Real\n\n```bash\nafdata get x\n```\n";
assert_eq!(
shape(source, "h1.0.blocks"),
[("code".to_string(), "afdata get x".to_string())]
);
assert_eq!(shape(source, "h1.0.paragraph"), []);
assert_eq!(
at(source, "h1.0.paragraph.0").unwrap_err().code(),
"document_path_not_found"
);
assert_eq!(
at(source, "h1.0.blocks.0.language").unwrap(),
Value::String("bash".to_string())
);
}
#[test]
fn probe_5_leading_fence_swallows_the_heading_inside_it() {
let source = "```\n# Real\n```\n";
assert_eq!(
shape(source, "preamble.blocks"),
[("code".to_string(), "# Real".to_string())]
);
assert_eq!(
at(source, "h1.0").unwrap_err().code(),
"document_path_not_found"
);
}
#[test]
fn probe_6_atx_heading_interrupts_a_paragraph() {
let source = "# Real\n\nThe lead.\n# looks like heading\nmore.\n";
assert_eq!(text(source, "h1.0.text"), "Real");
assert_eq!(
shape(source, "h1.0.paragraph"),
[("paragraph".to_string(), "The lead.".to_string())]
);
assert_eq!(text(source, "h1.1.text"), "looks like heading");
assert_eq!(text(source, "h1.1.paragraph.0.text"), "more.");
}
#[test]
fn probe_7_four_space_indent_is_code_not_a_heading() {
let source = " # Indented\n\nAfter.\n";
assert_eq!(
types(source, "preamble.blocks"),
["code".to_string(), "paragraph".to_string()]
);
assert_eq!(
at(source, "h1.0").unwrap_err().code(),
"document_path_not_found"
);
}
#[test]
fn probe_8_whole_paragraph_emphasis_is_unwrapped() {
assert_eq!(
text("# Real\n\n**A bold tagline.**\n", "h1.0.paragraph.0.text"),
"A bold tagline."
);
assert_eq!(
text(
"# T\n\nA **bold** `span` and a [link](https://example.com).\n",
"h1.0.paragraph.0.text"
),
"A bold span and a link."
);
}
#[test]
fn headings_nest_by_level() {
let source = "# A\n\na.\n\n## B\n\nb.\n\n### C\n\nc.\n\n## D\n\nd.\n\n# E\n";
assert_eq!(text(source, "h1.0.text"), "A");
assert_eq!(text(source, "h1.0.paragraph.0.text"), "a.");
assert_eq!(text(source, "h1.0.h2.0.text"), "B");
assert_eq!(text(source, "h1.0.h2.0.h3.0.text"), "C");
assert_eq!(text(source, "h1.0.h2.0.h3.0.paragraph.0.text"), "c.");
assert_eq!(text(source, "h1.0.h2.1.text"), "D");
assert_eq!(
at(source, "h1.0.h2.1.h3.0").unwrap_err().code(),
"document_path_not_found"
);
assert_eq!(text(source, "h1.1.text"), "E");
}
#[test]
fn a_skipped_level_keeps_its_own_name() {
let source = "# A\n\n### C\n\nc.\n";
assert_eq!(text(source, "h1.0.h3.0.text"), "C");
assert_eq!(
at(source, "h1.0.h2.0").unwrap_err().code(),
"document_path_not_found"
);
}
#[test]
fn a_document_opening_below_h1_has_no_h1() {
let source = "## Only\n\ntext.\n";
assert_eq!(text(source, "h2.0.text"), "Only");
assert_eq!(
at(source, "h1.0").unwrap_err().code(),
"document_path_not_found"
);
}
#[test]
fn preamble_is_always_present_and_empty_for_a_clean_file() {
assert_eq!(shape("# T\n\nlead\n", "preamble.blocks"), []);
assert_eq!(shape("", "preamble.blocks"), []);
}
#[test]
fn a_section_is_addressable_by_a_word_of_its_heading() {
let source = "# T\n\n## A Quick Look\n\ninside look.\n\n## Supported suffixes\n\ns.\n";
assert_eq!(text(source, "h1.0.h2.look.text"), "A Quick Look");
assert_eq!(
text(source, "h1.0.h2.look.paragraph.0.text"),
"inside look."
);
assert_eq!(text(source, "h1.0.h2.SUFFIX.text"), "Supported suffixes");
assert_eq!(
at(source, "h1.0.h2.look.text").unwrap(),
at(source, "h1.0.h2.0.text").unwrap()
);
assert_eq!(
at(source, "h1.0.h2.inside").unwrap_err().code(),
"document_slug_not_found"
);
}
#[test]
fn an_empty_segment_is_not_an_address() {
let one = "# T\n\n## Only One\n\na\n";
let two = "# T\n\n## A\n\na\n\n## B\n\nb\n";
for source in [one, two] {
assert_eq!(
at(source, "h1.0.h2..text").unwrap_err().code(),
"document_slug_not_found"
);
}
assert_eq!(text(two, "h1.0.h2.A.text"), "A");
}
#[test]
fn a_word_matching_several_sections_is_refused() {
let source = "# T\n\n## Quick look\n\na.\n\n## Another look\n\nb.\n";
let error = at(source, "h1.0.h2.look").unwrap_err();
assert_eq!(error.code(), "document_ambiguous_match");
let message = error.to_string();
assert!(message.contains("indices 0, 1"), "{message}");
assert!(!message.contains("Quick look"), "{message}");
assert!(!message.contains("Another look"), "{message}");
assert_eq!(text(source, "h1.0.h2.Another.text"), "Another look");
}
#[test]
fn content_addressing_lowercases_unicode() {
let source = "# T\n\n## Überblick\n\ninside.\n";
assert_eq!(text(source, "h1.0.h2.ÜBER.text"), "Überblick");
}
#[test]
fn the_ask_prompt_blockquote_is_addressable_by_its_opening_words() {
let source = "# T\n\nThe lead.\n\n> **Ask your agent:** \"Do the thing.\"\n";
assert_eq!(
text(source, "h1.0.blocks.Ask your agent.text"),
"Ask your agent: \"Do the thing.\""
);
}
#[test]
fn wrapped_paragraph_joins_onto_one_line() {
assert_eq!(
text("# T\n\nLead line one\nline two.\n", "h1.0.paragraph.0.text"),
"Lead line one line two."
);
}
#[test]
fn heading_level_is_reported_for_every_depth() {
let source = "# a\n\n## b\n\n###### f\n";
assert_eq!(at(source, "h1.0.level").unwrap(), Value::Integer(1));
assert_eq!(at(source, "h1.0.h2.0.level").unwrap(), Value::Integer(2));
assert_eq!(
at(source, "h1.0.h2.0.h6.0.level").unwrap(),
Value::Integer(6)
);
}
#[test]
fn blockquote_flattens_its_paragraphs() {
assert_eq!(
shape(
"> **Ask your agent:** \"Wrapped across\n> two lines.\"\n",
"preamble.blocks"
),
[(
"blockquote".to_string(),
"Ask your agent: \"Wrapped across two lines.\"".to_string()
)]
);
assert_eq!(
shape("> first\n>\n> second\n", "preamble.blocks"),
[("blockquote".to_string(), "first\nsecond".to_string())]
);
}
#[test]
fn list_reports_its_items_and_whether_it_is_ordered() {
let bullet = at("- one\n- two\n", "preamble.blocks.0").unwrap();
assert_eq!(bullet.get("text").and_then(Value::as_str), Some("one\ntwo"));
assert_eq!(bullet.get("ordered"), Some(&Value::Bool(false)));
assert_eq!(
at("1. one\n2. two\n", "preamble.blocks.0")
.unwrap()
.get("ordered"),
Some(&Value::Bool(true))
);
assert_eq!(
at("- one\n\n- two\n", "preamble.blocks.0")
.unwrap()
.get("text")
.and_then(Value::as_str),
Some("one\ntwo")
);
assert_eq!(
at("- one\n - inner\n- two\n", "preamble.blocks.0")
.unwrap()
.get("text")
.and_then(Value::as_str),
Some("one\ninner\ntwo")
);
}
#[test]
fn a_leading_metadata_block_is_its_own_kind() {
let toml = "+++\ntitle = \"T\"\n\n[extra]\ntagline = \"x\"\n+++\n\n# Real\n\nThe lead.\n";
assert_eq!(types(toml, "preamble.blocks"), ["frontmatter".to_string()]);
assert_eq!(
at(toml, "preamble.blocks.0.format").unwrap(),
Value::String("toml".to_string())
);
assert_eq!(text(toml, "preamble.blocks.0.text"), "");
assert_eq!(text(toml, "h1.0.text"), "Real");
assert_eq!(text(toml, "h1.0.paragraph.0.text"), "The lead.");
let yaml = "---\ntitle: T\n---\n\n# Real\n";
assert_eq!(types(yaml, "preamble.blocks"), ["frontmatter".to_string()]);
assert_eq!(
at(yaml, "preamble.blocks.0.format").unwrap(),
Value::String("yaml".to_string())
);
assert_eq!(text(yaml, "h1.0.text"), "Real");
}
#[test]
fn dashes_away_from_the_start_keep_their_commonmark_meaning() {
assert_eq!(text("Setext\n---\n\nbody\n", "h2.0.text"), "Setext");
assert_eq!(
types("# T\n\na\n\n---\n\nb\n", "h1.0.blocks"),
[
"paragraph".to_string(),
"rule".to_string(),
"paragraph".to_string()
]
);
}
#[test]
fn a_source_range_stops_at_the_block_it_names() {
let source = "- a\n - b\n\n\nAfter.\n";
assert_eq!(
at(source, "preamble.blocks.0.type").unwrap(),
Value::String("list".to_string())
);
assert_eq!(
at(source, "preamble.blocks.0.source_start_line").unwrap(),
Value::Integer(1)
);
assert_eq!(
at(source, "preamble.blocks.0.source_end_line").unwrap(),
Value::Integer(2)
);
assert_eq!(
at(source, "preamble.blocks.1.source_start_line").unwrap(),
Value::Integer(5)
);
let mixed = "# H\n\npara\n\n```\ncode\n```\n\n> quote\n\n---\n\ntail\n";
for (address, start, end) in [
("h1.0.blocks.0", 3, 3),
("h1.0.blocks.1", 5, 7),
("h1.0.blocks.2", 9, 9),
("h1.0.blocks.3", 11, 11),
("h1.0.blocks.4", 13, 13),
] {
assert_eq!(
at(mixed, &format!("{address}.source_start_line")).unwrap(),
Value::Integer(start),
"{address} start"
);
assert_eq!(
at(mixed, &format!("{address}.source_end_line")).unwrap(),
Value::Integer(end),
"{address} end"
);
}
}
#[test]
fn gfm_table_rows_are_a_paragraph() {
assert_eq!(
shape("| a | b |\n|---|---|\n| 1 | 2 |\n", "preamble.blocks"),
[(
"paragraph".to_string(),
"| a | b | |---|---| | 1 | 2 |".to_string()
)]
);
assert_eq!(
shape("| a | b |\n|---|---|\n| 1 | 2 |\n", "preamble.paragraph"),
[(
"paragraph".to_string(),
"| a | b | |---|---| | 1 | 2 |".to_string()
)]
);
}
#[test]
fn badge_syntax_remains_in_the_paragraph_view() {
let source = "# T\n\n[](b)\n\nThe lead.\n";
assert_eq!(
shape(source, "h1.0.paragraph"),
[
("paragraph".to_string(), "CI".to_string()),
("paragraph".to_string(), "The lead.".to_string()),
]
);
}
#[test]
fn atx_heading_and_blocks_report_inclusive_source_lines() {
let source = "# Title\n\nLead line one\nline two.\n\n```rs\nfn main() {}\n```\n";
assert_eq!(integer(source, "h1.0.source_start_line"), 1);
assert_eq!(integer(source, "h1.0.source_end_line"), 8);
assert_eq!(integer(source, "h1.0.source_start_line"), 1);
assert_eq!(integer(source, "h1.0.heading_end_line"), 1);
assert_eq!(integer(source, "h1.0.paragraph.0.source_start_line"), 3);
assert_eq!(integer(source, "h1.0.paragraph.0.source_end_line"), 4);
assert_eq!(integer(source, "h1.0.blocks.1.source_start_line"), 6);
assert_eq!(integer(source, "h1.0.blocks.1.source_end_line"), 8);
}
#[test]
fn setext_heading_range_includes_its_underline() {
let source = "My Project\n==========\n\nThe synopsis.\n\n## Install\n";
assert_eq!(integer(source, "h1.0.source_start_line"), 1);
assert_eq!(integer(source, "h1.0.heading_end_line"), 2);
assert_eq!(integer(source, "h1.0.source_end_line"), 6);
assert_eq!(integer(source, "h1.0.h2.0.source_start_line"), 6);
assert_eq!(integer(source, "h1.0.h2.0.source_end_line"), 6);
assert_eq!(integer(source, "h1.0.paragraph.0.source_start_line"), 4);
assert_eq!(integer(source, "h1.0.paragraph.0.source_end_line"), 4);
}
#[test]
fn line_ranges_are_utf8_safe_and_newline_style_independent() {
let source = "# 中文标题\r\n\r\n这是首段,\r\n也是首段。\r\n\r\n## 安装";
assert_eq!(integer(source, "h1.0.source_start_line"), 1);
assert_eq!(integer(source, "h1.0.heading_end_line"), 1);
assert_eq!(integer(source, "h1.0.paragraph.0.source_start_line"), 3);
assert_eq!(integer(source, "h1.0.paragraph.0.source_end_line"), 4);
assert_eq!(integer(source, "h1.0.h2.0.source_start_line"), 6);
assert_eq!(integer(source, "h1.0.h2.0.heading_end_line"), 6);
}
#[test]
fn nesting_beyond_the_block_depth_is_refused_not_a_stack_overflow() {
let nested = |depth: usize| format!("# T\n{}x\n", "> ".repeat(depth));
assert_eq!(text(&nested(47), "h1.0.blockquote.0.text"), "x");
for depth in [48, 100_000] {
let error = load(&nested(depth)).unwrap_err();
assert_eq!(error.code(), "document_source_refused");
assert!(error.to_string().contains("48 levels"), "{error}");
}
let error = load(&format!("{}x\n", "- ".repeat(100_000))).unwrap_err();
assert_eq!(error.code(), "document_source_refused");
}
#[test]
fn a_bare_carriage_return_is_refused_rather_than_numbered() {
let error = load("# T\r\rLead\rcontinued\r\r## End").unwrap_err();
assert_eq!(error.code(), "document_source_refused");
assert!(error.to_string().contains("carriage return"), "{error}");
assert!(
error.redacted_message().contains("CRLF line endings"),
"{}",
error.redacted_message()
);
let crlf = "# T\r\n\r\nLead\r\ncontinued\r\n\r\n## End\r\n";
assert_eq!(integer(crlf, "h1.0.paragraph.0.source_start_line"), 3);
assert_eq!(integer(crlf, "h1.0.paragraph.0.source_end_line"), 4);
assert_eq!(integer(crlf, "h1.0.h2.0.source_start_line"), 6);
let bare = "# T\n\nLead";
assert_eq!(integer(bare, "h1.0.paragraph.0.source_end_line"), 3);
}
#[test]
fn frontmatter_range_includes_both_delimiters() {
let source = "---\ntitle: T\nnested:\n token_secret: hidden\n---\n\n# T\n";
assert_eq!(integer(source, "preamble.blocks.0.source_start_line"), 1);
assert_eq!(integer(source, "preamble.blocks.0.source_end_line"), 5);
assert_eq!(text(source, "preamble.blocks.0.text"), "");
}
#[test]
fn thematic_break_is_a_block_with_no_text() {
assert_eq!(
shape("# T\n\na\n\n---\n\nb\n", "h1.0.blocks"),
[
("paragraph".to_string(), "a".to_string()),
("rule".to_string(), String::new()),
("paragraph".to_string(), "b".to_string()),
]
);
assert_eq!(
shape("# T\n\na\n\n---\n\nb\n", "h1.0.paragraph"),
[
("paragraph".to_string(), "a".to_string()),
("paragraph".to_string(), "b".to_string()),
]
);
}
#[test]
fn a_byte_order_mark_makes_the_first_block_a_paragraph() {
assert_eq!(
types("\u{feff}# Title\n\nlead\n", "preamble.blocks"),
["paragraph".to_string(), "paragraph".to_string()]
);
}
#[test]
fn empty_document_has_no_blocks() {
assert_eq!(shape("", "preamble.blocks"), []);
assert_eq!(shape("\n\n \n", "preamble.blocks"), []);
}
#[test]
fn code_block_keeps_its_lines_and_drops_one_trailing_newline() {
assert_eq!(
shape("```\nline one\nline two\n```\n", "preamble.blocks"),
[("code".to_string(), "line one\nline two".to_string())]
);
}
#[test]
fn inlines_keep_what_text_flattens() {
let source =
"See [the **docs**](docs.md),  and `code` <b>b</b>\\\nend\n";
assert_eq!(
text(source, "preamble.paragraph.0.text"),
"See the docs, alt x and code b end"
);
let kinds = |path: &str| -> Vec<(String, String)> {
at(source, path)
.unwrap()
.as_array()
.unwrap()
.iter()
.map(|node| {
(
node.get("type")
.and_then(Value::as_str)
.unwrap()
.to_string(),
node.get("text")
.and_then(Value::as_str)
.unwrap()
.to_string(),
)
})
.collect()
};
let pairs = |items: &[(&str, &str)]| -> Vec<(String, String)> {
items
.iter()
.map(|(kind, text)| (kind.to_string(), text.to_string()))
.collect()
};
assert_eq!(
kinds("preamble.paragraph.0.inlines"),
pairs(&[
("text", "See "),
("link", "the docs"),
("text", ", "),
("image", "alt x"),
("text", " and "),
("code", "code"),
("text", " "),
("html", "<b>"),
("text", "b"),
("html", "</b>"),
("line_break", ""),
("text", "end"),
])
);
assert_eq!(
kinds("preamble.paragraph.0.inlines.docs.inlines"),
pairs(&[("text", "the "), ("strong", "docs")])
);
assert_eq!(
text(source, "preamble.paragraph.0.inlines.docs.destination_url"),
"docs.md"
);
assert_eq!(
text(source, "preamble.paragraph.0.inlines.3.destination_url"),
"img.png"
);
assert!(
at(source, "preamble.paragraph.0.inlines.3.inlines").is_err(),
"an image reports flattened alt text only"
);
}
#[test]
fn a_heading_and_its_section_report_inlines() {
let source = "# Run `serve` now\n";
assert_eq!(text(source, "h1.0.text"), "Run serve now");
assert_eq!(text(source, "h1.0.inlines.1.type"), "code");
assert_eq!(text(source, "h1.0.inlines.1.text"), "serve");
}
#[test]
fn a_soft_break_joins_adjacent_text() {
assert_eq!(
text("one\ntwo\n", "preamble.paragraph.0.inlines.0.text"),
"one two"
);
assert!(at("one\ntwo\n", "preamble.paragraph.0.inlines.1").is_err());
}
#[test]
fn containers_report_their_child_blocks() {
let source = "- item\n\n ```bash\n nested\n ```\n\n> ```json\n> {}\n> ```\n";
assert_eq!(text(source, "preamble.blocks.0.blocks.0.type"), "item");
assert_eq!(
text(source, "preamble.blocks.0.blocks.0.blocks.1.type"),
"code"
);
assert_eq!(
text(source, "preamble.blocks.0.blocks.0.blocks.1.language"),
"bash"
);
assert_eq!(
text(source, "preamble.blocks.0.blocks.0.blocks.1.text"),
"nested"
);
assert_eq!(
integer(
source,
"preamble.blocks.0.blocks.0.blocks.1.source_start_line"
),
3
);
assert_eq!(text(source, "preamble.blocks.1.blocks.0.type"), "code");
assert_eq!(text(source, "preamble.blocks.1.blocks.0.language"), "json");
assert_eq!(text(source, "preamble.blocks.1.blocks.0.text"), "{}");
assert_eq!(text(source, "preamble.blocks.0.text"), "item\nnested");
}
#[test]
fn the_deepest_accepted_file_reports_a_tree_json_can_read_back() {
fn depth(value: &Value) -> usize {
match value {
Value::Array(items) => 1 + items.iter().map(depth).max().unwrap_or(0),
Value::Object(fields) => 1 + fields.values().map(depth).max().unwrap_or(0),
_ => 0,
}
}
let headings = "# a\n## b\n### c\n#### d\n##### e\n###### f\n";
let deepest = [
format!("{headings}{}x\n", "> ".repeat(MAX_BLOCK_DEPTH - 1)),
format!("{headings}{}x\n", "- ".repeat((MAX_BLOCK_DEPTH - 1) / 2)),
format!("{headings}{}*x*\n", "> ".repeat(MAX_BLOCK_DEPTH - 3)),
];
for source in deepest {
let tree = load(&source).unwrap();
assert!(depth(&tree) <= 112, "{} levels", depth(&tree));
let json = serde_json::to_string(&tree).unwrap();
let enveloped = format!("{{\"kind\":\"result\",\"result\":{{\"value\":{json}}}}}");
serde_json::from_str::<serde_json::Value>(&enveloped).unwrap();
}
}
#[test]
fn inline_nesting_counts_toward_the_depth_limit() {
let nested = |depth: usize| format!("{}x{}\n", "*".repeat(depth), "*".repeat(depth));
assert!(load(&nested(8)).is_ok());
let error = load(&nested(400)).unwrap_err();
assert_eq!(error.code(), "document_source_refused");
}
}