use crate::Error;
use crate::mindmap::{Mindmap, MindmapNode};
use crate::parser::common::strip_inline_comment;
pub fn parse(src: &str) -> Result<Mindmap, Error> {
let mut header_seen = false;
let mut node_lines: Vec<(usize, String)> = Vec::new();
for raw in src.lines() {
let stripped = strip_inline_comment(raw);
if !header_seen {
let trimmed = stripped.trim();
if trimmed.is_empty() || trimmed.starts_with("%%") {
continue;
}
if !trimmed.eq_ignore_ascii_case("mindmap") {
return Err(Error::ParseError(format!(
"expected `mindmap` header, got {trimmed:?}"
)));
}
header_seen = true;
continue;
}
let trimmed = stripped.trim();
if trimmed.is_empty() || trimmed.starts_with("%%") {
continue;
}
if trimmed.starts_with("accTitle") || trimmed.starts_with("accDescr") {
continue;
}
if trimmed.starts_with("::icon(") {
continue;
}
let indent = measure_indent(raw);
let text = strip_node_shape(trimmed);
node_lines.push((indent, text));
}
if !header_seen {
return Err(Error::ParseError(
"missing `mindmap` header line".to_string(),
));
}
if node_lines.is_empty() {
return Err(Error::ParseError(
"mindmap has no nodes (at least a root node is required)".to_string(),
));
}
let root = build_tree(&node_lines);
Ok(Mindmap { root })
}
fn measure_indent(line: &str) -> usize {
let mut count = 0;
for ch in line.chars() {
match ch {
' ' => count += 1,
'\t' => count += 4,
_ => break,
}
}
count
}
fn strip_node_shape(s: &str) -> String {
let body = if let Some(bracket_start) = s.find(['[', '(', '{', ')']) {
let prefix = &s[..bracket_start];
if prefix.chars().all(|c: char| !c.is_whitespace()) && !prefix.is_empty() {
&s[bracket_start..]
} else {
s
}
} else {
s
};
if let Some(inner) = body.strip_prefix("((").and_then(|t| t.strip_suffix("))")) {
return inner.trim().to_string();
}
if let Some(inner) = body.strip_prefix("))").and_then(|t| t.strip_suffix("((")) {
return inner.trim().to_string();
}
if let Some(inner) = body.strip_prefix(')').and_then(|t| t.strip_suffix('(')) {
return inner.trim().to_string();
}
if let Some(inner) = body.strip_prefix('(').and_then(|t| t.strip_suffix(')')) {
return inner.trim().to_string();
}
if let Some(inner) = body.strip_prefix("{{").and_then(|t| t.strip_suffix("}}")) {
return inner.trim().to_string();
}
if let Some(inner) = body.strip_prefix('[').and_then(|t| t.strip_suffix(']')) {
return inner.trim().to_string();
}
body.to_string()
}
fn build_tree(lines: &[(usize, String)]) -> MindmapNode {
let mut nodes: Vec<MindmapNode> = Vec::with_capacity(lines.len());
let mut stack: Vec<(usize, usize)> = Vec::new();
let mut children_map: Vec<Vec<usize>> = Vec::with_capacity(lines.len());
for (indent, text) in lines {
let new_idx = nodes.len();
nodes.push(MindmapNode::new(text));
children_map.push(Vec::new());
while let Some(&(stack_indent, _)) = stack.last() {
if stack_indent >= *indent {
stack.pop();
} else {
break;
}
}
if let Some(&(_, parent_idx)) = stack.last() {
children_map[parent_idx].push(new_idx);
}
stack.push((*indent, new_idx));
}
for parent_idx in (0..nodes.len()).rev() {
let child_indices: Vec<usize> = children_map[parent_idx].clone();
let children: Vec<MindmapNode> = child_indices
.into_iter()
.map(|ci| {
std::mem::replace(&mut nodes[ci], MindmapNode::new(""))
})
.collect();
nodes[parent_idx].children = children;
}
std::mem::replace(&mut nodes[0], MindmapNode::new(""))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_root_only() {
let diag = parse("mindmap\n root").unwrap();
assert_eq!(diag.root.text, "root");
assert!(diag.root.children.is_empty());
}
#[test]
fn parses_one_level_children() {
let diag = parse("mindmap\n root\n A\n B\n C").unwrap();
assert_eq!(diag.root.text, "root");
assert_eq!(diag.root.children.len(), 3);
assert_eq!(diag.root.children[0].text, "A");
assert_eq!(diag.root.children[1].text, "B");
assert_eq!(diag.root.children[2].text, "C");
}
#[test]
fn parses_nested_two_levels() {
let src = "mindmap\n root\n Parent\n Child1\n Child2\n Sibling";
let diag = parse(src).unwrap();
assert_eq!(diag.root.children.len(), 2);
let parent = &diag.root.children[0];
assert_eq!(parent.text, "Parent");
assert_eq!(parent.children.len(), 2);
assert_eq!(parent.children[0].text, "Child1");
assert_eq!(parent.children[1].text, "Child2");
let sibling = &diag.root.children[1];
assert_eq!(sibling.text, "Sibling");
assert!(sibling.children.is_empty());
}
#[test]
fn parses_node_shapes_strips_brackets() {
let src = "mindmap\n root((circle))\n rounded(text)\n hex{{hexa}}\n plain text";
let diag = parse(src).unwrap();
assert_eq!(diag.root.text, "circle");
assert_eq!(diag.root.children[0].text, "text");
assert_eq!(diag.root.children[1].text, "hexa");
assert_eq!(diag.root.children[2].text, "plain text");
}
#[test]
fn ignores_icon_directive() {
let src = "mindmap\n root\n Origins\n ::icon(fa fa-book)\n Long history";
let diag = parse(src).unwrap();
let origins = &diag.root.children[0];
assert_eq!(origins.text, "Origins");
assert_eq!(origins.children.len(), 1);
assert_eq!(origins.children[0].text, "Long history");
}
#[test]
fn comment_lines_skipped() {
let src = "%% preamble\nmindmap\n %% inner comment\n root\n child %% trailing";
let diag = parse(src).unwrap();
assert_eq!(diag.root.text, "root");
assert_eq!(diag.root.children.len(), 1);
assert_eq!(diag.root.children[0].text, "child");
}
#[test]
fn tabs_count_as_four_spaces() {
let src = "mindmap\n\troot\n\t\tchild";
let diag = parse(src).unwrap();
assert_eq!(diag.root.text, "root");
assert_eq!(diag.root.children.len(), 1);
assert_eq!(diag.root.children[0].text, "child");
}
#[test]
fn dedent_attaches_sibling_to_correct_parent() {
let src = "mindmap\n root\n A\n A1\n A1a\n B";
let diag = parse(src).unwrap();
assert_eq!(diag.root.children.len(), 2);
let a = &diag.root.children[0];
assert_eq!(a.text, "A");
assert_eq!(a.children.len(), 1);
assert_eq!(a.children[0].text, "A1");
assert_eq!(a.children[0].children[0].text, "A1a");
let b = &diag.root.children[1];
assert_eq!(b.text, "B");
assert!(b.children.is_empty());
}
#[test]
fn missing_header_returns_error() {
let err = parse("root\n child").unwrap_err();
assert!(
err.to_string().contains("mindmap"),
"unexpected error: {err}"
);
}
#[test]
fn accessibility_metadata_is_silently_ignored() {
let src = "mindmap\n accTitle: My title\n accDescr: A description\n root\n child";
let diag = parse(src).unwrap();
assert_eq!(diag.root.text, "root");
assert_eq!(diag.root.children.len(), 1);
assert_eq!(diag.root.children[0].text, "child");
}
}