Skip to main content

mermaid_text/parser/
mindmap.rs

1//! Parser for Mermaid `mindmap` diagrams.
2//!
3//! Accepted syntax:
4//!
5//! ```text
6//! mindmap
7//!   root((mindmap))
8//!     Origins
9//!       Long history
10//!       ::icon(fa fa-book)
11//!       Popularisation
12//!         British popular psychology author Tony Buzan
13//!     Research
14//!       On effectiveness<br/>and features
15//!     Tools
16//!       Pen and paper
17//!       Mermaid
18//! ```
19//!
20//! Rules:
21//! - `mindmap` keyword is required as the first non-blank, non-comment line.
22//! - Each subsequent non-blank, non-comment, non-icon line is a node.
23//! - **Indentation** determines the tree structure. A node indented more than the
24//!   previous node becomes its child; equal or less indentation makes it a
25//!   sibling or an ancestor's sibling. Tabs are normalised to 4 spaces before
26//!   measuring indent.
27//! - The first node after the `mindmap` keyword is the **root**.
28//! - Node shape brackets are stripped to inner text (Phase 1 limitation):
29//!   `((text))` → `text`, `(text)` → `text`, `{{text}}` → `text`,
30//!   `))text((` → `text`, `)text(` → `text`.
31//! - `::icon(...)` lines are silently ignored.
32//! - `%%` comment lines and blank lines are silently skipped.
33//! - `accTitle` / `accDescr` accessibility metadata lines are silently ignored.
34//!
35//! # Examples
36//!
37//! ```
38//! use mermaid_text::parser::mindmap::parse;
39//!
40//! let diag = parse("mindmap\n  root\n    child").unwrap();
41//! assert_eq!(diag.root.text, "root");
42//! assert_eq!(diag.root.children[0].text, "child");
43//! ```
44
45use crate::Error;
46use crate::mindmap::{Mindmap, MindmapNode};
47use crate::parser::common::strip_inline_comment;
48
49/// Parse a `mindmap` source string into a [`Mindmap`].
50///
51/// # Errors
52///
53/// - [`Error::ParseError`] — missing `mindmap` header, or the source contains
54///   no root node after the header.
55pub fn parse(src: &str) -> Result<Mindmap, Error> {
56    let mut header_seen = false;
57    // Collect (indent, text) pairs for all content lines.
58    let mut node_lines: Vec<(usize, String)> = Vec::new();
59
60    for raw in src.lines() {
61        let stripped = strip_inline_comment(raw);
62
63        if !header_seen {
64            let trimmed = stripped.trim();
65            if trimmed.is_empty() || trimmed.starts_with("%%") {
66                continue;
67            }
68            if !trimmed.eq_ignore_ascii_case("mindmap") {
69                return Err(Error::ParseError(format!(
70                    "expected `mindmap` header, got {trimmed:?}"
71                )));
72            }
73            header_seen = true;
74            continue;
75        }
76
77        let trimmed = stripped.trim();
78
79        // Skip blank and comment lines.
80        if trimmed.is_empty() || trimmed.starts_with("%%") {
81            continue;
82        }
83
84        // Silently skip accessibility metadata.
85        if trimmed.starts_with("accTitle") || trimmed.starts_with("accDescr") {
86            continue;
87        }
88
89        // Silently skip icon directives (e.g. `::icon(fa fa-book)`).
90        if trimmed.starts_with("::icon(") {
91            continue;
92        }
93
94        // Measure indent: tabs are 4 spaces each.
95        let indent = measure_indent(raw);
96
97        // Strip any node-shape bracket syntax to get the display text.
98        let text = strip_node_shape(trimmed);
99
100        node_lines.push((indent, text));
101    }
102
103    if !header_seen {
104        return Err(Error::ParseError(
105            "missing `mindmap` header line".to_string(),
106        ));
107    }
108
109    if node_lines.is_empty() {
110        return Err(Error::ParseError(
111            "mindmap has no nodes (at least a root node is required)".to_string(),
112        ));
113    }
114
115    let root = build_tree(&node_lines);
116    Ok(Mindmap { root })
117}
118
119/// Measure the indentation of a raw source line.
120///
121/// Tabs are treated as 4 spaces each so that a single tab aligns with
122/// four spaces of indentation (the most common convention in Mermaid
123/// examples). The count stops at the first non-whitespace character.
124fn measure_indent(line: &str) -> usize {
125    let mut count = 0;
126    for ch in line.chars() {
127        match ch {
128            ' ' => count += 1,
129            '\t' => count += 4,
130            _ => break,
131        }
132    }
133    count
134}
135
136/// Strip node-shape bracket syntax and return just the inner text.
137///
138/// Phase 1: all 6 shape variants are normalised to plain text; the shape
139/// itself is not recorded. The stripping is greedy — we try the most
140/// specific patterns first (e.g. `((text))` before `(text)`).
141fn strip_node_shape(s: &str) -> String {
142    // Remove an optional leading id prefix before the bracket (e.g. `id[text]`
143    // or `id((text))`). In Phase 1 we ignore the id and keep only the inner
144    // text. The id is defined as the contiguous non-bracket prefix.
145    let body = if let Some(bracket_start) = s.find(['[', '(', '{', ')']) {
146        // Check whether the part before the bracket is plausibly an id (no
147        // whitespace). If it contains whitespace the whole token is plain text.
148        let prefix = &s[..bracket_start];
149        if prefix.chars().all(|c: char| !c.is_whitespace()) && !prefix.is_empty() {
150            &s[bracket_start..]
151        } else {
152            s
153        }
154    } else {
155        s
156    };
157
158    // Double-parenthesis circle: `((text))`
159    if let Some(inner) = body.strip_prefix("((").and_then(|t| t.strip_suffix("))")) {
160        return inner.trim().to_string();
161    }
162    // Bang shape: `))text((`
163    if let Some(inner) = body.strip_prefix("))").and_then(|t| t.strip_suffix("((")) {
164        return inner.trim().to_string();
165    }
166    // Cloud: `)text(`
167    if let Some(inner) = body.strip_prefix(')').and_then(|t| t.strip_suffix('(')) {
168        return inner.trim().to_string();
169    }
170    // Single-parenthesis rounded: `(text)`
171    if let Some(inner) = body.strip_prefix('(').and_then(|t| t.strip_suffix(')')) {
172        return inner.trim().to_string();
173    }
174    // Double-brace hexagon: `{{text}}`
175    if let Some(inner) = body.strip_prefix("{{").and_then(|t| t.strip_suffix("}}")) {
176        return inner.trim().to_string();
177    }
178    // Square bracket: `[text]`
179    if let Some(inner) = body.strip_prefix('[').and_then(|t| t.strip_suffix(']')) {
180        return inner.trim().to_string();
181    }
182
183    // No shape brackets — use the body directly (which may be the original `s`
184    // if the prefix check didn't find a bracket-start we could use).
185    body.to_string()
186}
187
188/// Build a tree from a flat list of `(indent, text)` pairs.
189///
190/// The algorithm maintains a stack of `(indent, index_into_arena)` entries.
191/// The arena is a flat `Vec<MindmapNode>` where children are accumulated and
192/// then moved into their parent when the parent is popped off the stack.
193///
194/// The indentation-stack invariant:
195/// - The stack always ends with the deepest node that is still a potential
196///   parent of the next line.
197/// - When a new line's indent is ≤ any stack entry's indent, all deeper entries
198///   are popped first, transplanting children upward as they go.
199///
200/// This is the natural tree-building algorithm for indent-delimited formats
201/// (Python, YAML, Mermaid mindmap). We track indices into a flat Vec rather
202/// than building nested structures directly because Rust's ownership rules make
203/// it awkward to hold multiple `&mut MindmapNode` references simultaneously.
204fn build_tree(lines: &[(usize, String)]) -> MindmapNode {
205    // Flat arena: each slot is (node, children_indices).
206    let mut nodes: Vec<MindmapNode> = Vec::with_capacity(lines.len());
207
208    // Stack entries: (indent_of_node, arena_index).
209    let mut stack: Vec<(usize, usize)> = Vec::new();
210
211    // `children_map[parent_idx]` holds arena indices of its children in order.
212    let mut children_map: Vec<Vec<usize>> = Vec::with_capacity(lines.len());
213
214    for (indent, text) in lines {
215        let new_idx = nodes.len();
216        nodes.push(MindmapNode::new(text));
217        children_map.push(Vec::new());
218
219        // Pop any stack entries whose indent is >= the new node's indent;
220        // those are no longer potential parents.
221        while let Some(&(stack_indent, _)) = stack.last() {
222            if stack_indent >= *indent {
223                stack.pop();
224            } else {
225                break;
226            }
227        }
228
229        // The top of the stack (if any) is now the parent.
230        if let Some(&(_, parent_idx)) = stack.last() {
231            children_map[parent_idx].push(new_idx);
232        }
233
234        stack.push((*indent, new_idx));
235    }
236
237    // Reconstruct the tree bottom-up: work backwards through the arena,
238    // moving children into each node.
239    for parent_idx in (0..nodes.len()).rev() {
240        let child_indices: Vec<usize> = children_map[parent_idx].clone();
241        // We need to pull children out of `nodes`. Because we go in reverse
242        // parent order we must collect first, then move. Use a temporary
243        // placeholder to avoid indexing aliasing issues.
244        let children: Vec<MindmapNode> = child_indices
245            .into_iter()
246            .map(|ci| {
247                // Replace the child slot with a placeholder; we'll use the
248                // real node returned here.
249                std::mem::replace(&mut nodes[ci], MindmapNode::new(""))
250            })
251            .collect();
252        nodes[parent_idx].children = children;
253    }
254
255    // The root is always the first node (index 0).
256    std::mem::replace(&mut nodes[0], MindmapNode::new(""))
257}
258
259// ---------------------------------------------------------------------------
260// Tests
261// ---------------------------------------------------------------------------
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    #[test]
268    fn parses_root_only() {
269        let diag = parse("mindmap\n  root").unwrap();
270        assert_eq!(diag.root.text, "root");
271        assert!(diag.root.children.is_empty());
272    }
273
274    #[test]
275    fn parses_one_level_children() {
276        let diag = parse("mindmap\n  root\n    A\n    B\n    C").unwrap();
277        assert_eq!(diag.root.text, "root");
278        assert_eq!(diag.root.children.len(), 3);
279        assert_eq!(diag.root.children[0].text, "A");
280        assert_eq!(diag.root.children[1].text, "B");
281        assert_eq!(diag.root.children[2].text, "C");
282    }
283
284    #[test]
285    fn parses_nested_two_levels() {
286        let src = "mindmap\n  root\n    Parent\n      Child1\n      Child2\n    Sibling";
287        let diag = parse(src).unwrap();
288        assert_eq!(diag.root.children.len(), 2);
289        let parent = &diag.root.children[0];
290        assert_eq!(parent.text, "Parent");
291        assert_eq!(parent.children.len(), 2);
292        assert_eq!(parent.children[0].text, "Child1");
293        assert_eq!(parent.children[1].text, "Child2");
294        let sibling = &diag.root.children[1];
295        assert_eq!(sibling.text, "Sibling");
296        assert!(sibling.children.is_empty());
297    }
298
299    #[test]
300    fn parses_node_shapes_strips_brackets() {
301        let src = "mindmap\n  root((circle))\n    rounded(text)\n    hex{{hexa}}\n    plain text";
302        let diag = parse(src).unwrap();
303        assert_eq!(diag.root.text, "circle");
304        assert_eq!(diag.root.children[0].text, "text");
305        assert_eq!(diag.root.children[1].text, "hexa");
306        assert_eq!(diag.root.children[2].text, "plain text");
307    }
308
309    #[test]
310    fn ignores_icon_directive() {
311        let src = "mindmap\n  root\n    Origins\n      ::icon(fa fa-book)\n      Long history";
312        let diag = parse(src).unwrap();
313        let origins = &diag.root.children[0];
314        assert_eq!(origins.text, "Origins");
315        // The icon line must be gone; only "Long history" remains as child.
316        assert_eq!(origins.children.len(), 1);
317        assert_eq!(origins.children[0].text, "Long history");
318    }
319
320    #[test]
321    fn comment_lines_skipped() {
322        let src = "%% preamble\nmindmap\n  %% inner comment\n  root\n    child %% trailing";
323        let diag = parse(src).unwrap();
324        assert_eq!(diag.root.text, "root");
325        assert_eq!(diag.root.children.len(), 1);
326        assert_eq!(diag.root.children[0].text, "child");
327    }
328
329    #[test]
330    fn tabs_count_as_four_spaces() {
331        // One tab == 4 spaces, so a tab-indented child is at depth 4.
332        let src = "mindmap\n\troot\n\t\tchild";
333        let diag = parse(src).unwrap();
334        assert_eq!(diag.root.text, "root");
335        assert_eq!(diag.root.children.len(), 1);
336        assert_eq!(diag.root.children[0].text, "child");
337    }
338
339    #[test]
340    fn dedent_attaches_sibling_to_correct_parent() {
341        // A -> A1 -> A1a, then dedent back to A's level for B.
342        let src = "mindmap\n  root\n    A\n      A1\n        A1a\n    B";
343        let diag = parse(src).unwrap();
344        assert_eq!(diag.root.children.len(), 2);
345        let a = &diag.root.children[0];
346        assert_eq!(a.text, "A");
347        assert_eq!(a.children.len(), 1);
348        assert_eq!(a.children[0].text, "A1");
349        assert_eq!(a.children[0].children[0].text, "A1a");
350        let b = &diag.root.children[1];
351        assert_eq!(b.text, "B");
352        assert!(b.children.is_empty());
353    }
354
355    #[test]
356    fn missing_header_returns_error() {
357        let err = parse("root\n  child").unwrap_err();
358        assert!(
359            err.to_string().contains("mindmap"),
360            "unexpected error: {err}"
361        );
362    }
363
364    #[test]
365    fn accessibility_metadata_is_silently_ignored() {
366        let src = "mindmap\n  accTitle: My title\n  accDescr: A description\n  root\n    child";
367        let diag = parse(src).unwrap();
368        // The accTitle/accDescr lines must not appear as nodes.
369        assert_eq!(diag.root.text, "root");
370        assert_eq!(diag.root.children.len(), 1);
371        assert_eq!(diag.root.children[0].text, "child");
372    }
373}