use crate::model::{Direction, EdgeKind, End, Graph, NodeStyle, Shape};
pub fn to_mermaid(g: &Graph) -> String {
#[cfg(debug_assertions)]
check_preconditions(g);
let mut out = String::new();
out.push_str("flowchart ");
out.push_str(dir_token(g.direction));
out.push('\n');
for i in 0..g.nodes.len() {
out.push_str(" ");
out.push_str(&node_ref(g, i));
out.push('\n');
}
let mut visited = vec![false; g.subgraphs.len()];
for r in 0..g.subgraphs.len() {
if g.subgraphs[r].parent.is_none() {
emit_subgraph(g, r, 1, &mut visited, &mut out);
}
}
for r in 0..g.subgraphs.len() {
if !visited[r] {
emit_subgraph(g, r, 1, &mut visited, &mut out);
}
}
for e in &g.edges {
out.push_str(" ");
out.push_str(&endpoint(g, e.from));
push_edge_op(&mut out, e.kind, e.label.as_deref());
out.push_str(&endpoint(g, e.to));
out.push('\n');
}
for e in &g.sub_edges {
out.push_str(" ");
out.push_str(&end_ref(g, e.from));
push_edge_op(&mut out, e.kind, e.label.as_deref());
out.push_str(&end_ref(g, e.to));
out.push('\n');
}
for (i, n) in g.nodes.iter().enumerate() {
if let Some(props) = style_props(&n.style) {
out.push_str(" style ");
out.push_str(&g.nodes[i].id);
out.push(' ');
out.push_str(&props);
out.push('\n');
}
}
out
}
#[cfg(debug_assertions)]
fn check_preconditions(g: &Graph) {
for n in &g.nodes {
debug_assert!(
!g.subgraphs.iter().any(|s| s.id == n.id),
"to_mermaid: node id {:?} collides with a subgraph id — unrepresentable in mermaid",
n.id
);
}
for e in &g.edges {
debug_assert!(
e.from < g.nodes.len() && e.to < g.nodes.len(),
"to_mermaid: edge endpoint index out of bounds ({}→{}, {} nodes)",
e.from,
e.to,
g.nodes.len()
);
}
let mut seen = std::collections::HashSet::new();
for s in &g.subgraphs {
debug_assert!(
s.parent.map_or(true, |p| p < g.subgraphs.len()),
"to_mermaid: subgraph {:?} has an out-of-bounds parent index",
s.id
);
for &m in &s.nodes {
debug_assert!(
m < g.nodes.len(),
"to_mermaid: subgraph {:?} member index {} out of bounds ({} nodes)",
s.id,
m,
g.nodes.len()
);
debug_assert!(
seen.insert(m),
"to_mermaid: node {:?} is a direct member of two subgraphs — unrepresentable",
g.nodes.get(m).map(|n| n.id.as_str()).unwrap_or("?")
);
}
}
}
fn dir_token(d: Direction) -> &'static str {
match d {
Direction::TD => "TD",
Direction::LR => "LR",
Direction::RL => "RL",
Direction::BT => "BT",
}
}
const RESERVED: &[&str] = &[
"end", "subgraph", "direction", "style", "classDef", "class", "flowchart", "graph",
"linkStyle", "click",
];
fn is_reserved(id: &str) -> bool {
RESERVED.iter().any(|k| k.eq_ignore_ascii_case(id))
}
fn encode(label: &str, extra: &[char]) -> String {
let label: String = label
.chars()
.map(|c| {
if c.is_control() && !matches!(c, '\n' | '\t') {
' '
} else {
c
}
})
.collect();
let mut out = String::with_capacity(label.len());
let mut rest = label.as_str();
while let Some(h) = rest.find('#') {
out.push_str(&rest[..h]);
let tail = &rest[h + 1..];
let word = tail
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
.count();
let is_entity = word > 0 && tail[word..].starts_with(';');
out.push_str(if is_entity { "#35;" } else { "#" });
rest = tail;
}
out.push_str(rest);
let mut out = out.replace('<', "#60;").replace('"', "#quot;");
for &c in extra {
out = out.replace(c, &format!("#{};", c as u32));
}
out
}
fn node_ref(g: &Graph, i: usize) -> String {
let n = &g.nodes[i];
let bare_ok = n.label == n.id && matches!(n.shape, Shape::Rect) && !is_reserved(&n.id);
if bare_ok {
return n.id.clone();
}
let (open, close) = shape_delims(n.shape);
format!("{}{}{}{}", n.id, open, label_text(&n.label), close)
}
fn endpoint(g: &Graph, i: usize) -> String {
let n = &g.nodes[i];
if is_reserved(&n.id) {
node_ref(g, i)
} else {
n.id.clone()
}
}
fn end_ref(g: &Graph, e: End) -> String {
match e {
End::Node(v) => endpoint(g, v),
End::Sub(s) => g.subgraphs[s].id.clone(),
}
}
fn shape_delims(s: Shape) -> (&'static str, &'static str) {
match s {
Shape::Rect | Shape::ForkBar => ("[", "]"),
Shape::Rounded => ("(", ")"),
Shape::Stadium => ("([", "])"),
Shape::Diamond => ("{", "}"),
Shape::Circle | Shape::StateStart | Shape::StateEnd => ("((", "))"),
Shape::DoubleCircle => ("(((", ")))"),
Shape::Cylinder => ("[(", ")]"),
Shape::Subroutine => ("[[", "]]"),
Shape::Hexagon => ("{{", "}}"),
Shape::Parallelogram => ("[/", "/]"),
Shape::ParallelogramAlt => ("[\\", "\\]"),
}
}
fn label_text(label: &str) -> String {
if label.trim().is_empty() {
return "\" \"".to_string();
}
let enc = defuse_md(encode(label, &[]).replace('\n', "<br/>"));
format!("\"{}\"", enc)
}
fn defuse_md(enc: String) -> String {
if enc.starts_with('`') {
format!("#96;{}", &enc[1..])
} else {
enc
}
}
fn push_edge_op(out: &mut String, kind: EdgeKind, label: Option<&str>) {
let op = match kind {
EdgeKind::Arrow => "-->",
EdgeKind::Open => "---",
EdgeKind::Dotted => "-.->",
EdgeKind::DottedOpen => "-.-",
EdgeKind::Thick => "==>",
EdgeKind::ThickOpen => "===",
EdgeKind::Invisible => "~~~",
};
out.push(' ');
out.push_str(op);
if let Some(l) = label {
if !l.trim().is_empty() {
out.push_str("|\"");
out.push_str(&defuse_md(encode(l, &['|']).replace('\n', " ")));
out.push_str("\"|");
}
}
out.push(' ');
}
fn emit_subgraph(g: &Graph, si: usize, depth: usize, visited: &mut [bool], out: &mut String) {
if visited[si] {
return;
}
visited[si] = true;
debug_assert!(
depth <= 500,
"to_mermaid: subgraph {:?} nests deeper than the parser's 500-level cap — \
the emitted file could not be re-parsed",
g.subgraphs[si].id
);
let s = &g.subgraphs[si];
let pad = " ".repeat(depth);
out.push_str(&pad);
out.push_str("subgraph ");
out.push_str(&s.id);
if s.title != s.id {
if s.title.trim().is_empty() {
out.push_str("[\"\"]");
} else {
out.push('[');
out.push_str(&label_text(&s.title.replace('\n', " ")));
out.push(']');
}
}
out.push('\n');
if let Some(d) = s.direction {
out.push_str(&pad);
out.push_str(" direction ");
out.push_str(dir_token(d));
out.push('\n');
}
for &m in &s.nodes {
out.push_str(&pad);
out.push_str(" ");
if is_reserved(&g.nodes[m].id) {
out.push_str(&node_ref(g, m));
} else {
out.push_str(&g.nodes[m].id);
}
out.push('\n');
}
for c in 0..g.subgraphs.len() {
if g.subgraphs[c].parent == Some(si) {
emit_subgraph(g, c, depth + 1, visited, out);
}
}
out.push_str(&pad);
out.push_str("end\n");
}
fn style_value_ok(v: &str) -> bool {
let v = v.trim();
if v.is_empty() {
return false;
}
let mut depth = 0i32;
for c in v.chars() {
if c.is_control() {
return false;
}
match c {
'(' => depth += 1,
')' => depth -= 1,
',' if depth == 0 => return false,
_ => {}
}
if depth < 0 {
return false;
}
}
depth == 0
}
fn style_props(st: &NodeStyle) -> Option<String> {
let mut parts: Vec<String> = Vec::new();
if let Some(f) = &st.fill {
if style_value_ok(f) {
parts.push(format!("fill:{}", f.trim()));
}
}
if let Some(s) = &st.stroke {
if style_value_ok(s) {
parts.push(format!("stroke:{}", s.trim()));
}
}
if let Some(w) = st.stroke_width {
if w.is_finite() {
parts.push(format!("stroke-width:{}px", w));
}
}
if let Some(c) = &st.color {
if style_value_ok(c) {
parts.push(format!("color:{}", c.trim()));
}
}
if parts.is_empty() {
None
} else {
Some(parts.join(","))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::parse;
fn assert_roundtrip(g: &Graph) {
let text = to_mermaid(g);
let back = parse(&text).unwrap_or_else(|e| panic!("re-parse failed: {e}\n--\n{text}"));
assert_eq!(back.direction, g.direction, "direction\n--\n{text}");
assert_eq!(back.nodes.len(), g.nodes.len(), "node count\n--\n{text}");
let norm_label = |s: &str| {
s.chars()
.map(|c| if c.is_control() && !matches!(c, '\n' | '\t') { ' ' } else { c })
.collect::<String>()
.trim()
.to_string()
};
for (a, b) in g.nodes.iter().zip(&back.nodes) {
assert_eq!(a.id, b.id, "node id\n--\n{text}");
assert_eq!(norm_label(&a.label), b.label, "label of {}\n--\n{text}", a.id);
assert_eq!(a.shape, b.shape, "shape of {}\n--\n{text}", a.id);
let sv = |v: &Option<String>| {
v.as_deref()
.filter(|s| super::style_value_ok(s))
.map(|s| s.trim().to_string())
};
assert_eq!(
(sv(&a.style.fill), sv(&a.style.stroke), a.style.stroke_width, sv(&a.style.color)),
(sv(&b.style.fill), sv(&b.style.stroke), b.style.stroke_width, sv(&b.style.color)),
"style of {}\n--\n{text}",
a.id
);
}
let eid = |g: &Graph, i: usize| g.nodes[i].id.clone();
assert_eq!(back.edges.len(), g.edges.len(), "edge count\n--\n{text}");
for (a, b) in g.edges.iter().zip(&back.edges) {
assert_eq!(eid(g, a.from), eid(&back, b.from), "edge from\n--\n{text}");
assert_eq!(eid(g, a.to), eid(&back, b.to), "edge to\n--\n{text}");
assert_eq!(a.kind, b.kind, "edge kind\n--\n{text}");
let norm = norm_1line;
assert_eq!(norm(&a.label), norm(&b.label), "edge label\n--\n{text}");
}
assert_eq!(back.subgraphs.len(), g.subgraphs.len(), "subgraph count\n--\n{text}");
for s in &g.subgraphs {
let t = back
.subgraphs
.iter()
.find(|t| t.id == s.id)
.unwrap_or_else(|| panic!("subgraph {} lost\n--\n{text}", s.id));
assert_eq!(s.title.trim(), t.title, "title of {}\n--\n{text}", s.id);
let ids = |g: &Graph, m: &[usize]| {
let mut v: Vec<String> = m.iter().map(|&i| g.nodes[i].id.clone()).collect();
v.sort();
v
};
assert_eq!(ids(g, &s.nodes), ids(&back, &t.nodes), "members of {}\n--\n{text}", s.id);
let pid = |g: &Graph, p: Option<usize>| p.map(|i| g.subgraphs[i].id.clone());
assert_eq!(pid(g, s.parent), pid(&back, t.parent), "parent of {}\n--\n{text}", s.id);
assert_eq!(s.direction, t.direction, "direction of {}\n--\n{text}", s.id);
}
assert_eq!(back.sub_edges.len(), g.sub_edges.len(), "sub-edge count\n--\n{text}");
let end_id = |g: &Graph, e: End| match e {
End::Node(i) => format!("n:{}", g.nodes[i].id),
End::Sub(i) => format!("s:{}", g.subgraphs[i].id),
};
for (a, b) in g.sub_edges.iter().zip(&back.sub_edges) {
assert_eq!(end_id(g, a.from), end_id(&back, b.from), "sub-edge from\n--\n{text}");
assert_eq!(end_id(g, a.to), end_id(&back, b.to), "sub-edge to\n--\n{text}");
assert_eq!(a.kind, b.kind, "sub-edge kind\n--\n{text}");
assert_eq!(norm_1line(&a.label), norm_1line(&b.label), "sub-edge label\n--\n{text}");
}
}
fn norm_1line(l: &Option<String>) -> Option<String> {
l.as_deref()
.map(|s| {
s.chars()
.map(|c| if c.is_control() && c != '\t' { ' ' } else { c })
.collect::<String>()
.trim()
.to_string()
})
.filter(|s| !s.is_empty())
}
fn assert_source_roundtrip(src: &str) {
assert_roundtrip(&parse(src).unwrap());
}
#[test]
fn every_shape_and_edge_kind_round_trips() {
assert_source_roundtrip(
"flowchart LR\n\
A[rect] --> B(round) --> C([stadium]) --> D{diamond}\n\
E((circle)) --> F(((double))) --> G[(db)] --> H[[sub]]\n\
I{{hex}} --> J[/para/] --> K[\\alt\\]\n\
A --- B\n A -.-> C\n A -.- D\n A ==> E\n A === F\n A ~~~ G\n",
);
}
#[test]
fn labels_with_brackets_pipes_and_breaks_round_trip() {
assert_source_roundtrip(
"flowchart TD\n\
A[\"odd [text] here\"] -->|go / stop| B[\"a|b\"]\n\
C[\"line<br/>break\"] --> D[\"he said 'hi'\"]\n",
);
}
#[test]
fn quotes_pipes_and_hashes_round_trip_losslessly_via_entities() {
let mut g = Graph::default();
let a = g.ensure_node("a", Some("he said \"hi\"".into()), Some(Shape::Circle));
let b = g.ensure_node("b", Some("\"wrapped\"".into()), Some(Shape::Rect));
let c = g.ensure_node("c", Some("#quot; literal #35; and #f00".into()), None);
let d = g.ensure_node("d", Some("says \"hi\" :)".into()), Some(Shape::Circle));
g.add_edge(a, b, Some("say \"go\"".into()), EdgeKind::Arrow);
g.add_edge(b, c, Some("\"quoted label\"".into()), EdgeKind::Thick);
g.add_edge(c, d, Some("a|b".into()), EdgeKind::Dotted);
assert_roundtrip(&g);
let text = to_mermaid(&g);
assert!(text.contains("#quot;"), "quotes ride as entities:\n{text}");
assert_source_roundtrip("flowchart TD\nA[he said \"hi\"] --> B\n");
}
#[test]
fn styles_and_class_assignments_round_trip_as_style_lines() {
assert_source_roundtrip(
"flowchart TD\n\
A[x]:::hot --> B[y]\n\
classDef hot fill:#f00,stroke:#900,stroke-width:3px\n\
style B fill:#e4f5f4,color:#111,stroke-width:1.5px\n",
);
assert_source_roundtrip("flowchart TD\nA --> B\nstyle A fill:rgb(255,0,0),stroke:#900\n");
let mut g = Graph::default();
let a = g.ensure_node("a", None, None);
g.ensure_node("b", None, None);
g.nodes[a].style.stroke_width = Some(1e19);
g.add_edge(0, 1, None, EdgeKind::Arrow);
assert_roundtrip(&g);
let mut st = NodeStyle::default();
st.stroke_width = Some(f64::NAN);
assert_eq!(style_props(&st), None);
}
#[test]
fn nested_subgraphs_directions_titles_and_sub_edges_round_trip() {
assert_source_roundtrip(
"flowchart TD\n\
Client --> GW\n\
subgraph platform [The Platform]\n\
direction LR\n\
GW --> Auth\n\
subgraph inner\n\
Auth --> DB[(users)]\n\
end\n\
end\n\
Client --> platform\n\
platform ==> Audit\n",
);
assert_source_roundtrip("flowchart TD\nsubgraph s[say \"hi\"]\nA\nend\n");
}
#[test]
fn orphaned_subgraphs_are_still_emitted() {
let mut g = Graph::default();
let a = g.ensure_node("A", None, None);
g.subgraphs.push(crate::model::Subgraph {
id: "s0".into(),
title: "s0".into(),
nodes: vec![a],
parent: Some(1),
direction: None,
});
g.subgraphs.push(crate::model::Subgraph {
id: "s1".into(),
title: "s1".into(),
nodes: vec![],
parent: Some(0),
direction: None,
});
let text = to_mermaid(&g);
assert!(text.contains("subgraph s0") && text.contains("subgraph s1"), "{text}");
assert!(parse(&text).is_ok(), "{text}");
}
#[test]
fn reserved_word_ids_survive_case_insensitively() {
for id in ["end", "Class", "STYLE", "Direction", "subGraph", "classDef"] {
let mut g = Graph::default();
let a = g.ensure_node(id, Some("checker".into()), None);
let b = g.ensure_node("b", None, None);
g.add_edge(a, b, Some("k".into()), EdgeKind::Arrow);
g.subgraphs.push(crate::model::Subgraph {
id: "box".into(),
title: "box".into(),
nodes: vec![a],
parent: None,
direction: None,
});
assert_roundtrip(&g);
}
}
#[test]
fn empty_labels_round_trip_and_avoid_invalid_mermaid() {
let mut g = Graph::default();
let a = g.ensure_node("a", Some(String::new()), Some(Shape::Rect));
let b = g.ensure_node("b", None, None);
g.add_edge(a, b, Some(String::new()), EdgeKind::Arrow);
let text = to_mermaid(&g);
assert!(!text.contains("[\"\"]") && !text.contains("||"), "{text}");
assert_roundtrip(&g);
}
#[test]
fn programmatic_editor_graph_round_trips() {
let mut g = Graph::default();
g.direction = Direction::LR;
let a = g.ensure_node("start", Some("Start".into()), Some(Shape::Stadium));
let b = g.ensure_node("check", Some("pay_mode == cod".into()), Some(Shape::Diamond));
let c = g.ensure_node("ship", Some("Ship it".into()), Some(Shape::Rect));
g.add_edge(a, b, None, EdgeKind::Arrow);
g.add_edge(b, c, Some("yes".into()), EdgeKind::Arrow);
g.add_edge(b, a, Some("no".into()), EdgeKind::Dotted);
g.nodes[c].style.fill = Some("#e4f5f4".into());
g.nodes[c].style.stroke_width = Some(2.0);
assert_roundtrip(&g);
}
#[test]
fn fuzzed_graphs_round_trip() {
struct Lcg(u64);
impl Lcg {
fn next(&mut self, n: usize) -> usize {
self.0 = self
.0
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
((self.0 >> 33) as usize) % n.max(1)
}
}
let shapes = [
Shape::Rect,
Shape::Rounded,
Shape::Stadium,
Shape::Diamond,
Shape::Circle,
Shape::DoubleCircle,
Shape::Cylinder,
Shape::Subroutine,
Shape::Hexagon,
Shape::Parallelogram,
Shape::ParallelogramAlt,
];
let kinds = [
EdgeKind::Arrow,
EdgeKind::Open,
EdgeKind::Dotted,
EdgeKind::DottedOpen,
EdgeKind::Thick,
EdgeKind::ThickOpen,
EdgeKind::Invisible,
];
let labels = [
"plain", "with space", "a[b]c", "p|q", "()", "{}", "-->", "==>", "x\ny",
"he said \"hi\"", "\"wrapped\"", "says \"hi\" :)", "see \"n\" [1]", "#f00",
"#quot; raw", "#65;", "subgraph", "end", "100%", "a & b", "C#",
"Tom #amp; Jerry", "literal <br/> text", "a < b", "`md text`", "#0;",
"$$x^2$$", "fee $$ plus $$ tax",
];
let mut rng = Lcg(7);
for round in 0..60 {
let mut g = Graph::default();
g.direction = [Direction::TD, Direction::LR, Direction::RL, Direction::BT]
[rng.next(4)];
let n = 2 + rng.next(9);
for i in 0..n {
let id = format!("n{round}_{i}");
let label = labels[rng.next(labels.len())].to_string();
let ni = g.ensure_node(&id, Some(label), Some(shapes[rng.next(shapes.len())]));
if rng.next(4) == 0 {
g.nodes[ni].style.fill = Some("#abc".into());
g.nodes[ni].style.stroke_width = Some(rng.next(5) as f64 + 0.5);
}
}
for _ in 0..n {
let (a, b) = (rng.next(n), rng.next(n));
let label = if rng.next(3) == 0 {
Some(labels[rng.next(labels.len())].to_string())
} else {
None
};
g.add_edge(a, b, label, kinds[rng.next(kinds.len())]);
}
let mut claimed: Vec<usize> = Vec::new();
for si in 0..rng.next(3) {
let mut mem: Vec<usize> = Vec::new();
for i in 0..n {
if !claimed.contains(&i) && rng.next(3) == 0 {
mem.push(i);
claimed.push(i);
}
}
let parent = if si == 1 && rng.next(2) == 0 { Some(0) } else { None };
g.subgraphs.push(crate::model::Subgraph {
id: format!("s{round}_{si}"),
title: format!("s{round}_{si}"),
nodes: mem,
parent,
direction: None,
});
}
assert_roundtrip(&g);
}
}
#[test]
fn output_is_valid_standard_mermaid_shapes() {
let mut g = Graph::default();
let a = g.ensure_node("a", Some("A label".into()), Some(Shape::Stadium));
let b = g.ensure_node("b", None, None);
g.add_edge(a, b, Some("ok".into()), EdgeKind::Thick);
let text = to_mermaid(&g);
assert_eq!(
text,
"flowchart TD\n a([\"A label\"])\n b\n a ==>|\"ok\"| b\n"
);
}
#[test]
fn round2_entity_lookalikes_and_angle_brackets_survive() {
let mut g = Graph::default();
let a = g.ensure_node("a", Some("Tom #amp; Jerry".into()), None);
let b = g.ensure_node("b", Some("literal <br/> tag".into()), None);
let c = g.ensure_node("c", Some("`**md** text`".into()), None);
let d = g.ensure_node("d", Some("a < b".into()), None);
g.add_edge(a, b, Some("cost #eur; 5".into()), EdgeKind::Arrow);
g.add_edge(c, d, None, EdgeKind::Arrow);
assert_roundtrip(&g);
let text = to_mermaid(&g);
assert!(text.contains("#35;amp;"), "named lookalike escaped:\n{text}");
assert!(text.contains("#60;br/>"), "literal break tag defused:\n{text}");
assert!(text.contains("#96;"), "leading markdown backtick defused:\n{text}");
}
#[test]
fn round2_entities_cannot_mint_control_characters() {
let g = parse("flowchart TD\nA[\"x #0; y #27; z\"] --> B\n").unwrap();
assert_eq!(g.nodes[0].label, "x #0; y #27; z");
assert_roundtrip(&g);
let g = parse("flowchart TD\nA[\"a#10;b\"] --> B\n").unwrap();
assert_eq!(g.nodes[0].label, "a\nb");
}
#[test]
fn round2_keyword_named_subgraph_as_edge_source_round_trips() {
for kw in ["style", "class", "classDef", "direction"] {
let src = format!("flowchart TD\nsubgraph {kw}\nA\nend\nX --> {kw} --> B\n");
let g = parse(&src).unwrap_or_else(|e| panic!("{kw}: {e}"));
assert_eq!(g.sub_edges.len(), 2, "{kw} sub-edges");
assert_roundtrip(&g);
}
}
#[test]
fn round2_bad_style_values_drop_instead_of_corrupting() {
let mut g = Graph::default();
let a = g.ensure_node("a", None, None);
g.ensure_node("b", None, None);
g.add_edge(0, 1, None, EdgeKind::Arrow);
g.nodes[a].style.fill = Some("red, blue".into());
g.nodes[a].style.stroke = Some("#900".into());
let text = to_mermaid(&g);
assert!(!text.contains("red, blue"), "{text}");
assert!(text.contains("stroke:#900"), "{text}");
let back = parse(&text).unwrap();
assert_eq!(back.nodes[0].style.stroke.as_deref(), Some("#900"));
assert!(parse("flowchart TD\nA\nstyle A fill:rgb(255,stroke:#900\n").is_err());
}
#[test]
fn round2_empty_subgraph_title_round_trips() {
let g = parse("flowchart TD\nsubgraph s[\"\"]\nA\nend\n").unwrap();
assert_eq!(g.subgraphs[0].title, "");
assert_roundtrip(&g);
}
#[test]
fn round3_subgraph_named_subgraph_survives_as_edge_source() {
let src = "flowchart TD\nB\nsubgraph subgraph\nend\nsubgraph --> B\n";
let g = parse(src).unwrap();
assert_eq!(g.sub_edges.len(), 1);
assert_roundtrip(&g);
let g = parse("flowchart TD\nsubgraph style\nend\nstyle & A --> B\n").unwrap();
assert_eq!(g.sub_edges.len(), 1, "fan-out lead-in");
assert_roundtrip(&g);
}
#[test]
fn round3_old_dashed_statement_arguments_get_targeted_errors() {
assert!(parse("flowchart TD\nclassDef --x fill:red\nA\n").is_ok());
let e = parse("flowchart TD\nA\nstyle --x fill:red\n").unwrap_err();
assert!(e.message.contains("node id"), "targeted, not misleading: {}", e.message);
assert!(parse("flowchart TD\ndirection --> B\n").is_err());
}
#[test]
fn round3_style_values_with_newlines_cannot_inject_statements() {
let mut g = Graph::default();
let a = g.ensure_node("a", None, None);
g.ensure_node("b", None, None);
g.nodes[a].style.fill = Some("red\nB --> C".into());
g.nodes[a].style.stroke = Some(" #900 ".into());
let text = to_mermaid(&g);
assert!(!text.contains("B --> C"), "no injected statement:\n{text}");
assert!(text.contains("stroke:#900"), "trimmed neighbour survives:\n{text}");
let back = parse(&text).unwrap();
assert_eq!(back.nodes.len(), 2, "no phantom nodes:\n{text}");
}
#[test]
fn round4_prescan_and_main_loop_agree_on_subgraph_lines() {
let g = parse("flowchart TD\nsubgraph & X\nend\nsubgraph two\nC\nend\nD --> two\n").unwrap();
assert_eq!(g.sub_edges.len(), 1);
let crate::model::End::Sub(si) = g.sub_edges[0].to else {
panic!("expected a sub end")
};
assert_eq!(g.subgraphs[si].id, "two", "edge must bind the box it names");
let g = parse("flowchart TD\nsubgraph --> B\nend\nsubgraph two\nC\nend\nD --> two\n").unwrap();
let crate::model::End::Sub(si) = g.sub_edges[0].to else {
panic!("expected a sub end")
};
assert_eq!(g.subgraphs[si].id, "two");
}
#[test]
fn round4_keyword_gate_is_order_independent() {
let g = parse("flowchart TD\nstyle --> B\nsubgraph style\nA\nend\n").unwrap();
assert_eq!(g.sub_edges.len(), 1);
assert_roundtrip(&g);
}
#[test]
fn round4_mangled_statement_ids_error_instead_of_phantom_nodes() {
let e = parse("flowchart TD\nclass --> B\n").unwrap_err();
assert!(e.message.contains("node id"), "{}", e.message);
let e = parse("flowchart TD\nstyle --> B:::green\n").unwrap_err();
assert!(e.message.contains("node id"), "{}", e.message);
}
#[test]
fn round4_control_chars_sanitize_and_one_sided_backticks_defuse() {
let mut g = Graph::default();
g.ensure_node("a", Some("bell\u{7}!".into()), None);
let b = g.ensure_node("b", None, None);
g.add_edge(0, b, Some("x\ry".into()), EdgeKind::Arrow);
let text = to_mermaid(&g);
assert!(
!text.chars().any(|c| c.is_control() && c != '\n'),
"no raw control bytes:\n{text:?}"
);
let back = parse(&text).unwrap();
assert_eq!(back.nodes[0].label, "bell !");
let g = parse("flowchart TD\nA[\"#96;hello\"] --> B\n").unwrap();
assert_eq!(g.nodes[0].label, "`hello");
let text = to_mermaid(&g);
assert!(text.contains("#96;"), "{text}");
assert_roundtrip(&g);
}
#[test]
fn round7_bare_titles_and_prop_values_never_divert() {
let g = parse(
"flowchart TD\nsubgraph subgraph\nA\nend\nsubgraph -- why --> done\nB\nend\n",
)
.unwrap();
assert_eq!(g.subgraphs.len(), 2, "second block stays a header");
assert!(g.nodes.iter().all(|n| n.id != "end"), "no phantom end node");
let g = parse("flowchart TD\nsubgraph subgraph\nA\nend\nsubgraph -- Phase 1 ---\nB\nend\n")
.unwrap();
assert_eq!(g.subgraphs.len(), 2);
let g = parse("flowchart TD\nclassDef --x fill:url(#a-->b)\nB\n").unwrap();
assert!(g.nodes.iter().any(|n| n.id == "B"));
let g = parse("flowchart TD\nsubgraph style\nA\nend\nstyle -- go --> B\n").unwrap();
assert_eq!(g.sub_edges.len(), 1);
assert_eq!(g.sub_edges[0].label.as_deref(), Some("go"));
let g = parse("flowchart TD\nsubgraph style\nA\nend\nstyle -- (note) --> B\n").unwrap();
assert_eq!(g.sub_edges[0].label.as_deref(), Some("(note)"));
let g = parse("flowchart TD\nsubgraph style\nA\nend\nstyle -- ratio 1:2 --> B\n").unwrap();
assert_eq!(g.sub_edges[0].label.as_deref(), Some("ratio 1:2"));
let g = parse("flowchart TD\nclick -- open: docs --> B\n").unwrap();
assert_eq!(g.edges[0].label.as_deref(), Some("open: docs"));
}
#[test]
fn round6_click_named_nodes_and_case_variant_keywords() {
for id in ["click", "linkStyle", "Click"] {
let g = parse(&format!("flowchart TD\nA --> {id}\n")).unwrap();
assert_roundtrip(&g);
}
assert!(parse("flowchart TD\nClick\nClick --> B\n").is_ok());
assert!(parse("flowchart TD\nclick A callback\n").is_err());
let g = parse("flowchart TD\nclick -- go --> B\n").unwrap();
assert_eq!(g.edges.len(), 1);
assert_eq!(g.edges[0].label.as_deref(), Some("go"));
}
#[test]
fn round6_case_variant_heads_route_to_the_edge_path() {
let g = parse("flowchart TD\nsubgraph class\nA\nend\nClass --> B\n").unwrap();
assert!(g.nodes.iter().any(|n| n.id == "Class"));
assert_eq!(g.edges.len(), 1);
let g = parse("flowchart TD\nsubgraph Subgraph\nA\nend\nSUBGRAPH --> B\n").unwrap();
assert!(g.nodes.iter().any(|n| n.id == "SUBGRAPH"));
let g = parse("flowchart TD\nsubgraph Subgraph\nA\nend\nSubgraph --> B\n").unwrap();
assert_eq!(g.sub_edges.len(), 1);
}
#[test]
fn round6_subgraph_style_props_validated_and_dashed_ids_ok() {
assert!(parse("flowchart TD\nsubgraph my-group\nA\nend\nstyle my-group fill:#f9f\n").is_ok());
assert!(parse("flowchart TD\nsubgraph grp\nA\nend\nstyle grp NOTAPROP\n").is_err());
}
#[test]
fn round6_seq_whitespace_only_labels_still_load() {
use crate::parser::parse_document;
assert!(parse_document("sequenceDiagram\nnote over A: #32;\nA->>B: y\n").is_ok());
assert!(parse_document("sequenceDiagram\nparticipant A as \" \"\nA->>B: y\n").is_ok());
assert!(parse_document("sequenceDiagram\nnote over A:\nA->>B: y\n").is_err());
}
#[test]
fn round5_case_variant_and_padded_subgraph_keyword_ids() {
for id in ["SUBGRAPH", "Class", "subgraph"] {
let mut g = Graph::default();
let b = g.ensure_node("B", None, None);
g.subgraphs.push(crate::model::Subgraph {
id: id.into(),
title: id.into(),
nodes: vec![],
parent: None,
direction: None,
});
g.sub_edges.push(crate::model::SubEdge {
from: End::Sub(0),
to: End::Node(b),
label: None,
kind: EdgeKind::Arrow,
});
assert_roundtrip(&g);
}
let g = parse("flowchart TD\nsubgraph subgraph [T]\nend\nsubgraph --> B\n").unwrap();
assert_eq!(g.sub_edges.len(), 1);
assert_roundtrip(&g);
}
#[test]
fn round5_subgraph_styling_is_accepted_without_phantom_nodes() {
let g = parse(
"flowchart TD\nsubgraph grp\nA\nend\nstyle grp fill:#f9f\nclassDef hot fill:#f00\nclass grp hot\n",
)
.unwrap();
assert!(g.nodes.iter().all(|n| n.id != "grp"));
assert_roundtrip(&g);
let g = parse("flowchart TD\nA --> B\nclassDef hot fill:#f00\nclass A, B hot\n").unwrap();
assert!(g.nodes.iter().take(2).all(|n| n.style.fill.is_some()), "both styled");
}
#[test]
fn round5_label_edge_whitespace_and_unsupported_statements() {
let mut g = Graph::default();
g.ensure_node("a", Some("x \n".into()), None);
g.ensure_node("b", Some("bell\u{7}\n".into()), None);
g.add_edge(0, 1, None, EdgeKind::Arrow);
assert_roundtrip(&g);
let e = parse("flowchart TD\nA --> B\nlinkStyle 0 stroke:#f00\n").unwrap_err();
assert!(e.message.contains("aren't supported"), "{}", e.message);
let g = parse("flowchart TD\nclick --> B\n").unwrap();
assert_eq!(g.edges.len(), 1);
}
#[test]
fn round3_backtick_edge_labels_and_cr_entities_are_defused() {
let g = parse("flowchart TD\nA -->|\"`pow`\"| B\n").unwrap();
assert_eq!(g.edges[0].label.as_deref(), Some("`pow`"));
let text = to_mermaid(&g);
assert!(text.contains("#96;"), "leading backtick defused:\n{text}");
assert_roundtrip(&g);
let g = parse("flowchart TD\nA[\"a#13;b\"] --> B\n").unwrap();
assert_eq!(g.nodes[0].label, "a#13;b");
assert_roundtrip(&g);
}
}