use std::collections::{HashMap, HashSet};
use crate::{
Error,
types::{Direction, Edge, Graph, Node, NodeShape, Subgraph},
};
const START_PREFIX: &str = "__start__";
const END_PREFIX: &str = "__end__";
const PATH_SEP: &str = "__";
const MARKER_LABEL: &str = "●";
pub fn parse(input: &str) -> Result<Graph, Error> {
let stmts = tokenise(input)?;
if stmts.is_empty() {
return Err(Error::ParseError(
"no 'stateDiagram' header found".to_string(),
));
}
let header = &stmts[0].1;
let first_word = header.split_whitespace().next().unwrap_or("");
let lower = first_word.to_lowercase();
if lower != "statediagram" && lower != "statediagram-v2" {
return Err(Error::ParseError(format!(
"expected 'stateDiagram' or 'stateDiagram-v2' header, got '{first_word}'"
)));
}
let mut walker = Walker::default();
walker.parse_block(&stmts, 1, &[])?;
walker.materialise()
}
fn tokenise(input: &str) -> Result<Vec<(usize, String)>, Error> {
let lines: Vec<&str> = input.lines().collect();
let mut out = Vec::new();
let mut i = 0;
while i < lines.len() {
let raw = lines[i];
let stripped = strip_inline_comment(raw).trim();
let lineno = i + 1;
i += 1;
if stripped.is_empty() || stripped.starts_with("%%") {
continue;
}
if let Some(rest) = stripped.strip_prefix("note ")
&& !rest.contains(':')
{
while i < lines.len() {
let body = lines[i].trim();
i += 1;
if body == "end note" {
break;
}
}
continue;
}
out.push((lineno, stripped.to_string()));
}
Ok(out)
}
fn strip_inline_comment(line: &str) -> &str {
let bytes = line.as_bytes();
let mut in_quote = false;
let mut i = 0;
while i + 1 < bytes.len() {
let c = bytes[i];
if c == b'"' {
in_quote = !in_quote;
} else if !in_quote && c == b'%' && bytes[i + 1] == b'%' {
return &line[..i];
}
i += 1;
}
line
}
struct Walker {
seen: HashSet<String>,
seen_order: Vec<String>,
descriptions: HashMap<String, Vec<String>>,
explicit_labels: HashMap<String, String>,
shapes: HashMap<String, NodeShape>,
edges: Vec<Edge>,
direction: Direction,
composite_ids: HashSet<String>,
composite_order: Vec<String>,
composite_path: HashMap<String, Vec<String>>,
composite_members: HashMap<String, Vec<String>>,
composite_children: HashMap<String, Vec<String>>,
composite_directions: HashMap<String, Direction>,
}
impl Default for Walker {
fn default() -> Self {
Self {
seen: HashSet::new(),
seen_order: Vec::new(),
descriptions: HashMap::new(),
explicit_labels: HashMap::new(),
shapes: HashMap::new(),
edges: Vec::new(),
direction: Direction::TopToBottom,
composite_ids: HashSet::new(),
composite_order: Vec::new(),
composite_path: HashMap::new(),
composite_members: HashMap::new(),
composite_children: HashMap::new(),
composite_directions: HashMap::new(),
}
}
}
impl Walker {
fn parse_block(
&mut self,
stmts: &[(usize, String)],
start: usize,
path: &[String],
) -> Result<usize, Error> {
let mut i = start;
while i < stmts.len() {
let (lineno, stmt) = &stmts[i];
if stmt == "}" {
if path.is_empty() {
return Err(Error::ParseError(format!(
"line {lineno}: unexpected '}}' at top level"
)));
}
return Ok(i + 1);
}
if let Some(rest) = stmt.strip_prefix("direction ").map(str::trim) {
if let Some(dir) = Direction::parse(rest) {
if let Some(parent) = path.last() {
self.composite_directions.insert(parent.clone(), dir);
} else {
self.direction = dir;
}
}
i += 1;
continue;
}
if stmt.starts_with("note ") {
i += 1;
continue;
}
if matches_keyword(stmt, "classDef")
|| matches_keyword(stmt, "class")
|| matches_keyword(stmt, "style")
|| matches_keyword(stmt, "click")
|| matches_keyword(stmt, "accTitle")
|| matches_keyword(stmt, "accDescr")
|| matches_keyword(stmt, "scale")
|| stmt == "hide empty description"
{
i += 1;
continue;
}
if let Some(rest) = stmt.strip_prefix("state ") {
let body = rest.trim();
if let Some(header_body) = body.strip_suffix('{') {
let header_body = header_body.trim();
let (composite_id, composite_label) =
parse_composite_header(header_body, *lineno)?;
self.open_composite(composite_id.clone(), composite_label, path);
let mut child_path = path.to_vec();
child_path.push(composite_id);
let after = self.parse_block(stmts, i + 1, &child_path)?;
if after == stmts.len() && (after == 0 || stmts[after - 1].1 != "}") {
return Err(Error::ParseError(format!(
"line {lineno}: composite state opened with `{{` is missing its closing `}}`"
)));
}
i = after;
continue;
}
self.handle_state_decl(body, path);
i += 1;
continue;
}
if let Some((from, to, label)) = split_transition(stmt) {
let from_id = self.resolve_endpoint(&from, EndpointSide::Source, path);
let to_id = self.resolve_endpoint(&to, EndpointSide::Destination, path);
self.edges.push(Edge::new(from_id, to_id, label));
i += 1;
continue;
}
if let Some((id, desc)) = split_description(stmt) {
self.register_node(&id, path);
self.descriptions.entry(id).or_default().push(desc);
i += 1;
continue;
}
return Err(Error::ParseError(format!(
"line {lineno}: unrecognised statement: '{stmt}'"
)));
}
if !path.is_empty() {
return Err(Error::ParseError(format!(
"composite state '{}' is missing its closing `}}`",
path.last().unwrap()
)));
}
Ok(i)
}
fn open_composite(&mut self, id: String, label: String, parent_path: &[String]) {
if !self.composite_ids.insert(id.clone()) {
return;
}
self.composite_order.push(id.clone());
self.explicit_labels
.entry(id.clone())
.or_insert_with(|| label.clone());
let mut full_path = parent_path.to_vec();
full_path.push(id.clone());
self.composite_path.insert(id.clone(), full_path);
self.composite_members.entry(id.clone()).or_default();
self.composite_children.entry(id.clone()).or_default();
if let Some(parent_id) = parent_path.last() {
self.composite_children
.entry(parent_id.clone())
.or_default()
.push(id);
}
}
fn register_node(&mut self, id: &str, path: &[String]) {
if self.seen.insert(id.to_string()) {
self.seen_order.push(id.to_string());
if let Some(parent) = path.last() {
self.composite_members
.entry(parent.clone())
.or_default()
.push(id.to_string());
}
}
}
fn resolve_endpoint(&mut self, raw: &str, side: EndpointSide, path: &[String]) -> String {
if raw == "[*]" {
let prefix = match side {
EndpointSide::Source => START_PREFIX,
EndpointSide::Destination => END_PREFIX,
};
let shape = match side {
EndpointSide::Source => NodeShape::Circle,
EndpointSide::Destination => NodeShape::DoubleCircle,
};
let id = mangle_marker(prefix, path);
self.register_node(&id, path);
self.shapes.entry(id.clone()).or_insert(shape);
return id;
}
self.register_node(raw, path);
raw.to_string()
}
fn handle_state_decl(&mut self, body: &str, path: &[String]) {
if body.starts_with('"')
&& let Some(close_quote) = body[1..].find('"').map(|p| p + 1)
{
let display_raw = &body[1..close_quote];
let after = body[close_quote + 1..].trim_start();
if let Some(rest) = strip_keyword_prefix(after, "as") {
let id = rest.split_whitespace().next().unwrap_or("").to_string();
if !id.is_empty() {
self.register_node(&id, path);
let display = display_raw.replace("\\n", "\n");
self.explicit_labels.insert(id, display);
return;
}
}
}
let id = body.split_whitespace().next().unwrap_or("").to_string();
if id.is_empty() {
return;
}
self.register_node(&id, path);
}
fn gc_orphan_markers(&mut self) {
let mut neighbours: HashMap<String, Vec<String>> = HashMap::new();
for id in &self.seen_order {
neighbours.entry(id.clone()).or_default();
}
for edge in &self.edges {
neighbours
.entry(edge.from.clone())
.or_default()
.push(edge.to.clone());
neighbours
.entry(edge.to.clone())
.or_default()
.push(edge.from.clone());
}
let mut reachable: HashSet<String> = HashSet::new();
let mut stack: Vec<String> = Vec::new();
for id in &self.seen_order {
if !is_marker_id(id) {
reachable.insert(id.clone());
stack.push(id.clone());
}
}
for id in &self.composite_order {
reachable.insert(id.clone());
}
while let Some(id) = stack.pop() {
if let Some(adj) = neighbours.get(&id) {
for n in adj.clone() {
if reachable.insert(n.clone()) {
stack.push(n);
}
}
}
}
let dropped: HashSet<String> = self
.seen_order
.iter()
.filter(|id| is_marker_id(id) && !reachable.contains(id.as_str()))
.cloned()
.collect();
if dropped.is_empty() {
return;
}
self.seen_order.retain(|id| !dropped.contains(id));
self.seen.retain(|id| !dropped.contains(id));
self.shapes.retain(|id, _| !dropped.contains(id));
self.descriptions.retain(|id, _| !dropped.contains(id));
self.explicit_labels.retain(|id, _| !dropped.contains(id));
for members in self.composite_members.values_mut() {
members.retain(|id| !dropped.contains(id));
}
self.edges
.retain(|e| !dropped.contains(&e.from) && !dropped.contains(&e.to));
}
fn rewrite_composite_edges(&mut self) {
let composite_ids = self.composite_ids.clone();
let composite_paths = self.composite_path.clone();
let edges = std::mem::take(&mut self.edges);
let mut rewritten = Vec::with_capacity(edges.len());
for edge in edges {
let mut new_from = edge.from;
let mut new_to = edge.to;
if composite_ids.contains(&new_from) {
let path = composite_paths
.get(&new_from)
.cloned()
.unwrap_or_else(|| vec![new_from.clone()]);
let id = mangle_marker(END_PREFIX, &path);
self.register_node(&id, &path);
self.shapes
.entry(id.clone())
.or_insert(NodeShape::DoubleCircle);
new_from = id;
}
if composite_ids.contains(&new_to) {
let path = composite_paths
.get(&new_to)
.cloned()
.unwrap_or_else(|| vec![new_to.clone()]);
let id = mangle_marker(START_PREFIX, &path);
self.register_node(&id, &path);
self.shapes.entry(id.clone()).or_insert(NodeShape::Circle);
new_to = id;
}
rewritten.push(Edge::new(new_from, new_to, edge.label));
}
self.edges = rewritten;
}
fn materialise(mut self) -> Result<Graph, Error> {
self.rewrite_composite_edges();
self.gc_orphan_markers();
let mut graph = Graph::new(self.direction);
for id in &self.seen_order {
if self.composite_ids.contains(id) {
continue;
}
let shape = self
.shapes
.get(id)
.copied()
.unwrap_or(NodeShape::Rounded);
let label = if self.is_marker(id) {
MARKER_LABEL.to_string()
} else if let Some(explicit) = self.explicit_labels.get(id) {
explicit.clone()
} else if let Some(lines) = self.descriptions.get(id) {
lines.join("\n")
} else {
id.clone()
};
graph.nodes.push(Node::new(id.clone(), label, shape));
}
for sg_id in &self.composite_order {
let label = self
.explicit_labels
.get(sg_id)
.cloned()
.unwrap_or_else(|| sg_id.clone());
let mut sg = Subgraph::new(sg_id.clone(), label);
sg.direction = self.composite_directions.get(sg_id).copied();
sg.node_ids = self
.composite_members
.get(sg_id)
.cloned()
.unwrap_or_default();
sg.subgraph_ids = self
.composite_children
.get(sg_id)
.cloned()
.unwrap_or_default();
graph.subgraphs.push(sg);
}
graph.edges = self.edges;
Ok(graph)
}
fn is_marker(&self, id: &str) -> bool {
is_marker_id(id)
}
}
fn is_marker_id(id: &str) -> bool {
id.starts_with(START_PREFIX) || id.starts_with(END_PREFIX)
}
#[derive(Debug, Clone, Copy)]
enum EndpointSide {
Source,
Destination,
}
fn mangle_marker(prefix: &str, path: &[String]) -> String {
if path.is_empty() {
prefix.to_string()
} else {
format!("{prefix}{}", path.join(PATH_SEP))
}
}
fn matches_keyword(stmt: &str, keyword: &str) -> bool {
if let Some(rest) = stmt.strip_prefix(keyword) {
rest.is_empty() || rest.starts_with(char::is_whitespace) || rest.starts_with(':')
} else {
false
}
}
fn split_transition(stmt: &str) -> Option<(String, String, Option<String>)> {
let arrow_pos = stmt.find("-->")?;
let from = stmt[..arrow_pos].trim().to_string();
let after = &stmt[arrow_pos + 3..];
let (dest_raw, label) = if let Some(colon_pos) = after.find(':') {
(
after[..colon_pos].trim().to_string(),
Some(after[colon_pos + 1..].trim().to_string()),
)
} else {
(after.trim().to_string(), None)
};
if from.is_empty() || dest_raw.is_empty() {
return None;
}
Some((from, dest_raw, label.filter(|s| !s.is_empty())))
}
fn split_description(stmt: &str) -> Option<(String, String)> {
let colon_pos = stmt.find(':')?;
let id = stmt[..colon_pos].trim();
let desc = stmt[colon_pos + 1..].trim();
if id.is_empty() || desc.is_empty() || id.contains(char::is_whitespace) {
return None;
}
Some((id.to_string(), desc.to_string()))
}
fn parse_composite_header(body: &str, lineno: usize) -> Result<(String, String), Error> {
if body.starts_with('"')
&& let Some(close_quote) = body[1..].find('"').map(|p| p + 1)
{
let display = body[1..close_quote].replace("\\n", "\n");
let after = body[close_quote + 1..].trim_start();
if let Some(rest) = strip_keyword_prefix(after, "as") {
let id = rest.split_whitespace().next().unwrap_or("").to_string();
if !id.is_empty() {
return Ok((id, display));
}
}
return Err(Error::ParseError(format!(
"line {lineno}: composite header has a quoted display but no `as <Id>` follows"
)));
}
let id = body.split_whitespace().next().unwrap_or("").to_string();
if id.is_empty() {
return Err(Error::ParseError(format!(
"line {lineno}: composite header is missing an id"
)));
}
let label = id.clone();
Ok((id, label))
}
fn strip_keyword_prefix<'a>(s: &'a str, kw: &str) -> Option<&'a str> {
let lower = s.to_lowercase();
let lower_kw = kw.to_lowercase();
if lower.starts_with(&lower_kw) {
let rest = &s[kw.len()..];
if rest.starts_with(char::is_whitespace) {
Some(rest.trim_start())
} else {
None
}
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn header_required() {
assert!(parse("").is_err());
assert!(parse("[*] --> A").is_err());
}
#[test]
fn accepts_both_keyword_variants() {
assert!(parse("stateDiagram\n[*] --> A").is_ok());
assert!(parse("stateDiagram-v2\n[*] --> A").is_ok());
assert!(parse("StateDiagram-V2\n[*] --> A").is_ok());
}
#[test]
fn synthesises_start_node_for_left_star() {
let g = parse("stateDiagram-v2\n[*] --> A").unwrap();
let start = g.node("__start__").unwrap();
assert_eq!(start.label, MARKER_LABEL);
assert_eq!(start.shape, NodeShape::Circle);
assert!(g.has_node("A"));
}
#[test]
fn synthesises_end_node_for_right_star() {
let g = parse("stateDiagram-v2\nA --> [*]").unwrap();
let end = g.node("__end__").unwrap();
assert_eq!(end.label, MARKER_LABEL);
assert_eq!(end.shape, NodeShape::DoubleCircle);
}
#[test]
fn start_and_end_can_coexist() {
let g = parse("stateDiagram-v2\n[*] --> A\nA --> [*]").unwrap();
assert!(g.has_node("__start__"));
assert!(g.has_node("__end__"));
assert_eq!(g.edges.len(), 2);
}
#[test]
fn top_level_marker_ids_unchanged_regression_guard() {
let g = parse("stateDiagram-v2\n[*] --> A\nA --> [*]").unwrap();
let ids: Vec<&str> = g.nodes.iter().map(|n| n.id.as_str()).collect();
assert!(ids.contains(&"__start__"));
assert!(ids.contains(&"__end__"));
}
#[test]
fn self_transition() {
let g = parse("stateDiagram-v2\nA --> A : retry").unwrap();
assert_eq!(g.edges.len(), 1);
assert_eq!(g.edges[0].from, "A");
assert_eq!(g.edges[0].to, "A");
assert_eq!(g.edges[0].label.as_deref(), Some("retry"));
}
#[test]
fn description_lines_accumulate() {
let src = "stateDiagram-v2\nA : line one\nA : line two\nA : line three";
let g = parse(src).unwrap();
assert_eq!(g.node("A").unwrap().label, "line one\nline two\nline three");
}
#[test]
fn explicit_label_form() {
let g = parse("stateDiagram-v2\nstate \"Hello World\" as A").unwrap();
assert_eq!(g.node("A").unwrap().label, "Hello World");
}
#[test]
fn explicit_label_with_quoted_newline() {
let g = parse("stateDiagram-v2\nstate \"Top\\nBottom\" as A").unwrap();
assert_eq!(g.node("A").unwrap().label, "Top\nBottom");
}
#[test]
fn explicit_label_overrides_descriptions() {
let src =
"stateDiagram-v2\nA : description that should be ignored\nstate \"Real Label\" as A";
let g = parse(src).unwrap();
assert_eq!(g.node("A").unwrap().label, "Real Label");
}
#[test]
fn colons_in_labels_preserved() {
let g = parse("stateDiagram-v2\nA --> B : key: value").unwrap();
assert_eq!(g.edges[0].label.as_deref(), Some("key: value"));
}
#[test]
fn colons_in_descriptions_preserved() {
let g = parse("stateDiagram-v2\nA : status: active").unwrap();
assert_eq!(g.node("A").unwrap().label, "status: active");
}
#[test]
fn direction_override() {
let g = parse("stateDiagram-v2\ndirection LR\n[*] --> A").unwrap();
assert_eq!(g.direction, Direction::LeftToRight);
}
#[test]
fn default_direction_is_top_to_bottom() {
let g = parse("stateDiagram-v2\n[*] --> A").unwrap();
assert_eq!(g.direction, Direction::TopToBottom);
}
#[test]
fn comments_skipped() {
let src = "stateDiagram-v2\n%% this is a comment\nA --> B %% inline\n%% another";
let g = parse(src).unwrap();
assert_eq!(g.edges.len(), 1);
}
#[test]
fn single_line_note_silently_skipped() {
let g = parse("stateDiagram-v2\nA --> B\nnote right of A : hello").unwrap();
assert_eq!(g.edges.len(), 1);
}
#[test]
fn multi_line_note_silently_skipped() {
let src =
"stateDiagram-v2\nA --> B\nnote right of A\n some text\n more text\nend note\nB --> C";
let g = parse(src).unwrap();
assert_eq!(g.edges.len(), 2);
}
#[test]
fn classdef_and_style_silently_skipped() {
let src = "stateDiagram-v2\nclassDef foo fill:#f00\nclass A foo\nstyle A fill:#0f0\nA --> B";
let g = parse(src).unwrap();
assert_eq!(g.edges.len(), 1);
}
#[test]
fn shape_modifier_silently_treated_as_plain() {
let src = "stateDiagram-v2\nstate ifState <<choice>>\nA --> ifState\nifState --> B";
let g = parse(src).unwrap();
assert!(g.has_node("ifState"));
assert_eq!(g.edges.len(), 2);
}
#[test]
fn states_appear_in_source_order() {
let g = parse("stateDiagram-v2\n[*] --> CLOSED\nCLOSED --> OPEN").unwrap();
let ids: Vec<&str> = g.nodes.iter().map(|n| n.id.as_str()).collect();
assert_eq!(ids, vec!["__start__", "CLOSED", "OPEN"]);
}
#[test]
fn simple_composite() {
let src = "stateDiagram-v2
state X {
Inner1 --> Inner2
}";
let g = parse(src).unwrap();
assert_eq!(g.subgraphs.len(), 1);
let sg = &g.subgraphs[0];
assert_eq!(sg.id, "X");
assert_eq!(sg.label, "X");
assert_eq!(sg.node_ids, vec!["Inner1", "Inner2"]);
assert!(g.has_node("Inner1"));
assert!(g.has_node("Inner2"));
assert_eq!(g.edges.len(), 1);
assert!(g.node("X").is_none());
}
#[test]
fn composite_with_internal_star_uses_scoped_marker() {
let src = "stateDiagram-v2
state X {
[*] --> Inner
Inner --> [*]
}";
let g = parse(src).unwrap();
assert!(!g.has_node("__start__"));
assert!(!g.has_node("__end__"));
assert!(g.has_node("__start__X"));
assert!(g.has_node("__end__X"));
let sg = &g.subgraphs[0];
assert!(sg.node_ids.contains(&"__start__X".to_string()));
assert!(sg.node_ids.contains(&"__end__X".to_string()));
assert!(sg.node_ids.contains(&"Inner".to_string()));
}
#[test]
fn external_edge_into_composite_rewrites_to_scoped_start() {
let src = "stateDiagram-v2
[*] --> Active
state Active {
[*] --> Inner
Inner --> Inner
}";
let g = parse(src).unwrap();
let edge = g.edges.iter().find(|e| e.from == "__start__").unwrap();
assert_eq!(edge.to, "__start__Active");
let sg = g.subgraphs.iter().find(|s| s.id == "Active").unwrap();
assert!(sg.node_ids.contains(&"__start__Active".to_string()));
}
#[test]
fn orphan_markers_are_dropped_by_gc() {
let src = "stateDiagram-v2
state Active {
[*] --> Idle
Idle --> Idle
}
Active --> [*]";
let g = parse(src).unwrap();
assert!(
g.node("__end__Active").is_none(),
"orphan __end__Active should be dropped"
);
assert!(
g.node("__end__").is_none(),
"orphan __end__ should be dropped"
);
assert!(g.has_node("__start__Active"));
assert!(g.has_node("Idle"));
}
#[test]
fn external_edge_out_of_composite_rewrites_to_scoped_end() {
let src = "stateDiagram-v2
state Active {
Inner --> Inner
}
Active --> Done";
let g = parse(src).unwrap();
let edge = g.edges.iter().find(|e| e.to == "Done").unwrap();
assert_eq!(edge.from, "__end__Active");
let sg = g.subgraphs.iter().find(|s| s.id == "Active").unwrap();
assert!(sg.node_ids.contains(&"__end__Active".to_string()));
}
#[test]
fn nested_composites() {
let src = "stateDiagram-v2
state Outer {
state Inner {
Leaf --> Leaf
}
}";
let g = parse(src).unwrap();
assert_eq!(g.subgraphs.len(), 2);
let outer = g.subgraphs.iter().find(|s| s.id == "Outer").unwrap();
let inner = g.subgraphs.iter().find(|s| s.id == "Inner").unwrap();
assert_eq!(outer.subgraph_ids, vec!["Inner"]);
assert_eq!(inner.node_ids, vec!["Leaf"]);
}
#[test]
fn nested_composite_marker_id_uses_full_path() {
let src = "stateDiagram-v2
state Outer {
state Inner {
[*] --> Leaf
}
}";
let g = parse(src).unwrap();
assert!(g.has_node("__start__Outer__Inner"));
}
#[test]
fn per_composite_direction_override() {
let src = "stateDiagram-v2
direction TB
state X {
direction LR
A --> B
}";
let g = parse(src).unwrap();
assert_eq!(g.direction, Direction::TopToBottom);
let sg = g.subgraphs.iter().find(|s| s.id == "X").unwrap();
assert_eq!(sg.direction, Some(Direction::LeftToRight));
}
#[test]
fn composite_explicit_label_form() {
let src = "stateDiagram-v2
state \"Display Name\" as X {
A --> B
}";
let g = parse(src).unwrap();
let sg = g.subgraphs.iter().find(|s| s.id == "X").unwrap();
assert_eq!(sg.label, "Display Name");
}
#[test]
fn unterminated_composite_returns_error() {
let src = "stateDiagram-v2
state X {
A --> B";
let err = parse(src).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("missing its closing"),
"expected unterminated-composite error, got: {msg}"
);
}
#[test]
fn stray_closing_brace_at_top_level_returns_error() {
let src = "stateDiagram-v2
A --> B
}";
let err = parse(src).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("unexpected '}'"),
"expected stray-brace error, got: {msg}"
);
}
#[test]
fn composite_id_does_not_appear_as_a_node() {
let src = "stateDiagram-v2
state Active {
Inner --> Inner
}
[*] --> Active";
let g = parse(src).unwrap();
assert!(g.node("Active").is_none(), "composite id leaked as a node");
assert!(g.subgraphs.iter().any(|s| s.id == "Active"));
}
}