1use comrak::nodes::{AstNode, ListType, NodeValue};
22use comrak::{Arena, Options};
23
24use crate::dialect::{extract_trailing_anchor, is_standalone_anchor};
25use crate::finding::SourcePos;
26use crate::frontmatter::yaml_to_json;
27use crate::js;
28use crate::tree::{BlockNode, DocTree, Frontmatter, ListItem, SectionNode};
29
30#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct FrontmatterSplit {
35 pub raw: Option<String>,
37 pub body: String,
39}
40
41fn is_fence(line: &str) -> bool {
44 let line = line.strip_suffix('\r').unwrap_or(line);
45 match line.strip_prefix("---") {
46 Some(rest) => rest.chars().all(|c| c == ' ' || c == '\t'),
47 None => false,
48 }
49}
50
51fn opening_fence_end(md: &str) -> Option<usize> {
54 let eol = md.find('\n')?;
55 is_fence(&md[..eol]).then_some(eol + 1)
56}
57
58pub fn split_frontmatter(source: &str) -> FrontmatterSplit {
64 let whole = || FrontmatterSplit {
65 raw: None,
66 body: source.to_string(),
67 };
68 let md = source.strip_prefix('\u{feff}').unwrap_or(source);
70 let Some(open_end) = opening_fence_end(md) else {
71 return whole();
72 };
73 let mut idx = open_end;
74 while idx <= md.len() {
75 let (line, line_term_end) = match md[idx..].find('\n') {
76 Some(i) => (&md[idx..idx + i], idx + i + 1),
77 None => (&md[idx..], md.len()),
78 };
79 if is_fence(line) {
80 let mut raw_end = idx;
82 if raw_end > open_end && md.as_bytes()[raw_end - 1] == b'\n' {
83 raw_end -= 1;
84 if raw_end > open_end && md.as_bytes()[raw_end - 1] == b'\r' {
85 raw_end -= 1;
86 }
87 }
88 return FrontmatterSplit {
89 raw: Some(md[open_end..raw_end].to_string()),
90 body: md[line_term_end..].to_string(),
91 };
92 }
93 if line_term_end >= md.len() {
94 break; }
96 idx = line_term_end;
97 }
98 whole()
99}
100
101pub fn parse_document(source: &str) -> DocTree {
108 let split = split_frontmatter(source);
109 let prefix_len = source.len() - split.body.len();
111 let line_offset = source[..prefix_len].bytes().filter(|&b| b == b'\n').count() as u32;
112
113 let frontmatter = split.raw.map(|raw| {
114 let data = serde_yaml::from_str::<serde_yaml::Value>(&raw)
115 .ok()
116 .map(yaml_to_json);
117 Frontmatter {
118 data,
119 raw,
120 pos: SourcePos::at(1, 1),
121 }
122 });
123
124 let body = split.body.strip_prefix('\u{feff}').unwrap_or(&split.body);
127 let base = split.body.len() - body.len();
128 let root = project_body(body, line_offset, base);
129 DocTree {
130 frontmatter,
131 body: split.body,
132 root,
133 }
134}
135
136fn gfm_options() -> Options<'static> {
139 let mut options = Options::default();
140 options.extension.table = true;
141 options.extension.tasklist = true;
142 options.extension.strikethrough = true;
143 options.extension.autolink = true;
144 options.extension.footnotes = true;
145 options
146}
147
148struct Cx<'s> {
151 off: u32,
153 lines: Vec<&'s str>,
155 starts: Vec<usize>,
157 base: usize,
159 len: usize,
161}
162
163impl Cx<'_> {
164 fn offset(&self, line: usize, column: usize) -> usize {
167 let start = self
168 .starts
169 .get(line.saturating_sub(1))
170 .copied()
171 .unwrap_or(self.len);
172 (start + column.saturating_sub(1)).min(self.len)
173 }
174
175 fn range_of<'a>(&self, node: &'a AstNode<'a>) -> std::ops::Range<usize> {
178 let sp = node.data.borrow().sourcepos;
179 let start = self.offset(sp.start.line, sp.start.column);
180 let end = (self.offset(sp.end.line, sp.end.column) + 1)
181 .min(self.len)
182 .max(start);
183 self.base + start..self.base + end
184 }
185}
186
187fn project_body(body: &str, line_offset: u32, base: usize) -> SectionNode {
188 let arena = Arena::new();
189 let root = comrak::parse_document(&arena, body, &gfm_options());
190 let mut starts = vec![0];
191 starts.extend(body.match_indices('\n').map(|(i, _)| i + 1));
192 let cx = Cx {
193 off: line_offset,
194 lines: body.lines().collect(),
195 starts,
196 base,
197 len: body.len(),
198 };
199 build_tree(root, &cx)
200}
201
202fn pos_of<'a>(node: &'a AstNode<'a>, off: u32) -> SourcePos {
204 let sp = node.data.borrow().sourcepos;
205 SourcePos::at(sp.start.line as u32 + off, sp.start.column as u32)
206}
207
208fn flatten_inline<'a>(nodes: impl Iterator<Item = &'a AstNode<'a>>, out: &mut String) {
214 for n in nodes {
215 match &n.data.borrow().value {
216 NodeValue::Text(t) => out.push_str(t),
217 NodeValue::Code(c) => out.push_str(&c.literal),
218 NodeValue::SoftBreak => out.push('\n'),
219 NodeValue::LineBreak => out.push(' '),
220 NodeValue::HtmlInline(h) => out.push_str(h),
221 NodeValue::Image(_) => {} _ => flatten_inline(n.children(), out),
223 }
224 }
225}
226
227fn flatten_to_string<'a>(nodes: impl Iterator<Item = &'a AstNode<'a>>) -> String {
228 let mut out = String::new();
229 flatten_inline(nodes, &mut out);
230 out
231}
232
233fn flatten_list_item<'a>(item: &'a AstNode<'a>) -> String {
235 let parts: Vec<String> = item
236 .children()
237 .filter(|c| matches!(c.data.borrow().value, NodeValue::Paragraph))
238 .map(|c| flatten_to_string(c.children()))
239 .collect();
240 js::trim(&parts.join(" ")).to_string()
241}
242
243fn project_list<'a>(node: &'a AstNode<'a>, ordered: bool, cx: &Cx) -> BlockNode {
244 let items = node
245 .children()
246 .map(|li| {
247 let checked = match &li.data.borrow().value {
248 NodeValue::TaskItem(t) => Some(t.symbol.is_some()),
249 _ => None,
250 };
251 ListItem {
252 text: flatten_list_item(li),
253 checked,
254 pos: pos_of(li, cx.off),
255 }
256 })
257 .collect();
258 BlockNode::List {
259 ordered,
260 items,
261 anchor: None,
262 pos: pos_of(node, cx.off),
263 range: cx.range_of(node),
264 }
265}
266
267fn project_code<'a>(node: &'a AstNode<'a>, cx: &Cx) -> BlockNode {
268 let (lang, value, fenced) = match &node.data.borrow().value {
269 NodeValue::CodeBlock(cb) => (
271 cb.info.split_whitespace().next().map(str::to_string),
272 cb.literal
273 .strip_suffix('\n')
274 .map(|v| v.strip_suffix('\r').unwrap_or(v))
275 .unwrap_or(&cb.literal)
276 .to_string(),
277 cb.fenced,
278 ),
279 _ => (None, String::new(), true),
280 };
281 BlockNode::Code {
282 lang,
283 value,
284 fenced,
285 anchor: None,
286 pos: pos_of(node, cx.off),
287 range: cx.range_of(node),
288 }
289}
290
291fn project_paragraph<'a>(node: &'a AstNode<'a>, cx: &Cx) -> BlockNode {
292 BlockNode::Paragraph {
293 text: js::trim(&flatten_to_string(node.children())).to_string(),
294 anchor: None,
295 pos: pos_of(node, cx.off),
296 range: cx.range_of(node),
297 }
298}
299
300fn project_table<'a>(node: &'a AstNode<'a>, cx: &Cx) -> BlockNode {
306 let mut columns = Vec::new();
307 let mut rows: Vec<Vec<String>> = Vec::new();
308 let mut row_pos = Vec::new();
309 let mut last_row_line = 0usize;
310 for row in node.children() {
311 let is_header = matches!(row.data.borrow().value, NodeValue::TableRow(true));
312 let cells: Vec<String> = row
313 .children()
314 .map(|c| js::trim(&flatten_to_string(c.children())).to_string())
315 .collect();
316 if is_header && columns.is_empty() {
317 columns = cells;
318 } else {
319 last_row_line = row.data.borrow().sourcepos.start.line;
320 row_pos.push(pos_of(row, cx.off));
321 rows.push(cells);
322 }
323 }
324
325 let mut anchor = None;
326 if !rows.is_empty()
327 && let Some(id) = cx
328 .lines
329 .get(last_row_line.wrapping_sub(1))
330 .and_then(|line| single_cell_anchor(line))
331 {
332 anchor = Some(id.to_string());
333 rows.pop();
334 row_pos.pop();
335 }
336
337 BlockNode::Table {
338 columns,
339 rows,
340 row_pos,
341 anchor,
342 pos: pos_of(node, cx.off),
343 range: cx.range_of(node),
344 }
345}
346
347fn single_cell_anchor(line: &str) -> Option<&str> {
350 let cell = js::trim(line);
351 let cell = cell.strip_prefix('|').unwrap_or(cell);
352 let cell = cell.strip_suffix('|').unwrap_or(cell);
353 if cell.contains('|') {
354 return None;
355 }
356 is_standalone_anchor(cell)
357}
358
359fn project_block<'a>(node: &'a AstNode<'a>, cx: &Cx) -> Option<BlockNode> {
362 let value = &node.data.borrow().value;
363 match value {
364 NodeValue::Paragraph => Some(project_paragraph(node, cx)),
365 NodeValue::CodeBlock(_) => Some(project_code(node, cx)),
366 NodeValue::List(l) => Some(project_list(node, l.list_type == ListType::Ordered, cx)),
367 NodeValue::Table(_) => Some(project_table(node, cx)),
368 _ => None,
369 }
370}
371
372fn bind_trailing_anchor(block: &mut BlockNode) {
375 match block {
376 BlockNode::Paragraph { text, anchor, .. } => {
377 if let Some(t) = extract_trailing_anchor(text) {
378 *text = t.rest;
379 *anchor = Some(t.id);
380 }
381 }
382 BlockNode::List { items, anchor, .. } => {
383 for item in items {
384 if let Some(t) = extract_trailing_anchor(&item.text) {
385 item.text = t.rest;
386 *anchor = Some(t.id);
387 break;
388 }
389 }
390 }
391 _ => {}
392 }
393}
394
395fn bind_anchor(sec: &mut SectionNode, id: &str) {
398 match sec.blocks.last_mut() {
399 Some(last) if last.anchor().is_none() => *last.anchor_mut() = Some(id.to_string()),
400 _ => sec.anchors.push(id.to_string()),
401 }
402}
403
404fn attach_content_node<'a>(sec: &mut SectionNode, node: &'a AstNode<'a>, cx: &Cx) {
406 let Some(mut block) = project_block(node, cx) else {
407 return; };
409
410 if let BlockNode::Paragraph { text, .. } = &block
413 && let Some(id) = is_standalone_anchor(text)
414 {
415 let id = id.to_string();
416 bind_anchor(sec, &id);
417 return;
418 }
419
420 bind_trailing_anchor(&mut block);
421 sec.blocks.push(block);
422}
423
424fn pop_to_depth(stack: &mut Vec<SectionNode>, depth: u8, end: usize) {
427 while stack.len() > 1 && stack.last().expect("non-empty stack").depth >= depth {
428 let mut done = stack.pop().expect("len > 1");
429 done.range.end = end;
430 stack.last_mut().expect("root remains").sections.push(done);
431 }
432}
433
434fn build_tree<'a>(root: &'a AstNode<'a>, cx: &Cx) -> SectionNode {
439 let doc_root = SectionNode::new("", 1, SourcePos::line(0));
440 let mut title: Option<String> = None;
441 let mut stack: Vec<SectionNode> = vec![doc_root];
442
443 for node in root.children() {
444 let heading = match &node.data.borrow().value {
445 NodeValue::Heading(h) => Some(h.level),
446 _ => None,
447 };
448 if let Some(level) = heading {
449 let name = js::trim(&flatten_to_string(node.children())).to_string();
450 if level == 1 && title.is_none() {
451 title = Some(name);
453 continue;
454 }
455 let start = cx.range_of(node).start;
456 pop_to_depth(&mut stack, level, start);
457 let mut section = SectionNode::new(name, level, pos_of(node, cx.off));
458 section.range = start..start;
459 stack.push(section);
460 continue;
461 }
462 attach_content_node(stack.last_mut().expect("non-empty stack"), node, cx);
463 }
464
465 let end = cx.base + cx.len;
466 pop_to_depth(&mut stack, 1, end); let mut doc_root = stack.pop().expect("root");
468 doc_root.range = 0..end;
469 doc_root.name = title.unwrap_or_default();
470 doc_root
471}
472
473#[cfg(test)]
474mod tests {
475 use super::*;
476
477 #[test]
480 fn one_section_one_paragraph() {
481 let tree = parse_document("## Overview\n\nPlain prose.\n");
482 assert!(tree.frontmatter.is_none());
483 assert_eq!(tree.root.name, "");
484 assert_eq!(tree.root.sections.len(), 1);
485 let sec = &tree.root.sections[0];
486 assert_eq!(sec.name, "Overview");
487 assert_eq!(sec.depth, 2);
488 assert_eq!(sec.pos, SourcePos::at(1, 1));
489 assert_eq!(
490 sec.blocks,
491 vec![BlockNode::Paragraph {
492 text: "Plain prose.".into(),
493 anchor: None,
494 pos: SourcePos::at(3, 1),
495 range: 13..25,
496 }]
497 );
498 assert_eq!(sec.range, 0..26);
499 assert_eq!(tree.root.range, 0..26);
500 }
501
502 #[test]
503 fn leading_h1_is_the_title_not_a_section() {
504 let tree = parse_document("# Widget notes\n\n## Background\n\nWidgets are small.");
505 assert_eq!(tree.root.name, "Widget notes");
506 assert_eq!(tree.root.sections.len(), 1);
507 assert_eq!(tree.root.sections[0].name, "Background");
508 assert_eq!(tree.root.sections[0].pos.line, 3);
509 }
510
511 #[test]
514 fn frontmatter_splits_raw_and_verbatim_body() {
515 let s = split_frontmatter("---\na: 1\n---\nbody\n");
516 assert_eq!(s.raw.as_deref(), Some("a: 1"));
517 assert_eq!(s.body, "body\n");
518 }
519
520 #[test]
521 fn unclosed_fence_is_not_frontmatter() {
522 let s = split_frontmatter("---\na: 1\nbody\n");
523 assert_eq!(s.raw, None);
524 assert_eq!(s.body, "---\na: 1\nbody\n");
525 }
526
527 #[test]
528 fn fence_must_be_exactly_three_dashes_at_column_one() {
529 assert_eq!(split_frontmatter("----\na: 1\n----\nbody\n").raw, None);
530 assert_eq!(split_frontmatter(" ---\na: 1\n---\nbody\n").raw, None);
531 assert_eq!(split_frontmatter("\n---\na: 1\n---\nbody\n").raw, None);
532 assert_eq!(
534 split_frontmatter("--- \na: 1\n--- \nbody\n").raw.as_deref(),
535 Some("a: 1")
536 );
537 }
538
539 #[test]
540 fn empty_frontmatter_and_eof_closing_fence() {
541 let s = split_frontmatter("---\n---\nbody\n");
542 assert_eq!(s.raw.as_deref(), Some(""));
543 assert_eq!(s.body, "body\n");
544 let s = split_frontmatter("---\na: 1\n---");
545 assert_eq!(s.raw.as_deref(), Some("a: 1"));
546 assert_eq!(s.body, "");
547 }
548
549 #[test]
550 fn crlf_frontmatter_keeps_inner_line_endings_and_strips_the_fence_terminator() {
551 let s = split_frontmatter("---\r\na: 1\r\nb: 2\r\n---\r\nbody\r\n");
552 assert_eq!(s.raw.as_deref(), Some("a: 1\r\nb: 2"));
553 assert_eq!(s.body, "body\r\n");
554 }
555
556 #[test]
557 fn dots_do_not_close_a_frontmatter_block() {
558 assert_eq!(split_frontmatter("---\na: 1\n...\nbody\n").raw, None);
559 }
560
561 #[test]
566 fn body_positions_are_absolute_document_lines() {
567 let src = "---\nid: D-0099\nstatus: open/draft\ntitle: Adopt the widget protocol\n---\n\n# Adopt the widget protocol\n\n## Summary\n\nWe will adopt the widget protocol across all services.";
568 let tree = parse_document(src);
569 let fm = tree.frontmatter.as_ref().expect("frontmatter present");
570 assert_eq!(
571 fm.raw,
572 "id: D-0099\nstatus: open/draft\ntitle: Adopt the widget protocol"
573 );
574 assert_eq!(fm.pos, SourcePos::at(1, 1));
575 assert_eq!(tree.root.name, "Adopt the widget protocol");
576 let sec = &tree.root.sections[0];
577 assert_eq!(sec.name, "Summary");
578 assert_eq!(sec.pos, SourcePos::at(9, 1));
579 assert_eq!(sec.blocks[0].pos(), SourcePos::at(11, 1));
580 }
581
582 #[test]
585 fn table_columns_rows_and_row_lines() {
586 let src = "## Decision\n\n| # | Component | Resolution |\n| - | --------- | ---------- |\n| 1 | engine | markdown-contract |";
587 let tree = parse_document(src);
588 let BlockNode::Table {
589 columns,
590 rows,
591 row_pos,
592 anchor,
593 pos,
594 ..
595 } = &tree.root.sections[0].blocks[0]
596 else {
597 panic!("expected a table");
598 };
599 assert_eq!(columns, &["#", "Component", "Resolution"]);
600 assert_eq!(
601 rows,
602 &[vec![
603 "1".to_string(),
604 "engine".into(),
605 "markdown-contract".into()
606 ]]
607 );
608 assert_eq!(pos.line, 3);
609 assert_eq!(row_pos[0].line, 5); assert_eq!(*anchor, None);
611 }
612
613 #[test]
614 fn task_list_items_carry_checked_state_and_lines() {
615 let src = "## Acceptance criteria\n\n- [ ] first thing\n- [x] second thing\n- plain item\n";
616 let tree = parse_document(src);
617 let BlockNode::List { ordered, items, .. } = &tree.root.sections[0].blocks[0] else {
618 panic!("expected a list");
619 };
620 assert!(!ordered);
621 assert_eq!(
622 items
623 .iter()
624 .map(|i| (i.text.as_str(), i.checked, i.pos.line))
625 .collect::<Vec<_>>(),
626 vec![
627 ("first thing", Some(false), 3),
628 ("second thing", Some(true), 4),
629 ("plain item", None, 5)
630 ]
631 );
632 }
633
634 #[test]
635 fn fenced_code_is_opaque_and_keeps_its_lang() {
636 let src = "## Example\n\n```ts\nconst x = 1;\n## not a heading\n```\n";
637 let tree = parse_document(src);
638 let sec = &tree.root.sections[0];
639 assert_eq!(sec.sections.len(), 0); let BlockNode::Code {
641 lang,
642 value,
643 fenced,
644 pos,
645 ..
646 } = &sec.blocks[0]
647 else {
648 panic!("expected code");
649 };
650 assert_eq!(lang.as_deref(), Some("ts"));
651 assert_eq!(value, "const x = 1;\n## not a heading");
653 assert!(fenced);
654 assert_eq!(pos.line, 3);
655 }
656
657 #[test]
658 fn indented_code_is_not_fenced() {
659 let tree = parse_document(
660 "## Example
661
662 let x = 1;
663",
664 );
665 let BlockNode::Code { fenced, pos, .. } = &tree.root.sections[0].blocks[0] else {
666 panic!("expected code");
667 };
668 assert!(!fenced);
669 assert_eq!(pos.line, 3);
670 }
671
672 #[test]
675 fn a_second_h1_starts_a_new_top_level_section() {
676 let tree = parse_document("# T1\n\n## A\n\n# T2\n\n## B\n");
677 let names: Vec<_> = tree.root.sections.iter().map(|s| s.name.as_str()).collect();
678 assert_eq!(names, vec!["A", "T2"]);
679 assert_eq!(tree.root.sections[1].sections[0].name, "B");
680 }
681
682 #[test]
684 fn a_leading_bom_is_skipped() {
685 let tree = parse_document("\u{FEFF}---\nstatus: open\n---\n\n## A\n");
686 let fm = tree.frontmatter.as_ref().expect("frontmatter");
687 assert_eq!(fm.data, Some(serde_json::json!({"status": "open"})));
688 assert_eq!(tree.root.sections[0].name, "A");
689 assert_eq!(tree.root.sections[0].pos.line, 5);
690 }
691
692 #[test]
695 fn a_two_cell_row_with_an_anchor_is_a_data_row() {
696 let table = |last: &str| {
697 let src = format!("## T\n\n| A | B |\n| - | - |\n| 1 | 2 |\n{last}\n");
698 let tree = parse_document(&src);
699 let BlockNode::Table { rows, anchor, .. } = &tree.root.sections[0].blocks[0] else {
700 panic!("expected a table");
701 };
702 (rows.len(), anchor.clone())
703 };
704 assert_eq!(table("| ^x | |"), (2, None));
705 assert_eq!(table("| ^x |"), (1, Some("x".to_string())));
706 }
707
708 #[test]
711 fn paragraph_trailing_anchor_line_binds_to_the_paragraph() {
712 let src = "## Summary\n\nProse line one.\n^summary\n";
713 let tree = parse_document(src);
714 let block = &tree.root.sections[0].blocks[0];
715 assert_eq!(block.anchor(), Some("summary"));
716 let BlockNode::Paragraph { text, .. } = block else {
717 panic!()
718 };
719 assert_eq!(text, "Prose line one.");
720 }
721
722 #[test]
723 fn standalone_anchor_paragraph_binds_to_the_preceding_block() {
724 let src = "## Decision\n\n| A | B |\n| - | - |\n| 1 | 2 |\n\n^components\n";
725 let tree = parse_document(src);
726 assert_eq!(tree.root.sections[0].blocks[0].anchor(), Some("components"));
727 }
728
729 #[test]
730 fn table_absorbed_anchor_row_is_lifted_out() {
731 let src = "## Decision\n\n| # | Component | Resolution |\n| - | --------- | ---------- |\n| 1 | Projection | mdast → DocTree |\n^components\n";
734 let tree = parse_document(src);
735 let BlockNode::Table { rows, anchor, .. } = &tree.root.sections[0].blocks[0] else {
736 panic!("expected a table");
737 };
738 assert_eq!(anchor.as_deref(), Some("components"));
739 assert_eq!(rows.len(), 1); }
741
742 #[test]
743 fn standalone_anchor_with_no_block_is_section_level() {
744 let src = "## Summary\n\n^orphan\n";
745 let tree = parse_document(src);
746 assert_eq!(tree.root.sections[0].anchors, vec!["orphan".to_string()]);
747 assert!(tree.root.sections[0].blocks.is_empty());
748 }
749
750 #[test]
753 fn skipped_level_attaches_to_nearest_ancestor_with_true_depth() {
754 let src = "## Decision\n\nProse.\n\n#### Components\n\nDeep.\n";
755 let tree = parse_document(src);
756 let decision = &tree.root.sections[0];
757 assert_eq!(decision.sections.len(), 1);
758 assert_eq!(decision.sections[0].depth, 4); assert_eq!(decision.sections[0].pos.line, 5);
760 }
761
762 #[test]
763 fn blockquote_content_is_not_hoisted() {
764 let src = "## Notes\n\n> | A |\n> | - |\n> | 1 |\n";
765 let tree = parse_document(src);
766 assert!(tree.root.sections[0].blocks.is_empty()); }
768
769 #[test]
770 fn multiline_paragraph_flattens_with_soft_breaks_as_newlines() {
771 let src = "## Summary\n\nline one\nline two\n";
772 let tree = parse_document(src);
773 let BlockNode::Paragraph { text, .. } = &tree.root.sections[0].blocks[0] else {
774 panic!()
775 };
776 assert_eq!(text, "line one\nline two");
777 }
778
779 #[test]
780 fn wikilinks_survive_projection_intact() {
781 let src = "## Refs\n\nsee [[D-0002]] and ![[Diagram]]\n";
782 let tree = parse_document(src);
783 let BlockNode::Paragraph { text, .. } = &tree.root.sections[0].blocks[0] else {
784 panic!()
785 };
786 assert_eq!(text, "see [[D-0002]] and ![[Diagram]]");
787 }
788
789 #[test]
790 fn section_and_block_ranges_are_body_bytes() {
791 let doc = "---\na: 1\n---\n# T\n\n## A\n\npara\n\n### A1\n\n- x\n- y\n\n## B\n\n```\n## not\n```\n";
792 let tree = parse_document(doc);
793 let body = tree.body.as_str();
794 let a = &tree.root.sections[0];
795 assert_eq!(
796 &body[a.range.clone()],
797 "## A\n\npara\n\n### A1\n\n- x\n- y\n\n"
798 );
799 assert_eq!(&body[a.blocks[0].range()], "para");
800 let a1 = &a.sections[0];
801 assert_eq!(&body[a1.range.clone()], "### A1\n\n- x\n- y\n\n");
802 assert_eq!(&body[a1.blocks[0].range()], "- x\n- y");
803 let b = &tree.root.sections[1];
804 assert_eq!(&body[b.range.clone()], "## B\n\n```\n## not\n```\n");
805 assert_eq!(&body[b.blocks[0].range()], "```\n## not\n```");
806 assert_eq!(tree.root.range, 0..body.len());
807 }
808
809 #[test]
810 fn ranges_count_a_leading_bom() {
811 let tree = parse_document("\u{feff}## A\n\npara\n");
812 let sec = &tree.root.sections[0];
813 assert_eq!(&tree.body[sec.range.clone()], "## A\n\npara\n");
814 assert_eq!(&tree.body[sec.blocks[0].range()], "para");
815 }
816
817 #[test]
818 fn block_by_id_finds_a_block_in_any_section() {
819 let tree = parse_document("## A\n\n### B\n\nfound ^here\n");
820 let block = tree.block_by_id("here").expect("bound block");
821 assert_eq!(&tree.body[block.range()], "found ^here");
822 assert!(tree.block_by_id("nowhere").is_none());
823 assert_eq!(&tree.body[tree.block_range_by_id("here").unwrap()], "found");
824 }
825
826 #[test]
827 fn heading_selection_on_the_tree_counts_the_title_h1() {
828 let tree = parse_document("# T\n\n## A\n\na\n\n## B\n");
829 let r = tree.section_by_heading_path(&["T", "A"]).unwrap();
830 assert_eq!(&tree.body[r], "## A\n\na");
831 let r = tree.section_by_selector("T#B").unwrap();
832 assert_eq!(&tree.body[r], "## B");
833 assert!(tree.section_by_heading_path(&["A", "B"]).is_none());
834 }
835}