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}