use super::*;
use std::path::{Path, PathBuf};
fn slice<'a>(body: &'a str, range: &Range<usize>) -> &'a str {
&body[range.clone()]
}
fn kinds(tree: &BlockTree) -> Vec<&'static str> {
tree.blocks
.iter()
.map(|b| match b.kind {
BlockKind::Paragraph => "para",
BlockKind::Fence(_) => "fence",
BlockKind::BlockQuote(_) => "quote",
BlockKind::List(_) => "list",
BlockKind::Table(_) => "table",
BlockKind::HtmlBlock => "html",
BlockKind::IndentedCode => "indented",
})
.collect()
}
fn only_list(tree: &BlockTree) -> &List {
tree.blocks
.iter()
.find_map(|b| match &b.kind {
BlockKind::List(l) => Some(l),
_ => None,
})
.expect("a list block")
}
fn only_quote(tree: &BlockTree) -> &BlockQuote {
tree.blocks
.iter()
.find_map(|b| match &b.kind {
BlockKind::BlockQuote(q) => Some(q),
_ => None,
})
.expect("a blockquote block")
}
#[test]
fn headings_carry_level_path_and_a_body_range_that_slices_back() {
let body = "# Top\n\nintro\n\n## A\n\nunder a\n\n### A1\n\ndeep\n\n## B\n\nunder b\n";
let tree = parse_blocks(body);
let levels: Vec<u8> = tree.headings.iter().map(|h| h.level).collect();
assert_eq!(
levels,
vec![1, 2, 3, 2],
"H1 is level 1, not a 0-based index"
);
let paths: Vec<Vec<String>> = tree.headings.iter().map(|h| h.path.clone()).collect();
assert_eq!(
paths,
vec![
vec!["Top"],
vec!["Top", "A"],
vec!["Top", "A", "A1"],
vec!["Top", "B"],
],
"a nested heading's path is what Obsidian joins with `#`"
);
assert_eq!(slice(body, &tree.headings[0].range), "# Top\n");
assert_eq!(
slice(body, &tree.headings[1].body_range),
"\nunder a\n\n### A1\n\ndeep\n\n"
);
assert_eq!(slice(body, &tree.headings[2].body_range), "\ndeep\n\n");
assert_eq!(slice(body, &tree.headings[3].body_range), "\nunder b\n");
}
#[test]
fn duplicate_heading_texts_differ_by_path() {
let body = "## A\n\n### Notes\n\nx\n\n## B\n\n### Notes\n\ny\n";
let tree = parse_blocks(body);
let notes: Vec<Vec<String>> = tree
.headings
.iter()
.filter(|h| h.text == "Notes")
.map(|h| h.path.clone())
.collect();
assert_eq!(
notes,
vec![vec!["A", "Notes"], vec!["B", "Notes"]],
"two `Notes` headings are distinguishable only by their ancestors"
);
}
#[test]
fn setext_headings_are_headings_and_keep_both_lines_in_range() {
let body = "Title\n=====\n\npara\n\nSub\n---\n\nmore\n";
let tree = parse_blocks(body);
assert_eq!(
tree.headings
.iter()
.map(|h| (h.level, h.text.as_str()))
.collect::<Vec<_>>(),
vec![(1, "Title"), (2, "Sub")]
);
assert_eq!(slice(body, &tree.headings[0].range), "Title\n=====\n");
assert_eq!(tree.headings[1].path, vec!["Title", "Sub"]);
}
#[test]
fn an_atx_closing_sequence_empties_the_title_but_not_the_raw_text() {
let body = "### #\n\nbody\n\n## Done ##\n\n## C#\n";
let tree = parse_blocks(body);
let seen: Vec<(&str, &str)> = tree
.headings
.iter()
.map(|h| (h.text.as_str(), h.raw.as_str()))
.collect();
assert_eq!(
seen,
vec![("", "#"), ("Done", "Done ##"), ("C#", "C#")],
"a `#` run only closes when whitespace precedes it โ `C#` keeps its hash"
);
}
#[test]
fn crlf_line_endings_do_not_leak_into_heading_text() {
let body = "# H\r\n\r\npara one\r\n\r\n## H2\r\n\r\ntail\r\n";
let tree = parse_blocks(body);
assert_eq!(
tree.headings
.iter()
.map(|h| h.text.as_str())
.collect::<Vec<_>>(),
vec!["H", "H2"]
);
assert_eq!(slice(body, &tree.headings[0].range), "# H\r\n");
let paras: Vec<&str> = tree
.blocks
.iter()
.filter(|b| matches!(b.kind, BlockKind::Paragraph))
.map(|b| slice(body, &b.range))
.collect();
assert_eq!(paras, vec!["para one\r\n", "tail\r\n"]);
}
#[test]
fn every_block_records_the_heading_it_sits_under() {
let body = "before\n\n# Top\n\nunder top\n\n## Sub\n\nunder sub\n";
let tree = parse_blocks(body);
let attributed: Vec<(Option<usize>, &str)> = tree
.blocks
.iter()
.map(|b| (b.heading, slice(body, &b.range)))
.collect();
assert_eq!(
attributed,
vec![
(None, "before\n"),
(Some(0), "under top\n"),
(Some(1), "under sub\n"),
],
"a block before the first heading has no section"
);
}
#[test]
fn a_fence_keeps_its_info_string_and_a_content_only_range() {
let body = "# H\n\n```python title=\"x\"\nprint(1)\nprint(2)\n```\n\nafter\n";
let tree = parse_blocks(body);
let fence = tree
.blocks
.iter()
.find_map(|b| match &b.kind {
BlockKind::Fence(f) => Some(f),
_ => None,
})
.expect("a fence");
assert_eq!(fence.info, "python title=\"x\"");
assert_eq!(fence.lang.as_deref(), Some("python"));
assert_eq!(slice(body, &fence.code_range), "print(1)\nprint(2)\n");
}
#[test]
fn a_tilde_fence_inside_a_backtick_fence_is_content_not_a_second_fence() {
let body = "```\nnot a fence:\n~~~\nstill code\n```\n\nafter\n";
let tree = parse_blocks(body);
assert_eq!(kinds(&tree), vec!["fence", "para"]);
let fence = match &tree.blocks[0].kind {
BlockKind::Fence(f) => f,
other => panic!("expected a fence, got {other:?}"),
};
assert_eq!(
slice(body, &fence.code_range),
"not a fence:\n~~~\nstill code\n"
);
}
#[test]
fn an_indented_code_block_and_an_html_block_keep_their_own_ranges() {
let body = "para\n\n indented code\n more\n\n<div>\n<p>hi [[x]]</p>\n</div>\n\ntail\n";
let tree = parse_blocks(body);
assert_eq!(kinds(&tree), vec!["para", "indented", "html", "para"]);
assert_eq!(
slice(body, &tree.blocks[1].range),
"indented code\n more\n"
);
assert_eq!(
slice(body, &tree.blocks[2].range),
"<div>\n<p>hi [[x]]</p>\n</div>\n"
);
}
#[test]
fn a_lazy_blockquote_continuation_stays_inside_the_quote() {
let body = "> quoted\nlazy continuation\n\nafter\n";
let tree = parse_blocks(body);
assert_eq!(kinds(&tree), vec!["quote", "para", "para"]);
assert_eq!(
slice(body, &tree.blocks[0].range),
"> quoted\nlazy continuation\n",
"the unprefixed second line is part of the quote"
);
assert_eq!(tree.blocks[1].inside, Some(0), "the quote's own paragraph");
assert_eq!(tree.blocks[2].inside, None);
let quote = only_quote(&tree);
assert_eq!(quote.callout, None);
assert_eq!(quote.inner_text_range, tree.blocks[0].range);
}
#[test]
fn a_titled_folded_callout_reports_kind_title_fold_and_a_body_after_the_marker() {
let body = "> [!Warning]- Be careful\n> Body line one.\n> more\n\nafter\n";
let tree = parse_blocks(body);
let quote = only_quote(&tree);
assert_eq!(
quote.callout,
Some(Callout {
kind: "warning".to_string(),
title: Some("Be careful".to_string()),
fold: Some('-'),
}),
"the kind is lowercased; Obsidian matches it case-insensitively"
);
assert_eq!(
slice(body, "e.inner_text_range),
"> Body line one.\n> more\n",
"the marker line declares the callout and is not its text"
);
}
#[test]
fn an_untitled_callout_has_no_title_and_an_unfolded_one_no_marker() {
let body = "> [!note]\n> body\n\n> [!tip]+ Open\n> t\n\n> [!versionadded] 15.1\n> v\n";
let tree = parse_blocks(body);
let callouts: Vec<Option<Callout>> = tree
.blocks
.iter()
.filter_map(|b| match &b.kind {
BlockKind::BlockQuote(q) => Some(q.callout.clone()),
_ => None,
})
.collect();
assert_eq!(
callouts,
vec![
Some(Callout {
kind: "note".to_string(),
title: None,
fold: None
}),
Some(Callout {
kind: "tip".to_string(),
title: Some("Open".to_string()),
fold: Some('+')
}),
Some(Callout {
kind: "versionadded".to_string(),
title: Some("15.1".to_string()),
fold: None
}),
]
);
let first = match &tree.blocks[0].kind {
BlockKind::BlockQuote(q) => q,
other => panic!("expected a quote, got {other:?}"),
};
assert_eq!(slice(body, &first.inner_text_range), "> body\n");
}
#[test]
fn nested_lists_keep_item_text_and_a_fence_inside_an_item() {
let body =
"1. step one\n\n ```python\n x=1\n ```\n\n - sub a\n - sub b\n2. step two\n";
let tree = parse_blocks(body);
let list = only_list(&tree);
assert!(list.ordered);
assert_eq!(list.start, Some(1));
assert_eq!(list.items.len(), 2);
let first = &list.items[0];
assert_eq!(
slice(body, first.text_range.as_ref().expect("step text")),
"step one\n",
"the item's text stops before the fence it contains"
);
assert_eq!(first.children.len(), 1, "the nested bullet list");
let sub: Vec<&str> = first.children[0]
.items
.iter()
.map(|i| slice(body, i.text_range.as_ref().unwrap()))
.collect();
assert_eq!(sub, vec!["sub a", "sub b"]);
assert!(!first.children[0].ordered);
let fence = tree
.blocks
.iter()
.position(|b| matches!(b.kind, BlockKind::Fence(_)))
.expect("a fence block");
assert_eq!(tree.blocks[fence].inside, Some(0), "inside the outer list");
assert_eq!(
slice(body, &list.items[1].range),
"2. step two\n",
"item ranges are verbatim"
);
}
#[test]
fn a_tight_items_text_spans_the_inline_syntax_it_contains() {
let body = "- plain item\n- see `code` in [Docs](https://x/y)\n";
let tree = parse_blocks(body);
let list = only_list(&tree);
let texts: Vec<&str> = list
.items
.iter()
.map(|i| slice(body, i.text_range.as_ref().unwrap()))
.collect();
assert_eq!(
texts,
vec"],
"a tight item has no Paragraph event, and its text is still the source"
);
}
#[test]
fn an_items_text_is_its_first_paragraph_not_its_last() {
let body = "1. step one\n\n more prose about step one\n2. step two\n";
let tree = parse_blocks(body);
let list = only_list(&tree);
assert_eq!(
slice(body, list.items[0].text_range.as_ref().expect("step text")),
"step one\n",
"a second paragraph is the step's detail, not its name"
);
}
#[test]
fn an_ordered_lists_start_number_survives() {
let body = "3. three\n4. four\n";
let list_start = only_list(&parse_blocks(body)).start;
assert_eq!(list_start, Some(3));
}
#[test]
fn table_cells_are_raw_source_including_wikilinks_and_images() {
let body = "| Name | Shot |\n|---|---|\n| [[Atlas]] | ![[map.png]] |\n| `int` | plain |\n";
let tree = parse_blocks(body);
let table = tree
.blocks
.iter()
.find_map(|b| match &b.kind {
BlockKind::Table(t) => Some(t),
_ => None,
})
.expect("a table");
assert_eq!(
table
.header
.iter()
.map(|c| c.text.as_str())
.collect::<Vec<_>>(),
vec!["Name", "Shot"]
);
assert_eq!(
table
.rows
.iter()
.map(|r| r.iter().map(|c| c.text.as_str()).collect::<Vec<_>>())
.collect::<Vec<_>>(),
vec![vec!["[[Atlas]]", "![[map.png]]"], vec!["`int`", "plain"]],
"nothing in a cell is resolved โ that is the link pass's job"
);
for cell in table.header.iter().chain(table.rows.iter().flatten()) {
assert_eq!(
slice(body, &cell.range),
cell.text,
"cell ranges slice back"
);
}
}
#[test]
fn block_ids_attach_to_the_paragraph_item_or_preceding_block() {
let body = "A paragraph. ^abc-123\n\n- item one ^bid2\n - nested ^bid3\n- two\n\n\
| a |\n|---|\n| b |\n\n^tableid\n";
let tree = parse_blocks(body);
let seen: Vec<(&str, Option<usize>, bool, &str)> = tree
.block_ids
.iter()
.map(|b| {
(
b.id.as_str(),
b.attaches_to,
b.own_line,
slice(body, &b.range),
)
})
.collect();
assert_eq!(kinds(&tree), vec!["para", "list", "table", "para"]);
assert_eq!(
seen,
vec![
("abc-123", Some(0), false, "^abc-123"),
("bid2", Some(1), false, "^bid2"),
("bid3", Some(1), false, "^bid3"),
("tableid", Some(2), true, "^tableid"),
]
);
}
#[test]
fn an_underscore_is_not_a_block_id() {
let tree = parse_blocks("A paragraph. ^my_id\n");
assert_eq!(tree.block_ids, vec![]);
}
#[test]
fn comments_are_found_inline_and_across_lines_but_not_inside_code() {
let body =
"Pre %%inline%% post\n\n%%\nblock comment\n%%\n\n```sh\necho %%not a comment%%\n```\n";
let tree = parse_blocks(body);
let found: Vec<&str> = tree.comments.iter().map(|r| slice(body, r)).collect();
assert_eq!(
found,
vec!["%%inline%%", "%%\nblock comment\n%%"],
"`%%` inside a fence is code, and the shortest close wins"
);
}
fn golden_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/okf/golden")
}
fn golden_bodies(vault: &str) -> Vec<(String, String)> {
let root = golden_root().join(vault);
let mut out = Vec::new();
for entry in walkdir::WalkDir::new(&root).sort_by_file_name() {
let entry = entry.expect("walking the golden vault");
if entry.path().extension().is_none_or(|e| e != "md") {
continue;
}
let text = std::fs::read_to_string(entry.path()).expect("reading a golden note");
let (_yaml, body) = crate::okf::frontmatter::split(&text);
out.push((entry.path().display().to_string(), body));
}
assert!(!out.is_empty(), "golden vault {vault} has notes");
out
}
#[test]
fn a_heading_inside_a_comment_is_not_in_the_tree() {
let body = "# Top\n\n%%\n# Hidden\n%%\n\ntext\n\n## Real\n\nmore\n";
let tree = parse_blocks(body);
assert_eq!(
tree.headings
.iter()
.map(|h| h.text.as_str())
.collect::<Vec<_>>(),
vec!["Top", "Real"]
);
assert_eq!(tree.headings[1].path, vec!["Top", "Real"]);
assert_eq!(
tree.headings[0].body_range.end,
body.len(),
"the hidden heading does not close the section it sits in"
);
let text = tree
.blocks
.iter()
.find(|b| slice(body, &b.range).trim() == "more")
.expect("the paragraph under `## Real`");
assert_eq!(
text.heading,
Some(1),
"blocks re-point at the kept headings"
);
}
fn scanned_heading_lines(body: &str) -> usize {
let mut fenced = false;
let mut count = 0;
for line in body.lines() {
let t = line.trim_start();
if t.starts_with("```") || t.starts_with("~~~") {
fenced = !fenced;
continue;
}
if fenced {
continue;
}
let hashes = t.len() - t.trim_start_matches('#').len();
if (1..=6).contains(&hashes) {
let rest = &t[hashes..];
if rest.is_empty() || rest.starts_with([' ', '\t']) {
count += 1;
}
}
}
count
}
#[test]
fn the_golden_vaults_headings_match_what_the_link_pass_counts() {
let mut total = 0;
for vault in ["okf", "obsidian", "vault"] {
for (path, body) in golden_bodies(vault) {
let tree = parse_blocks(&body);
let scanned = scanned_heading_lines(&body);
assert_eq!(
tree.headings.len(),
scanned,
"{path}: the tree and the line scanner must see the same headings"
);
for heading in &tree.headings {
assert!(
slice(&body, &heading.range).contains(&heading.text),
"{path}: heading range must contain its own text"
);
assert!(heading.body_range.end <= body.len());
}
total += tree.headings.len();
}
}
assert_eq!(total, 12, "the golden corpus's heading count (Phase 0)");
}
#[test]
fn every_golden_range_slices_back_into_its_own_body() {
for vault in ["okf", "obsidian", "vault"] {
for (path, body) in golden_bodies(vault) {
let tree = parse_blocks(&body);
let mut previous = 0;
for block in &tree.blocks {
assert!(
block.range.end <= body.len() && block.range.start <= block.range.end,
"{path}: block range out of bounds"
);
if block.inside.is_none() {
assert!(
block.range.start >= previous,
"{path}: top-level blocks are in document order"
);
previous = block.range.end;
}
let _ = slice(&body, &block.range);
}
}
}
}