use std::collections::{HashMap, HashSet};
use crate::{
Error,
parser::common::{
NoteSide, apply_pending_classes, extract_class_modifier, matches_keyword,
parse_class_def_directive, parse_class_directive, parse_link_style_directive,
parse_note_anchor, parse_style_directive, strip_inline_comment, strip_keyword_prefix,
},
parser::flowchart::parse_click_directive,
types::{
BarOrientation, Direction, Edge, EdgeEndpoint, EdgeStyle, 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(':')
{
let mut text_lines: Vec<String> = Vec::new();
while i < lines.len() {
let body = lines[i].trim();
i += 1;
if body == "end note" {
break;
}
if !body.is_empty() {
text_lines.push(body.to_string());
}
}
let joined = format!("note {} : {}", rest, text_lines.join("\n"));
out.push((lineno, joined));
continue;
}
out.push((lineno, stripped.to_string()));
}
Ok(out)
}
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>,
pending_bar_kinds: HashSet<String>,
anon_choice_by_scope: HashMap<String, String>,
pending_classes: Vec<(String, String)>,
style_scratch: Graph,
note_counter: usize,
choice_counter: usize,
anonymous_choice_ids: HashSet<String>,
}
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::LeftToRight,
composite_ids: HashSet::new(),
composite_order: Vec::new(),
composite_path: HashMap::new(),
composite_members: HashMap::new(),
composite_children: HashMap::new(),
composite_directions: HashMap::new(),
pending_bar_kinds: HashSet::new(),
anon_choice_by_scope: HashMap::new(),
pending_classes: Vec::new(),
style_scratch: Graph::new(Direction::LeftToRight),
note_counter: 0,
choice_counter: 0,
anonymous_choice_ids: HashSet::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 let Some(rest) = stmt.strip_prefix("note ") {
self.handle_note(rest, path);
i += 1;
continue;
}
if matches_keyword(stmt, "classDef") {
parse_class_def_directive(stmt, &mut self.style_scratch);
i += 1;
continue;
}
if matches_keyword(stmt, "class") {
parse_class_directive(stmt, &mut self.pending_classes);
i += 1;
continue;
}
if matches_keyword(stmt, "style") {
parse_style_directive(stmt, &mut self.style_scratch);
i += 1;
continue;
}
if matches_keyword(stmt, "linkStyle") {
parse_link_style_directive(stmt, &mut self.style_scratch);
i += 1;
continue;
}
if matches_keyword(stmt, "click") {
parse_click_directive(stmt, &mut self.style_scratch);
i += 1;
continue;
}
if 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_clean, from_classes) = extract_class_modifier(&from);
let (to_clean, to_classes) = extract_class_modifier(&to);
let from_id = self.resolve_endpoint(&from_clean, EndpointSide::Source, path);
let to_id = self.resolve_endpoint(&to_clean, EndpointSide::Destination, path);
for c in from_classes {
self.pending_classes.push((from_id.clone(), c));
}
for c in to_classes {
self.pending_classes.push((to_id.clone(), c));
}
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 handle_note(&mut self, body: &str, path: &[String]) {
let Some(colon_pos) = body.find(':') else {
return;
};
let anchor_part = body[..colon_pos].trim();
let text = body[colon_pos + 1..].trim().to_string();
let Some((side, anchor)) = parse_note_anchor(anchor_part) else {
return;
};
if anchor.contains(',') {
return;
}
self.register_note(&anchor, side, text, path);
}
fn register_note(&mut self, anchor: &str, side: NoteSide, text: String, path: &[String]) {
self.note_counter += 1;
let note_id = format!("__note_{}__", self.note_counter);
self.register_node(¬e_id, path);
self.shapes.insert(note_id.clone(), NodeShape::Note);
self.explicit_labels.insert(note_id.clone(), text);
self.register_node(anchor, path);
let (from, to) = match side {
NoteSide::Left => (note_id.clone(), anchor.to_string()),
NoteSide::Right | NoteSide::Over => (anchor.to_string(), note_id.clone()),
};
let mut edge = Edge::new(from, to, None);
edge.style = EdgeStyle::Dotted;
edge.end = EdgeEndpoint::None;
edge.start = EdgeEndpoint::None;
self.edges.push(edge);
}
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;
}
if raw == "<<choice>>" || raw == "[[choice]]" {
let scope_key = if path.is_empty() {
String::new()
} else {
path.join(PATH_SEP)
};
let id = if let Some(existing) = self.anon_choice_by_scope.get(&scope_key) {
existing.clone()
} else {
self.choice_counter += 1;
let new_id = if scope_key.is_empty() {
format!("__choice_{}__", self.choice_counter)
} else {
format!("__choice_{}_{}__", self.choice_counter, scope_key)
};
self.anon_choice_by_scope.insert(scope_key, new_id.clone());
self.anonymous_choice_ids.insert(new_id.clone());
new_id
};
self.register_node(&id, path);
self.shapes.insert(id.clone(), NodeShape::Diamond);
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 mut parts = body.splitn(2, char::is_whitespace);
let raw_id = parts.next().unwrap_or("").trim().to_string();
if raw_id.is_empty() {
return;
}
let (id, classes) = extract_class_modifier(&raw_id);
self.register_node(&id, path);
for c in classes {
self.pending_classes.push((id.clone(), c));
}
let rest = parts.next().unwrap_or("");
if let Some(kind) = parse_shape_modifier(rest) {
match kind {
ShapeKind::Choice => {
self.shapes.insert(id, NodeShape::Diamond);
}
ShapeKind::ForkOrJoin => {
self.pending_bar_kinds.insert(id);
}
}
}
}
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 {
from: new_from,
to: new_to,
label: edge.label,
style: edge.style,
end: edge.end,
start: edge.start,
});
}
self.edges = rewritten;
}
fn resolve_pending_bars(&mut self) {
if self.pending_bar_kinds.is_empty() {
return;
}
let orientation = match self.direction {
Direction::LeftToRight | Direction::RightToLeft => BarOrientation::Vertical,
Direction::TopToBottom | Direction::BottomToTop => BarOrientation::Horizontal,
};
for id in self.pending_bar_kinds.drain() {
self.shapes.insert(id, NodeShape::Bar(orientation));
}
}
fn materialise(mut self) -> Result<Graph, Error> {
self.resolve_pending_bars();
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 self.anonymous_choice_ids.contains(id) {
String::new()
} 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;
graph.node_styles = self.style_scratch.node_styles;
graph.edge_styles = self.style_scratch.edge_styles;
graph.class_defs = self.style_scratch.class_defs;
graph.subgraph_styles = self.style_scratch.subgraph_styles;
graph.click_targets = self.style_scratch.click_targets;
apply_pending_classes(&mut graph, &self.pending_classes);
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,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ShapeKind {
Choice,
ForkOrJoin,
}
fn parse_shape_modifier(rest: &str) -> Option<ShapeKind> {
match rest.trim() {
"<<choice>>" | "[[choice]]" => Some(ShapeKind::Choice),
"<<fork>>" | "[[fork]]" | "<<join>>" | "[[join]]" => Some(ShapeKind::ForkOrJoin),
_ => None,
}
}
fn mangle_marker(prefix: &str, path: &[String]) -> String {
if path.is_empty() {
prefix.to_string()
} else {
format!("{prefix}{}", path.join(PATH_SEP))
}
}
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 label_colon = find_label_colon(after);
let (dest_raw, label) = if let Some(colon_pos) = label_colon {
(
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 find_label_colon(s: &str) -> Option<usize> {
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b':' {
if i + 2 < bytes.len() && bytes[i + 1] == b':' && bytes[i + 2] == b':' {
i += 3;
continue;
}
return Some(i);
}
i += 1;
}
None
}
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))
}
#[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_left_to_right() {
let g = parse("stateDiagram-v2\n[*] --> A").unwrap();
assert_eq!(g.direction, Direction::LeftToRight);
}
#[test]
fn explicit_direction_tb_still_honoured() {
let g = parse("stateDiagram-v2\ndirection TB\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_now_synthesises_an_edge() {
let g = parse("stateDiagram-v2\nA --> B\nnote right of A : hello").unwrap();
assert_eq!(g.edges.len(), 2, "1 user edge + 1 note connector");
assert!(g.has_node("__note_1__"));
}
#[test]
fn multi_line_note_now_synthesises_an_edge() {
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(), 3, "2 user edges + 1 note connector");
assert_eq!(g.node("__note_1__").unwrap().label, "some text\nmore text");
}
#[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 choice_modifier_assigns_diamond_shape() {
let g = parse("stateDiagram-v2\nstate D <<choice>>\n[*] --> D").unwrap();
assert_eq!(g.node("D").unwrap().shape, NodeShape::Diamond);
}
#[test]
fn fork_modifier_assigns_bar_perpendicular_to_flow() {
let g = parse("stateDiagram-v2\nstate F <<fork>>\n[*] --> F").unwrap();
assert_eq!(
g.node("F").unwrap().shape,
NodeShape::Bar(BarOrientation::Vertical)
);
let g = parse("stateDiagram-v2\ndirection TB\nstate F <<fork>>\n[*] --> F").unwrap();
assert_eq!(
g.node("F").unwrap().shape,
NodeShape::Bar(BarOrientation::Horizontal)
);
}
#[test]
fn join_modifier_uses_same_shape_as_fork() {
let g = parse("stateDiagram-v2\nstate J <<join>>\n[*] --> J").unwrap();
assert_eq!(
g.node("J").unwrap().shape,
NodeShape::Bar(BarOrientation::Vertical)
);
}
#[test]
fn double_bracket_shape_modifier_variants_accepted() {
let g = parse("stateDiagram-v2\nstate D [[choice]]\n[*] --> D").unwrap();
assert_eq!(g.node("D").unwrap().shape, NodeShape::Diamond);
let g = parse("stateDiagram-v2\nstate F [[fork]]\n[*] --> F").unwrap();
assert_eq!(
g.node("F").unwrap().shape,
NodeShape::Bar(BarOrientation::Vertical)
);
let g = parse("stateDiagram-v2\nstate J [[join]]\n[*] --> J").unwrap();
assert_eq!(
g.node("J").unwrap().shape,
NodeShape::Bar(BarOrientation::Vertical)
);
}
#[test]
fn unrecognised_modifier_falls_through_to_default_shape() {
let g = parse("stateDiagram-v2\nstate X <<typo>>\n[*] --> X").unwrap();
assert_eq!(g.node("X").unwrap().shape, NodeShape::Rounded);
}
#[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 state_diagram_classdef_records_palette() {
let src = "stateDiagram-v2
A --> B
classDef cache fill:#234,stroke:#9cf";
let g = parse(src).unwrap();
let style = g.class_defs.get("cache").copied().unwrap();
assert_eq!(style.fill, Some(crate::types::Rgb(0x22, 0x33, 0x44)));
assert_eq!(style.stroke, Some(crate::types::Rgb(0x99, 0xcc, 0xff)));
}
#[test]
fn state_diagram_class_directive_applies_to_states() {
let src = "stateDiagram-v2
A --> B
classDef hot fill:#f00
class A,B hot";
let g = parse(src).unwrap();
assert_eq!(
g.node_styles.get("A").and_then(|s| s.fill),
Some(crate::types::Rgb(0xff, 0, 0))
);
assert_eq!(
g.node_styles.get("B").and_then(|s| s.fill),
Some(crate::types::Rgb(0xff, 0, 0))
);
}
#[test]
fn state_diagram_triple_colon_inline_on_transition_endpoint() {
let src = "stateDiagram-v2
A:::warm --> B:::cold
classDef warm fill:#f00
classDef cold fill:#00f";
let g = parse(src).unwrap();
assert_eq!(
g.node_styles.get("A").and_then(|s| s.fill),
Some(crate::types::Rgb(0xff, 0, 0))
);
assert_eq!(
g.node_styles.get("B").and_then(|s| s.fill),
Some(crate::types::Rgb(0, 0, 0xff))
);
}
#[test]
fn state_diagram_class_on_composite_lands_in_subgraph_styles() {
let src = "stateDiagram-v2
state Active {
Inner --> Inner
}
classDef accent stroke:#abc
class Active accent";
let g = parse(src).unwrap();
assert_eq!(
g.subgraph_styles.get("Active").and_then(|s| s.stroke),
Some(crate::types::Rgb(0xaa, 0xbb, 0xcc))
);
assert!(!g.node_styles.contains_key("Active"));
}
#[test]
fn state_diagram_triple_colon_on_star_marker_attaches_to_mangled_id() {
let src = "stateDiagram-v2
[*]:::started --> A
classDef started fill:#0f0";
let g = parse(src).unwrap();
assert_eq!(
g.node_styles.get("__start__").and_then(|s| s.fill),
Some(crate::types::Rgb(0, 0xff, 0))
);
}
#[test]
fn state_diagram_style_directive_no_longer_silently_skipped() {
let src = "stateDiagram-v2
[*] --> A
style A fill:#abc";
let g = parse(src).unwrap();
assert_eq!(
g.node_styles.get("A").and_then(|s| s.fill),
Some(crate::types::Rgb(0xaa, 0xbb, 0xcc))
);
}
fn note_connector_count(g: &Graph) -> usize {
g.edges
.iter()
.filter(|e| {
e.style == crate::types::EdgeStyle::Dotted
&& e.end == crate::types::EdgeEndpoint::None
&& e.start == crate::types::EdgeEndpoint::None
})
.count()
}
#[test]
fn note_left_of_creates_note_node_with_dotted_edge() {
let g = parse("stateDiagram-v2\nA --> B\nnote left of A : hello").unwrap();
let note = g.node("__note_1__").expect("note node missing");
assert_eq!(note.shape, NodeShape::Note);
assert_eq!(note.label, "hello");
let edge = g
.edges
.iter()
.find(|e| e.from == "__note_1__")
.expect("note → anchor edge missing");
assert_eq!(edge.to, "A");
assert_eq!(edge.style, crate::types::EdgeStyle::Dotted);
assert_eq!(edge.end, crate::types::EdgeEndpoint::None);
assert_eq!(edge.start, crate::types::EdgeEndpoint::None);
}
#[test]
fn note_right_of_creates_anchor_to_note_edge() {
let g = parse("stateDiagram-v2\nA --> B\nnote right of A : hello").unwrap();
let edge = g
.edges
.iter()
.find(|e| e.to == "__note_1__")
.expect("anchor → note edge missing");
assert_eq!(edge.from, "A");
}
#[test]
fn note_over_treated_as_right_of_for_v1() {
let g = parse("stateDiagram-v2\nA --> B\nnote over A : hello").unwrap();
let edge = g
.edges
.iter()
.find(|e| e.to == "__note_1__")
.expect("anchor → note edge missing for `over`");
assert_eq!(edge.from, "A");
}
#[test]
fn multiline_note_joins_lines_into_label() {
let src = "stateDiagram-v2
A --> B
note right of A
first line
second line
end note";
let g = parse(src).unwrap();
let note = g.node("__note_1__").unwrap();
assert_eq!(note.label, "first line\nsecond line");
}
#[test]
fn multiple_notes_get_distinct_synthetic_ids() {
let src = "stateDiagram-v2
A --> B
note left of A : first
note right of B : second";
let g = parse(src).unwrap();
assert!(g.has_node("__note_1__"));
assert!(g.has_node("__note_2__"));
assert_eq!(g.node("__note_1__").unwrap().label, "first");
assert_eq!(g.node("__note_2__").unwrap().label, "second");
assert_eq!(note_connector_count(&g), 2);
}
#[test]
fn floating_note_silently_skipped() {
let src = "stateDiagram-v2
A --> B
note \"floating text\" as N1";
let g = parse(src).unwrap();
assert!(g.node("__note_1__").is_none());
assert_eq!(note_connector_count(&g), 0);
}
#[test]
fn note_over_multi_anchor_silently_skipped() {
let src = "stateDiagram-v2\nA --> B\nnote over A,B : shared";
let g = parse(src).unwrap();
assert!(g.node("__note_1__").is_none());
assert_eq!(note_connector_count(&g), 0);
}
#[test]
fn note_inside_composite_is_a_member_of_that_composite() {
let src = "stateDiagram-v2
state Active {
Idle --> Working
note right of Idle : worker pool size = 4
}";
let g = parse(src).unwrap();
let active = g.subgraphs.iter().find(|s| s.id == "Active").unwrap();
assert!(
active.node_ids.contains(&"__note_1__".to_string()),
"note must be registered as a member of its enclosing composite"
);
}
#[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"));
}
#[test]
fn named_choice_keeps_user_label() {
let g = parse(
"stateDiagram-v2\nstate if_state <<choice>>\n[*] --> if_state\nif_state --> True\nif_state --> False",
)
.unwrap();
let node = g.node("if_state").expect("named choice node must exist");
assert_eq!(node.shape, NodeShape::Diamond);
assert_eq!(
node.label, "if_state",
"named choice must keep its user-supplied id as label"
);
}
#[test]
fn anonymous_choice_has_none_label() {
let g = parse(
"stateDiagram-v2\n[*] --> <<choice>>\n<<choice>> --> True: condition\n<<choice>> --> False: !condition",
)
.unwrap();
let diamonds: Vec<&crate::types::Node> = g
.nodes
.iter()
.filter(|n| n.shape == NodeShape::Diamond)
.collect();
assert_eq!(diamonds.len(), 1, "exactly one anonymous choice diamond");
let node = diamonds[0];
assert!(
node.label.is_empty(),
"anonymous choice must have an empty label, got {:?}",
node.label
);
assert_ne!(
node.label, node.id,
"synthetic id must not leak into the label"
);
}
#[test]
fn anonymous_choice_all_occurrences_same_scope_share_one_node() {
let g = parse("stateDiagram-v2\n[*] --> <<choice>>\n<<choice>> --> A\n<<choice>> --> B")
.unwrap();
let diamonds: Vec<&crate::types::Node> = g
.nodes
.iter()
.filter(|n| n.shape == NodeShape::Diamond)
.collect();
assert_eq!(diamonds.len(), 1, "single anonymous choice per scope");
assert_eq!(g.edges.len(), 3);
}
#[test]
fn anonymous_choice_double_bracket_syntax() {
let g = parse("stateDiagram-v2\n[*] --> [[choice]]\n[[choice]] --> Done: ok").unwrap();
let diamonds: Vec<&crate::types::Node> = g
.nodes
.iter()
.filter(|n| n.shape == NodeShape::Diamond)
.collect();
assert_eq!(diamonds.len(), 1);
assert!(
diamonds[0].label.is_empty(),
"[[choice]] anonymous form must also suppress the synthetic label"
);
}
}