Skip to main content

markdown_contract/
parse.rs

1//! Parsing — frontmatter split + markdown → [`DocTree`] (the projection).
2//!
3//! Mirrors the TS engine's `frontmatter.ts` + `projection.ts`:
4//!
5//! - [`split_frontmatter`] splits a leading `---` YAML block off the body **before**
6//!   markdown parsing, byte-exact: `raw` is the fences-stripped inter-fence text,
7//!   `body` is the verbatim tail after the closing fence's line terminator.
8//! - [`parse_document`] parses the body with comrak (GFM: tables, task lists,
9//!   strikethrough, autolinks, footnotes) and projects it into the section/block tree.
10//!   Every position is an **absolute document line** (1-based): the body is parsed
11//!   standalone and each sourcepos is shifted by the frontmatter's line count, so
12//!   findings line up with the TS engine, which parses the whole document at once.
13//!
14//! The invariants of the TS projection hold here too: fenced code is opaque, a
15//! skipped heading level attaches to the nearest ancestor with its TRUE depth preserved,
16//! and only root-level blocks become section blocks — nothing is hoisted out of
17//! blockquotes or list items.
18//!
19//! fs-free and wasm32-compatible: string in, tree out.
20
21use 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// ── Frontmatter split (mirrors `splitFrontmatter` / `bodyAfterFrontmatter`) ─────────
31
32/// The result of [`split_frontmatter`].
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct FrontmatterSplit {
35    /// inter-fence YAML text, fences stripped; `None` = no frontmatter
36    pub raw: Option<String>,
37    /// verbatim source after the closing fence's line terminator (whole doc when none)
38    pub body: String,
39}
40
41/// Is `line` (terminator already stripped) exactly a `---` fence, allowing trailing
42/// spaces/tabs? Matches micromark-extension-frontmatter: exactly three markers.
43fn 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
51/// Byte offset just past the opening fence's `\n`, iff the document opens with a fence
52/// line. A fence with no line terminator cannot open a frontmatter block.
53fn opening_fence_end(md: &str) -> Option<usize> {
54    let eol = md.find('\n')?;
55    is_fence(&md[..eol]).then_some(eol + 1)
56}
57
58/// Pure, byte-exact frontmatter/body split — no YAML parse, no projection. Same
59/// recognizer as the TS `splitFrontmatter`: the opening `---` must be the very first
60/// line (after a byte-order mark, which is skipped), the block must be closed by a
61/// `---` line (else the whole doc is body), and `body` begins after exactly one line
62/// terminator (CRLF or LF) past the closing fence.
63pub fn split_frontmatter(source: &str) -> FrontmatterSplit {
64    let whole = || FrontmatterSplit {
65        raw: None,
66        body: source.to_string(),
67    };
68    // micromark skips a leading U+FEFF, so a BOM does not hide the frontmatter.
69    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            // `raw` is the inter-fence text minus the line terminator before the fence.
81            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; // last line reached without a closing fence → unclosed
95        }
96        idx = line_term_end;
97    }
98    whole()
99}
100
101// ── parse_document — the public entry point ─────────────────────────────────────────
102
103/// Parse raw markdown (frontmatter + body) into the positioned projection. Positions in
104/// the returned tree are absolute document lines, matching the TS engine. The
105/// frontmatter's `data` mirrors the TS `yaml` package's `toJS()` value shape; it is
106/// `None` when the YAML does not parse.
107pub fn parse_document(source: &str) -> DocTree {
108    let split = split_frontmatter(source);
109    // `body` is a suffix of `source`; the lines before it shift every body sourcepos.
110    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    // comrak does not skip a BOM, so strip it before parsing the body. Byte
125    // ranges still count from the start of `split.body`, BOM included.
126    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
136// ── comrak projection ────────────────────────────────────────────────────────────────
137
138fn 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
148/// What the projection needs besides the node: the line shift for positions,
149/// the body's lines, and where each line starts, for byte ranges.
150struct Cx<'s> {
151    /// lines before the body, added to every sourcepos line
152    off: u32,
153    /// the body's lines (terminators stripped)
154    lines: Vec<&'s str>,
155    /// the byte offset of each line's start in the parsed text
156    starts: Vec<usize>,
157    /// bytes in front of the parsed text in `DocTree::body` (a skipped BOM)
158    base: usize,
159    /// the parsed text's length
160    len: usize,
161}
162
163impl Cx<'_> {
164    /// The byte offset of a 1-based (line, column) point, with `column` 0
165    /// meaning the line's start.
166    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    /// A node's bytes in `DocTree::body`: from its first byte to one past its
176    /// last.
177    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
202/// A node's start point as a `SourcePos` (1-based line + col), shifted to document lines.
203fn 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
208/// Flatten an inline subtree to plain text, matching the TS `flattenInline`: text and
209/// inline code contribute their value, a soft break stays a newline (remark keeps it
210/// inside the text node), a hard break becomes a space, raw inline HTML passes through,
211/// an image contributes nothing (mdast images carry `alt` out-of-band), and every other
212/// container contributes its children's text.
213fn 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(_) => {} // remark flattens an image to nothing
222            _ => 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
233/// A list item's text: its paragraphs' flattened prose, joined by a space, trimmed.
234fn 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        // mdast's `value` drops the final line break that comrak's `literal` keeps.
270        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
300/// Project a table. The header row flattens to `columns`; body rows to `rows`. GFM
301/// absorbs an anchor line directly under a table as an extra row. When that last row
302/// is a single cell holding only a `^block-id`, it is lifted out as the table's
303/// anchor, as the TS projection does. comrak pads short rows with empty cells, so the
304/// cell count is read from the row's source line (`lines` are the body's lines).
305fn 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
347/// The `^id` of a table row line that is one cell holding only that anchor
348/// (`^id`, `| ^id`, `| ^id |`).
349fn 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
359/// Project one root-level node to a block, or `None` when it carries no block (headings
360/// are handled by the section walker; blockquotes etc. are not hoisted).
361fn 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
372/// Strip a trailing `^id` off a paragraph or the first list item carrying one, binding it
373/// as the block's anchor (Obsidian binds a block id to the block it sits in).
374fn 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
395/// Bind a standalone `^id`: to the section's trailing anchor-less block, else to the
396/// section itself.
397fn 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
404/// Project one non-heading root node and attach its block to the current section.
405fn 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; // blockquote / nested content yields no section-level block
408    };
409
410    // A standalone `^block-id` paragraph binds to the preceding block, else the section,
411    // and is itself not a content block.
412    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
424/// Close every open section at `depth` or deeper, attaching each popped section to its
425/// parent. The root (the stack's first entry) is never popped.
426fn 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
434/// Walk the flat root children, building the nested section tree: the first H1 is the
435/// document title (`root.name`), a later H1 is a section of depth 1, and each other
436/// heading nests under the nearest open section of smaller depth, keeping its true
437/// depth.
438fn 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                // The leading H1 is the document title — captured, not a body section.
452                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); // close every open section back into the root
467    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    // ── Contract first: a small document and its exact projection ───────────────────
478
479    #[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    // ── Frontmatter split (mirrors the TS splitFrontmatter cases) ───────────────────
512
513    #[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        // trailing whitespace on a fence line is tolerated (micromark behavior)
533        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    // ── Absolute line accounting with frontmatter (parity with remark) ──────────────
562
563    // The 08a fixture shape: frontmatter lines 1-5, H1 line 7, ## Summary line 9 —
564    // the TS engine reports absolute document lines; so do we.
565    #[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    // ── Blocks: table / list / code line accounting (parity with remark) ────────────
583
584    #[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); // body row, delimiter line skipped — remark parity
610        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); // the ## inside the fence is verbatim
640        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        // As mdast does, the value drops the newline before the closing fence.
652        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    // The first H1 is the title. A second H1 is a section of its own, and the H2s after
673    // it are its children, as in the TS projection.
674    #[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    // A leading byte-order mark is not content: frontmatter still opens on line 1.
683    #[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    // Only a row that is a lone `^id` in the source is lifted as the anchor. comrak pads
693    // `| ^x |` and `| ^x | |` to the same cells, so only the source line tells them apart.
694    #[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    // ── Anchors ──────────────────────────────────────────────────────────────────────
709
710    #[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        // No blank line: GFM absorbs the `^id` line as a table row; the projection
732        // lifts it back out as the table's anchor (15a fixture shape).
733        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); // the anchor row is not a data row
740    }
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    // ── Nesting: depth preserved, nearest-ancestor attach, no hoisting ──────────────
751
752    #[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); // TRUE depth, no synthesized H3
759        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()); // blockquote content yields no block
767    }
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}