Skip to main content

markdown_contract/
tree.rs

1//! The projected document substrate — `DocTree` / `SectionNode` / `BlockNode`.
2//!
3//! The tree every plane reads: flat headings become a nested section tree (the first
4//! H1 is the document title; each later H1, and every H2 before the first later H1,
5//! is a top-level section; other headings nest by depth under the nearest shallower
6//! heading),
7//! blocks are flattened (table cells → strings, list items → text, code verbatim,
8//! paragraph text flattened), each carrying a 1-based source position. `^block-id`
9//! anchors bind to blocks (`BlockNode` anchor) or sections (`SectionNode::anchors`).
10//!
11//! This module is data-only; the comrak projection that builds it lives in
12//! [`crate::parse`].
13
14use std::ops::Range;
15
16use crate::finding::SourcePos;
17
18/// The block kinds the structure plane's kind-gate distinguishes.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20#[allow(missing_docs)]
21pub enum BlockKind {
22    Table,
23    List,
24    /// A fenced or indented code block.
25    Code,
26    Paragraph,
27}
28
29impl std::fmt::Display for BlockKind {
30    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        f.write_str(match self {
32            BlockKind::Table => "table",
33            BlockKind::List => "list",
34            BlockKind::Code => "code",
35            BlockKind::Paragraph => "paragraph",
36        })
37    }
38}
39
40/// One list item: flattened text, task-list checkbox state (when present), source position.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct ListItem {
43    /// the item text
44    pub text: String,
45    /// `Some` for a task-list item: whether it is checked
46    pub checked: Option<bool>,
47    /// where the item starts
48    pub pos: SourcePos,
49}
50
51/// A flattened content block directly under a section heading.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum BlockNode {
54    /// A table.
55    Table {
56        /// the header cells
57        columns: Vec<String>,
58        /// the body rows, as cell text
59        rows: Vec<Vec<String>>,
60        /// body-row index → source position (the TS `rowPos(i)` closure, as data)
61        row_pos: Vec<SourcePos>,
62        /// the `^block-id` bound to the table, if any
63        anchor: Option<String>,
64        /// where the table starts
65        pos: SourcePos,
66        /// the bytes of the table in [`DocTree::body`]
67        range: Range<usize>,
68    },
69    /// A list.
70    List {
71        /// whether the list is ordered
72        ordered: bool,
73        /// the items
74        items: Vec<ListItem>,
75        /// the `^block-id` bound to the list, if any
76        anchor: Option<String>,
77        /// where the list starts
78        pos: SourcePos,
79        /// the bytes of the list in [`DocTree::body`]
80        range: Range<usize>,
81    },
82    /// A fenced or indented code block.
83    Code {
84        /// the info-string language, if any
85        lang: Option<String>,
86        /// the code, without the fences
87        value: String,
88        /// `true` for a fenced block, whose `value` starts on the line after `pos`; an
89        /// indented block's `value` starts on `pos.line`
90        fenced: bool,
91        /// the `^block-id` bound to the block, if any
92        anchor: Option<String>,
93        /// where the block starts
94        pos: SourcePos,
95        /// the bytes of the block in [`DocTree::body`]
96        range: Range<usize>,
97    },
98    /// A paragraph.
99    Paragraph {
100        /// the paragraph text
101        text: String,
102        /// the `^block-id` bound to the paragraph, if any
103        anchor: Option<String>,
104        /// where the paragraph starts
105        pos: SourcePos,
106        /// the bytes of the paragraph in [`DocTree::body`]
107        range: Range<usize>,
108    },
109}
110
111impl BlockNode {
112    /// The kind of block.
113    pub fn kind(&self) -> BlockKind {
114        match self {
115            BlockNode::Table { .. } => BlockKind::Table,
116            BlockNode::List { .. } => BlockKind::List,
117            BlockNode::Code { .. } => BlockKind::Code,
118            BlockNode::Paragraph { .. } => BlockKind::Paragraph,
119        }
120    }
121
122    /// Where the block starts.
123    pub fn pos(&self) -> SourcePos {
124        match self {
125            BlockNode::Table { pos, .. }
126            | BlockNode::List { pos, .. }
127            | BlockNode::Code { pos, .. }
128            | BlockNode::Paragraph { pos, .. } => *pos,
129        }
130    }
131
132    /// The block's bytes in [`DocTree::body`], from its first byte to its last
133    /// (an absorbed `^block-id` line included).
134    pub fn range(&self) -> Range<usize> {
135        match self {
136            BlockNode::Table { range, .. }
137            | BlockNode::List { range, .. }
138            | BlockNode::Code { range, .. }
139            | BlockNode::Paragraph { range, .. } => range.clone(),
140        }
141    }
142
143    /// The `^block-id` bound to the block, if any.
144    pub fn anchor(&self) -> Option<&str> {
145        match self {
146            BlockNode::Table { anchor, .. }
147            | BlockNode::List { anchor, .. }
148            | BlockNode::Code { anchor, .. }
149            | BlockNode::Paragraph { anchor, .. } => anchor.as_deref(),
150        }
151    }
152
153    pub(crate) fn anchor_mut(&mut self) -> &mut Option<String> {
154        match self {
155            BlockNode::Table { anchor, .. }
156            | BlockNode::List { anchor, .. }
157            | BlockNode::Code { anchor, .. }
158            | BlockNode::Paragraph { anchor, .. } => anchor,
159        }
160    }
161}
162
163/// A heading-delimited section: exact heading text, TRUE heading depth (a skipped level
164/// is preserved, not synthesized), heading position, nested subsections,
165/// heading-direct blocks (no hoisting), and section-level anchors.
166#[derive(Debug, Clone, PartialEq, Eq)]
167pub struct SectionNode {
168    /// heading text, trimmed (exact, case-sensitive)
169    pub name: String,
170    /// 1..6 (the synthetic root is depth 1)
171    pub depth: u8,
172    /// the heading's position
173    pub pos: SourcePos,
174    /// nested subsections
175    pub sections: Vec<SectionNode>,
176    /// blocks directly under the heading
177    pub blocks: Vec<BlockNode>,
178    /// section-level `^block-id`s (block-bound ids live on the block)
179    pub anchors: Vec<String>,
180    /// the section's bytes in [`DocTree::body`]: from the start of its heading
181    /// line to the start of the next heading of the same or a shallower depth,
182    /// or the end of the body. Subsections are inside it. The root's range is
183    /// the whole body.
184    pub range: Range<usize>,
185}
186
187impl SectionNode {
188    /// The block a content slot reads: the block bound to `anchor` when one is given,
189    /// else the section's first block.
190    pub(crate) fn block_for(&self, anchor: Option<&str>) -> Option<&BlockNode> {
191        match anchor {
192            Some(a) => self.blocks.iter().find(|b| b.anchor() == Some(a)),
193            None => self.blocks.first(),
194        }
195    }
196
197    pub(crate) fn new(name: impl Into<String>, depth: u8, pos: SourcePos) -> Self {
198        Self {
199            name: name.into(),
200            depth,
201            pos,
202            sections: Vec::new(),
203            blocks: Vec::new(),
204            anchors: Vec::new(),
205            range: 0..0,
206        }
207    }
208
209    /// The first block bound to `^id`, here or in any subsection, depth first.
210    pub fn block_by_id(&self, id: &str) -> Option<&BlockNode> {
211        self.blocks
212            .iter()
213            .find(|b| b.anchor() == Some(id))
214            .or_else(|| self.sections.iter().find_map(|s| s.block_by_id(id)))
215    }
216}
217
218/// The frontmatter block. `data` is the parsed YAML in the JSON value shape the TS
219/// `yaml` package's `toJS()` gives, or `None` when the YAML does not parse. `pos` is
220/// the opening `---` fence (line 1). The frontmatter plane finds the line of a key, and
221/// the error of YAML that does not parse, by reading `raw`.
222#[derive(Debug, Clone, PartialEq)]
223pub struct Frontmatter {
224    /// inter-fence YAML text, fences stripped
225    pub raw: String,
226    /// the parsed YAML, or `None` when it does not parse
227    pub data: Option<serde_json::Value>,
228    /// the opening `---` fence
229    pub pos: SourcePos,
230}
231
232/// The projected document: the frontmatter block (when present), the verbatim body
233/// after it, and the synthetic root whose `sections` are the top-level body sections and whose
234/// `name` is the leading-H1 title.
235#[derive(Debug, Clone, PartialEq)]
236pub struct DocTree {
237    /// the leading `---` YAML block, position-aware; `None` when absent
238    pub frontmatter: Option<Frontmatter>,
239    /// verbatim source body after the frontmatter block (the whole doc when none)
240    pub body: String,
241    /// synthetic root (depth 1); `root.sections` are the top-level body sections
242    pub root: SectionNode,
243}
244
245impl DocTree {
246    /// The section that a heading path names, as a byte range in
247    /// [`Self::body`], trimmed. Obsidian's rules, not the tree's: every
248    /// heading counts, the first H1 included, and the segments before the
249    /// last must be the nearest headings above it. See [`crate::select`].
250    pub fn section_by_heading_path(&self, path: &[&str]) -> Option<Range<usize>> {
251        crate::select::heading_path_range(&self.body, path)
252    }
253
254    /// The section a link fragment's heading selector (`Heading`,
255    /// `Parent#Child`, or a heading whose text holds `#`) names, as a byte
256    /// range in [`Self::body`]. See [`crate::select::heading_range`].
257    pub fn section_by_selector(&self, selector: &str) -> Option<Range<usize>> {
258        crate::select::heading_range(&self.body, selector)
259    }
260
261    /// The text a `^id` block marker labels, as a byte range in
262    /// [`Self::body`], marker excluded. See [`crate::select::block_range`].
263    pub fn block_range_by_id(&self, id: &str) -> Option<Range<usize>> {
264        crate::select::block_range(&self.body, id)
265    }
266
267    /// The projected block bound to `^id`, in any section, depth first.
268    pub fn block_by_id(&self, id: &str) -> Option<&BlockNode> {
269        self.root.block_by_id(id)
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn block_kind_displays_as_the_ts_kind_strings() {
279        assert_eq!(BlockKind::Table.to_string(), "table");
280        assert_eq!(BlockKind::List.to_string(), "list");
281        assert_eq!(BlockKind::Code.to_string(), "code");
282        assert_eq!(BlockKind::Paragraph.to_string(), "paragraph");
283    }
284
285    #[test]
286    fn block_node_exposes_kind_pos_and_anchor_uniformly() {
287        let b = BlockNode::Paragraph {
288            text: "hello".into(),
289            anchor: Some("summary".into()),
290            pos: SourcePos::at(3, 1),
291            range: 4..9,
292        };
293        assert_eq!(b.kind(), BlockKind::Paragraph);
294        assert_eq!(b.pos(), SourcePos::at(3, 1));
295        assert_eq!(b.anchor(), Some("summary"));
296        assert_eq!(b.range(), 4..9);
297    }
298}