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();
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(())
}
fn options() -> Options {
Options::ENABLE_YAML_STYLE_METADATA_BLOCKS | Options::ENABLE_PLUSES_DELIMITED_METADATA_BLOCKS
}
struct Section {
level: i64,
text: String,
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(),
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(
"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 = read_loose_inline(events, cursor);
if !text.is_empty() {
let source = covered_range(events, first, *cursor);
blocks.push(block("paragraph", text, &source, lines, vec![]));
}
}
}
}
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 => Some(block(
"paragraph",
read_inline(events, cursor),
source,
lines,
vec![],
)),
Tag::Heading { level, .. } => Some(block(
"heading",
read_inline(events, cursor),
source,
lines,
vec![("level", Value::Integer(*level as i64))],
)),
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(_) => Some(block(
"blockquote",
read_container(events, lines, cursor),
source,
lines,
vec![],
)),
Tag::List(first_number) => Some(block(
"list",
read_container(events, lines, cursor),
source,
lines,
vec![("ordered", Value::Bool(first_number.is_some()))],
)),
Tag::Item => Some(block(
"item",
read_container(events, lines, cursor),
source,
lines,
vec![],
)),
_ => {
skip_subtree(events, cursor);
None
}
}
}
fn read_container(
events: &[SpannedEvent<'_>],
lines: &LineIndex<'_>,
cursor: &mut usize,
) -> String {
let children = read_blocks(events, lines, cursor);
*cursor += 1;
children
.iter()
.filter_map(|child| child.get("text").and_then(Value::as_str))
.filter(|text| !text.is_empty())
.collect::<Vec<_>>()
.join("\n")
}
fn read_inline(events: &[SpannedEvent<'_>], cursor: &mut usize) -> String {
let text = read_loose_inline(events, cursor);
if matches!(events.get(*cursor), Some((Event::End(_), _))) {
*cursor += 1;
}
text
}
fn read_loose_inline(events: &[SpannedEvent<'_>], cursor: &mut usize) -> String {
let mut text = String::new();
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(_) => {
depth += 1;
*cursor += 1;
}
Event::End(_) => {
depth -= 1;
*cursor += 1;
}
Event::Text(chunk) | Event::Code(chunk) => {
text.push_str(chunk);
*cursor += 1;
}
Event::SoftBreak | Event::HardBreak => {
text.push(' ');
*cursor += 1;
}
_ => *cursor += 1,
}
}
text.trim().to_string()
}
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 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())]
);
}
}