#![allow(dead_code)]
use crate::preview::mermaid::chart::{find_header, first_word, ParseError, Source};
pub const KEYWORD: &str = "mindmap";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum NodeShape {
#[default]
NoBorder,
RoundedRect,
Rect,
Circle,
Cloud,
Bang,
Hexagon,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Node {
pub id: String,
pub label: String,
pub shape: NodeShape,
pub level: isize,
pub parent: Option<usize>,
pub icon: Option<String>,
pub classes: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Mindmap {
pub nodes: Vec<Node>,
}
const DIALECT: Dialect = Dialect {
kind: KEYWORD,
single_root: true,
at_ends_the_id: false,
};
pub fn is_mindmap(src: &str) -> bool {
find_header(src, &[KEYWORD]).is_some()
}
pub fn parse(src: &str) -> Result<Mindmap, ParseError> {
let Some(source) = find_header(src, &[KEYWORD]) else {
return Err(ParseError::NotThisChart {
expected: KEYWORD,
header: first_word(src),
});
};
let mut map = Mindmap::default();
read_tree(&mut map, &source, &DIALECT, |_, _| false)?;
if map.nodes.is_empty() {
return Err(ParseError::NoData {
kind: KEYWORD,
wanted: "node",
});
}
Ok(map)
}
pub(crate) struct Dialect {
pub kind: &'static str,
pub single_root: bool,
pub at_ends_the_id: bool,
}
pub(crate) fn read_tree(
map: &mut Mindmap,
source: &Source,
dialect: &Dialect,
mut extra: impl FnMut(&mut Node, &str) -> bool,
) -> Result<(), ParseError> {
let kind = dialect.kind;
let Source {
lines,
header_index,
header_rest,
..
} = source;
let mut baseline: Option<usize> = None;
let mut auto = 0usize;
let mut raw: Vec<(usize, &str)> = Vec::new();
if !header_rest.is_empty() {
raw.push((header_index + 1, header_rest.as_str()));
}
for (i, line) in lines.iter().enumerate().skip(header_index + 1) {
raw.push((i + 1, line.as_str()));
}
let rows = join_quoted_rows(&raw);
for (number, line) in rows {
let line = line.as_str();
if line.trim_start().starts_with("%%") || line.trim().is_empty() {
continue;
}
let indent = line.len() - line.trim_start().len();
let body = line.trim_start();
if let Some(rest) = body.strip_prefix("::icon(") {
let icon = rest.split(')').next().unwrap_or("").trim().to_string();
if let Some(node) = map.nodes.last_mut() {
node.icon = Some(icon);
}
continue;
}
if let Some(rest) = body.strip_prefix(":::") {
if let Some(node) = map.nodes.last_mut() {
node.classes
.extend(rest.split_whitespace().map(str::to_string));
}
continue;
}
let (mut node, tail) = read_node(body, number, dialect, &mut auto)?;
let tail = tail.trim();
if !tail.is_empty() && !extra(&mut node, tail) {
return Err(ParseError::Unexpected {
kind,
line: number,
text: tail.to_string(),
});
}
let level = match baseline {
None => {
baseline = Some(indent);
0
}
Some(base) => indent as isize - base as isize,
};
node.level = level;
node.parent = map.nodes.iter().rposition(|n| n.level < level);
if dialect.single_root && node.parent.is_none() && !map.nodes.is_empty() {
return Err(ParseError::Invalid {
line: number,
message: format!(
"{kind} can have only one root, and {:?} is a second",
node.label
),
});
}
map.nodes.push(node);
}
Ok(())
}
fn join_quoted_rows(raw: &[(usize, &str)]) -> Vec<(usize, String)> {
let mut out: Vec<(usize, String)> = Vec::new();
let mut i = 0usize;
while i < raw.len() {
let (number, first) = raw[i];
let mut text = first.to_string();
i += 1;
while let Some(open) = open_quote(&text) {
let Some((_, next)) = raw.get(i) else { break };
text.push('\n');
text.push_str(next);
i += 1;
let _ = open;
}
out.push((number, text));
}
out
}
fn open_quote(row: &str) -> Option<bool> {
let b = row.as_bytes();
let mut i = 0usize;
while i < b.len() {
if b[i] == b'"' {
let markdown = b.get(i + 1) == Some(&b'`');
let (needle, skip) = if markdown { ("`\"", 2) } else { ("\"", 1) };
match row[i + skip..].find(needle) {
Some(end) => i = i + skip + end + needle.len(),
None => return Some(markdown),
}
} else {
i += 1;
}
}
None
}
fn read_node<'a>(
body: &'a str,
number: usize,
dialect: &Dialect,
auto: &mut usize,
) -> Result<(Node, &'a str), ParseError> {
let kind = dialect.kind;
let id_end = body
.find(|c| matches!(c, '(' | '[' | ')' | '{' | '}') || (dialect.at_ends_the_id && c == '@'))
.unwrap_or(body.len());
let id = body[..id_end].trim().to_string();
let rest = &body[id_end..];
let Some((open, label, close, after)) = read_bracketed(rest) else {
if id.is_empty() {
return Err(ParseError::Unexpected {
kind,
line: number,
text: body.trim().to_string(),
});
}
return Ok((
Node {
id: id.clone(),
label: id,
shape: NodeShape::NoBorder,
level: 0,
parent: None,
icon: None,
classes: Vec::new(),
},
rest,
));
};
let shape = shape_of(open, close);
let id = if id.is_empty() {
*auto += 1;
label.clone()
} else {
id
};
Ok((
Node {
id,
label,
shape,
level: 0,
parent: None,
icon: None,
classes: Vec::new(),
},
after,
))
}
fn read_bracketed(s: &str) -> Option<(&str, String, &str, &str)> {
const OPENERS: &[&str] = &["))", "((", "{{", "(-", "-)", "(", "[", ")"];
let open = OPENERS.iter().find(|o| s.starts_with(**o))?;
let mut rest = &s[open.len()..];
let mut text = String::new();
if let Some(after) = rest.strip_prefix("\"`") {
let end = after.find("`\"")?;
text.push_str(&after[..end]);
rest = &after[end + 2..];
} else if let Some(after) = rest.strip_prefix('"') {
let end = after.find('"')?;
text.push_str(&after[..end]);
rest = &after[end + 1..];
} else {
let end = rest.find([')', ']', '(', '}']).unwrap_or(rest.len());
text.push_str(&rest[..end]);
rest = &rest[end..];
}
const CLOSERS: &[&str] = &["))", "((", "}}", "(-", "-)", ")", "]", "("];
let close = CLOSERS.iter().find(|c| rest.starts_with(**c))?;
Some((open, text.trim().to_string(), close, &rest[close.len()..]))
}
fn shape_of(open: &str, close: &str) -> NodeShape {
match open {
"[" => NodeShape::Rect,
"(" => {
if close == ")" {
NodeShape::RoundedRect
} else {
NodeShape::Cloud
}
}
"((" => NodeShape::Circle,
")" => NodeShape::Cloud,
"))" => NodeShape::Bang,
"{{" => NodeShape::Hexagon,
_ => NodeShape::NoBorder,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ok(src: &str) -> Mindmap {
parse(src).unwrap_or_else(|e| panic!("{e}\n{src}"))
}
#[test]
fn the_documented_example_builds_the_documented_tree() {
let m = ok("mindmap\n root((mindmap))\n Origins\n Long history\n Popularisation\n British popular psychology author Tony Buzan\n Research\n Tools\n");
assert_eq!(m.nodes[0].label, "mindmap");
assert_eq!(m.nodes[0].shape, NodeShape::Circle);
assert_eq!(m.nodes[0].parent, None);
assert_eq!(m.nodes[1].label, "Origins");
assert_eq!(m.nodes[1].parent, Some(0));
assert_eq!(m.nodes[3].label, "Popularisation");
assert_eq!(m.nodes[3].parent, Some(1));
assert_eq!(m.nodes[4].parent, Some(3));
}
#[test]
fn every_documented_shape_is_read_as_that_shape() {
for (src, want) in [
("mindmap\n id[I am a square]", NodeShape::Rect),
(
"mindmap\n id(I am a rounded square)",
NodeShape::RoundedRect,
),
("mindmap\n id((I am a circle))", NodeShape::Circle),
("mindmap\n id))I am a bang((", NodeShape::Bang),
("mindmap\n id)I am a cloud(", NodeShape::Cloud),
("mindmap\n id{{I am a hexagon}}", NodeShape::Hexagon),
("mindmap\n I am the default shape", NodeShape::NoBorder),
("mindmap\n id(also a cloud(", NodeShape::Cloud),
] {
assert_eq!(ok(src).nodes[0].shape, want, "{src}");
}
}
#[test]
fn indentation_does_not_have_to_be_consistent() {
let m = ok("mindmap\nRoot\n A\n B\n C\n");
assert_eq!(m.nodes[1].label, "A");
assert_eq!(m.nodes[2].label, "B");
assert_eq!(m.nodes[2].parent, Some(1));
assert_eq!(m.nodes[3].label, "C");
assert_eq!(m.nodes[3].parent, Some(1), "C hangs off A, not off B");
}
#[test]
fn a_second_root_is_refused_the_way_upstream_refuses_it() {
assert!(matches!(
parse("mindmap\nRoot\nAlso root\n"),
Err(ParseError::Invalid { .. })
));
}
#[test]
fn an_icon_and_a_class_decorate_the_node_before_them() {
let m = ok("mindmap\n Root\n A\n ::icon(fa fa-book)\n B(B)\n :::urgent large\n");
assert_eq!(m.nodes[1].icon.as_deref(), Some("fa fa-book"));
assert_eq!(m.nodes[2].classes, ["urgent", "large"]);
assert_eq!(m.nodes.len(), 3, "a decoration is not a node");
}
#[test]
fn a_quoted_markdown_label_keeps_its_newlines_and_brackets() {
let m = ok("mindmap\n id1[\"`**Root** with\na second line`\"]\n");
assert_eq!(m.nodes[0].label, "**Root** with\na second line");
assert_eq!(m.nodes[0].shape, NodeShape::Rect);
}
#[test]
fn a_node_written_without_an_id_is_named_by_its_own_words() {
let m = ok("mindmap\n (just a label)\n");
assert_eq!(m.nodes[0].id, "just a label");
assert_eq!(m.nodes[0].shape, NodeShape::RoundedRect);
}
#[test]
fn a_header_with_no_nodes_is_refused() {
assert!(matches!(parse("mindmap\n"), Err(ParseError::NoData { .. })));
}
#[test]
fn the_routing_predicate_and_the_parser_agree() {
for src in [
"mindmap\n root",
"MINDMAP\n root",
"mindmap",
"mindmaps\n a",
"kanban\n a",
"",
"%% only a comment\n",
] {
let refused = matches!(parse(src), Err(ParseError::NotThisChart { .. }));
assert_eq!(is_mindmap(src), !refused, "{src:?}");
}
}
}