use std::collections::HashMap;
use super::model::{Direction, Kind, NotePosition, ParseError, StateDiagram, Transition};
use crate::preview::mermaid::flowchart::preprocess::preprocess;
use crate::preview::mermaid::flowchart::text::decode_label;
const MAX_TRANSITIONS: usize = 500;
#[cfg(test)]
pub const MAX_TRANSITIONS_FOR_TESTS: usize = MAX_TRANSITIONS;
const MAX_DEPTH: usize = 32;
pub fn parse(src: &str) -> Result<StateDiagram, ParseError> {
let pre = preprocess(src);
let lines: Vec<&str> = pre.text.split('\n').collect();
let header = find_header(&lines)?;
let mut scanner = Scanner::new();
scanner.line(&header.trailing, header.line_index + 1)?;
for (i, line) in lines.iter().enumerate().skip(header.line_index + 1) {
scanner.line(line, i + 1)?;
}
let mut doc = scanner.finish()?;
let mut counter = Counters::default();
translate("root", &mut doc, &mut counter, 0);
let mut out = StateDiagram::new(scanner.direction.unwrap_or(header.direction), pre.title);
out.acc_title = scanner.acc_title.take();
out.acc_descr = scanner.acc_descr.take();
let mut collector = Collector::default();
let roots = collector.collect(&mut out, None, &doc, 0);
out.roots = roots;
collector.finish(&mut out);
for (ids, style) in &scanner.class_statements {
for id in ids {
if let Some(state) = out.state_mut(id) {
if !state.classes.contains(style) {
state.classes.push(style.clone());
}
}
}
}
if out.states.is_empty() {
return Err(ParseError::NoStates);
}
Ok(out)
}
pub fn is_state_diagram(src: &str) -> bool {
let pre = preprocess(src);
let lines: Vec<&str> = pre.text.split('\n').collect();
find_header(&lines).is_ok()
}
struct Header {
direction: Direction,
line_index: usize,
trailing: String,
}
const STATE_KEYWORDS: &[&str] = &["stateDiagram-v2", "stateDiagram"];
fn find_header(lines: &[&str]) -> Result<Header, ParseError> {
for (i, line) in lines.iter().enumerate() {
if line.trim().is_empty() {
continue;
}
let t = line.trim_start();
for kw in STATE_KEYWORDS {
let Some(rest) = t.strip_prefix(kw) else {
continue;
};
if rest.chars().next().is_some_and(is_plain_id_char) {
continue;
}
return Ok(Header {
direction: Direction::default(),
line_index: i,
trailing: rest.to_string(),
});
}
let header: String = t
.chars()
.take_while(|c| !c.is_whitespace())
.take(40)
.collect();
return Err(ParseError::NotAStateDiagram { header });
}
Err(ParseError::Empty)
}
fn is_plain_id_char(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Endpoint {
id: String,
kind: Kind,
classes: Vec<String>,
}
impl Endpoint {
fn new(id: &str) -> Endpoint {
let (id, classes) = split_style_separator(id.trim());
Endpoint {
id,
kind: Kind::Simple,
classes,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct RawState {
id: String,
kind: Kind,
descriptions: Vec<String>,
classes: Vec<String>,
note: Option<(NotePosition, String)>,
doc: Option<Vec<Raw>>,
}
impl RawState {
fn new(id: &str, kind: Kind) -> RawState {
let (id, classes) = split_style_separator(id.trim());
RawState {
id,
kind,
descriptions: Vec::new(),
classes,
note: None,
doc: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Raw {
State(RawState),
Relation {
from: Endpoint,
to: Endpoint,
descr: Option<String>,
},
Divider,
}
struct Frame {
owner: Option<RawState>,
body: Vec<Raw>,
line: usize,
}
struct PendingNote {
target: String,
position: NotePosition,
lines: Vec<String>,
line: usize,
}
struct Scanner {
stack: Vec<Frame>,
note: Option<PendingNote>,
acc_descr_block: bool,
direction: Option<Direction>,
acc_title: Option<String>,
acc_descr: Option<String>,
class_statements: Vec<(Vec<String>, String)>,
}
impl Scanner {
fn new() -> Scanner {
Scanner {
stack: vec![Frame {
owner: None,
body: Vec::new(),
line: 0,
}],
note: None,
acc_descr_block: false,
direction: None,
acc_title: None,
acc_descr: None,
class_statements: Vec::new(),
}
}
fn body(&mut self) -> &mut Vec<Raw> {
let last = self.stack.len() - 1;
&mut self.stack[last].body
}
fn line(&mut self, raw: &str, line: usize) -> Result<(), ParseError> {
if self.acc_descr_block {
if let Some(end) = raw.find('}') {
self.push_acc_descr(&raw[..end]);
self.acc_descr_block = false;
} else {
self.push_acc_descr(raw);
}
return Ok(());
}
if let Some(note) = &mut self.note {
if raw.trim() == "end note" {
let note = self.note.take().expect("note is open");
self.attach_note(note);
} else {
note.lines.push(raw.trim().to_string());
}
return Ok(());
}
let text = strip_comment(raw);
if let Some(rest) = acc_descr_block_start(&text) {
match rest.find('}') {
Some(end) => self.push_acc_descr(&rest[..end]),
None => {
self.push_acc_descr(rest);
self.acc_descr_block = true;
}
}
return Ok(());
}
for seg in segments(&text, line)? {
match seg {
Segment::Statement(s) => self.statement(&s, line)?,
Segment::Open(head) => self.open(&head, line),
Segment::Close => self.close(),
}
}
Ok(())
}
fn push_acc_descr(&mut self, text: &str) {
let t = text.trim();
if t.is_empty() {
return;
}
match &mut self.acc_descr {
Some(existing) => {
existing.push('\n');
existing.push_str(t);
}
None => self.acc_descr = Some(t.to_string()),
}
}
fn open(&mut self, head: &str, line: usize) {
let owner = if self.stack.len() > MAX_DEPTH {
None
} else {
read_state_keyword(head)
};
self.stack.push(Frame {
owner,
body: Vec::new(),
line,
});
}
fn close(&mut self) {
if self.stack.len() < 2 {
return;
}
let frame = self.stack.pop().expect("checked above");
match frame.owner {
Some(mut owner) => {
owner.doc = Some(frame.body);
self.body().push(Raw::State(owner));
}
None => {
let body = frame.body;
self.body().extend(body);
}
}
}
fn finish(&mut self) -> Result<Vec<Raw>, ParseError> {
if let Some(note) = self.note.take() {
return Err(ParseError::UnclosedNote { line: note.line });
}
if self.stack.len() > 1 {
let frame = &self.stack[1];
return Err(ParseError::UnclosedComposite {
id: frame
.owner
.as_ref()
.map_or_else(|| "{".to_string(), |o| o.id.clone()),
line: frame.line,
});
}
Ok(std::mem::take(&mut self.stack[0].body))
}
fn statement(&mut self, s: &str, line: usize) -> Result<(), ParseError> {
let t = s.trim();
if t.is_empty() {
return Ok(());
}
if t == "--" {
if self.stack.len() > 1 {
self.body().push(Raw::Divider);
}
return Ok(());
}
if let Some(dir) = direction_statement(t) {
if self.stack.len() == 1 {
self.direction = Some(dir);
}
return Ok(());
}
if let Some(v) = keyword_value(t, "accTitle") {
self.acc_title = Some(v);
return Ok(());
}
if let Some(v) = keyword_value(t, "accDescr") {
self.acc_descr = Some(v);
return Ok(());
}
if t == "accDescr" || t.starts_with("accDescr ") {
return Ok(());
}
if is_ignored_statement(t) {
self.apply_class_statement(t);
return Ok(());
}
if let Some(rest) = keyword(t, "note") {
return self.note_statement(rest, line);
}
if let Some(rest) = keyword(t, "state") {
let state = read_state_body(rest);
self.body().push(Raw::State(state));
return Ok(());
}
if let Some((left, right)) = split_transition(t) {
let (right, descr) = split_description(&right);
let from = Endpoint::new(&left);
let to = Endpoint::new(&right);
if from.id.is_empty() || to.id.is_empty() || to.id.contains("-->") {
return Ok(());
}
self.body().push(Raw::Relation {
from,
to,
descr: descr.map(|d| decode_label(&d)),
});
return Ok(());
}
let (head, descr) = split_description(t);
let mut state = RawState::new(&head, Kind::Simple);
if let Some(d) = descr {
state.descriptions.push(decode_label(&d));
}
self.body().push(Raw::State(state));
Ok(())
}
fn apply_class_statement(&mut self, t: &str) {
let Some(rest) = keyword(t, "class") else {
return;
};
let mut parts = rest.trim().splitn(2, char::is_whitespace);
let (Some(ids), Some(style)) = (parts.next(), parts.next()) else {
return;
};
let style = style.trim().to_string();
if style.is_empty() {
return;
}
let ids: Vec<String> = ids
.split(',')
.map(|i| i.trim().to_string())
.filter(|i| !i.is_empty())
.collect();
if !ids.is_empty() {
self.class_statements.push((ids, style));
}
}
fn note_statement(&mut self, rest: &str, line: usize) -> Result<(), ParseError> {
let r = rest.trim_start();
if r.starts_with('"') {
return Ok(());
}
let (position, after) = if let Some(a) = r.strip_prefix("right of") {
(NotePosition::Right, a)
} else if let Some(a) = r.strip_prefix("left of") {
(NotePosition::Left, a)
} else {
return Ok(());
};
let (target, text) = split_description(after.trim());
let target = target.trim().to_string();
if target.is_empty() {
return Ok(());
}
match text {
Some(t) => {
self.attach_note(PendingNote {
target,
position,
lines: vec![t],
line,
});
Ok(())
}
None => {
self.note = Some(PendingNote {
target,
position,
lines: Vec::new(),
line,
});
Ok(())
}
}
}
fn attach_note(&mut self, note: PendingNote) {
let text = note
.lines
.iter()
.map(|l| decode_label(l))
.collect::<Vec<_>>()
.join("\n");
let text = text.trim_matches('\n').to_string();
if text.is_empty() {
return;
}
let mut state = RawState::new(¬e.target, Kind::Simple);
state.note = Some((note.position, text));
self.body().push(Raw::State(state));
}
}
fn acc_descr_block_start(line: &str) -> Option<&str> {
let t = line.trim_start();
let rest = t.strip_prefix("accDescr")?;
let rest = rest.trim_start();
rest.strip_prefix('{')
}
fn is_ignored_statement(t: &str) -> bool {
for kw in ["classDef", "class", "style", "click", "scale"] {
if keyword(t, kw).is_some() {
return true;
}
}
t == "hide empty description" || t == "end note"
}
fn keyword<'a>(s: &'a str, kw: &str) -> Option<&'a str> {
let rest = s.strip_prefix(kw)?;
if rest.is_empty() {
return None;
}
if !rest.starts_with(char::is_whitespace) {
return None;
}
Some(rest)
}
fn keyword_value(s: &str, kw: &str) -> Option<String> {
let rest = s.strip_prefix(kw)?;
let rest = rest.trim_start();
let rest = rest.strip_prefix(':')?;
Some(rest.trim().to_string())
}
fn direction_statement(s: &str) -> Option<Direction> {
let rest = keyword(s, "direction")?;
Direction::parse(rest.trim())
}
fn split_style_separator(s: &str) -> (String, Vec<String>) {
match s.find(":::") {
Some(i) => {
let classes: Vec<String> = s[i + 3..]
.split(',')
.map(|c| c.trim().to_string())
.filter(|c| !c.is_empty())
.collect();
(s[..i].trim().to_string(), classes)
}
None => (s.trim().to_string(), Vec::new()),
}
}
fn split_description(s: &str) -> (String, Option<String>) {
match descr_colon(s) {
Some(i) => {
let head = s[..i].trim().to_string();
let tail = s[i + 1..].trim();
(head, Some(tail.to_string()))
}
None => (s.trim().to_string(), None),
}
}
fn descr_colon(s: &str) -> Option<usize> {
let bytes = s.as_bytes();
let mut i = 0;
let mut quoted = false;
while i < bytes.len() {
match bytes[i] {
b'"' => quoted = !quoted,
b':' if !quoted => {
if s[i..].starts_with(":::") {
i += 3;
continue;
}
return Some(i);
}
_ => {}
}
i += 1;
}
None
}
fn split_transition(s: &str) -> Option<(String, String)> {
let bytes = s.as_bytes();
let limit = descr_colon(s).unwrap_or(s.len());
let mut i = 0;
let mut quoted = false;
while i + 3 <= limit {
match bytes[i] {
b'"' => quoted = !quoted,
b'-' if !quoted && s[i..].starts_with("-->") => {
return Some((s[..i].to_string(), s[i + 3..].to_string()));
}
_ => {}
}
i += 1;
}
None
}
fn read_state_body(rest: &str) -> RawState {
let r = rest.trim();
for (marker, kind) in [
("<<fork>>", Kind::Fork),
("<<join>>", Kind::Join),
("<<choice>>", Kind::Choice),
("[[fork]]", Kind::Fork),
("[[join]]", Kind::Join),
("[[choice]]", Kind::Choice),
] {
if let Some(i) = rfind_ignore_ascii_case(r, marker) {
let id = r[..i].trim();
if !id.is_empty() {
return RawState::new(id, kind);
}
}
}
if let Some(after_quote) = r.strip_prefix('"') {
if let Some(end) = after_quote.find('"') {
let descr = decode_label(&after_quote[..end]);
let tail = after_quote[end + 1..].trim_start();
let id_part = keyword(tail, "as").unwrap_or(tail).trim();
let (id, extra) = split_description(id_part);
let mut state = RawState::new(&id, Kind::Simple);
state.descriptions.push(descr);
if let Some(e) = extra {
state.descriptions.push(decode_label(&e));
}
return state;
}
}
let (head, descr) = split_description(r);
let id = head.split_whitespace().next().unwrap_or("");
let mut state = RawState::new(id, Kind::Simple);
if let Some(d) = descr {
state.descriptions.push(decode_label(&d));
}
state
}
fn read_state_keyword(head: &str) -> Option<RawState> {
let rest = keyword(head.trim(), "state")?;
let state = read_state_body(rest);
if state.id.is_empty() {
return None;
}
Some(state)
}
fn rfind_ignore_ascii_case(haystack: &str, needle: &str) -> Option<usize> {
let h = haystack.to_ascii_lowercase();
h.rfind(&needle.to_ascii_lowercase())
}
enum Segment {
Statement(String),
Open(String),
Close,
}
fn segments(line: &str, line_no: usize) -> Result<Vec<Segment>, ParseError> {
let mut out = Vec::new();
let bytes = line.as_bytes();
let mut i = 0;
let mut start = 0;
while i < bytes.len() {
match bytes[i] {
b'"' => {
let Some(end) = line[i + 1..].find('"') else {
return Err(ParseError::UnclosedString { line: line_no });
};
i += 1 + end + 1;
continue;
}
b':' => {
if line[i..].starts_with(":::") {
i += 3;
continue;
}
break;
}
b'{' => {
out.push(Segment::Open(line[start..i].to_string()));
i += 1;
start = i;
continue;
}
b'}' => {
out.push(Segment::Statement(line[start..i].to_string()));
out.push(Segment::Close);
i += 1;
start = i;
continue;
}
_ => {}
}
i += 1;
}
out.push(Segment::Statement(line[start..].to_string()));
Ok(out)
}
fn strip_comment(line: &str) -> String {
let limit = descr_colon(line).unwrap_or(line.len());
let bytes = line.as_bytes();
let mut i = 0;
let mut quoted = false;
while i < limit {
let boundary = i == 0 || bytes[i - 1].is_ascii_whitespace();
match bytes[i] {
b'"' => quoted = !quoted,
b'#' if !quoted && boundary => return line[..i].to_string(),
b'%' if !quoted
&& boundary
&& line[i..].starts_with("%%")
&& !line[i..].starts_with("%%{") =>
{
return line[..i].to_string();
}
_ => {}
}
i += 1;
}
line.to_string()
}
#[derive(Default)]
struct Counters {
divider: usize,
}
const EDGE_STATE: &str = "[*]";
fn translate(owner: &str, doc: &mut Vec<Raw>, counter: &mut Counters, depth: usize) {
if depth > MAX_DEPTH {
return;
}
split_dividers(doc, counter);
for item in doc.iter_mut() {
match item {
Raw::Relation { from, to, .. } => {
rename_edge_state(from, owner, true);
rename_edge_state(to, owner, false);
}
Raw::State(s) => {
if s.id == EDGE_STATE {
s.id = format!("{owner}_start");
s.kind = Kind::Start;
}
}
Raw::Divider => {}
}
}
for item in doc.iter_mut() {
if let Raw::State(s) = item {
if let Some(inner) = &mut s.doc {
let id = s.id.clone();
translate(&id, inner, counter, depth + 1);
}
}
}
}
fn rename_edge_state(ep: &mut Endpoint, owner: &str, first: bool) {
if ep.id != EDGE_STATE {
return;
}
ep.id = format!("{owner}_{}", if first { "start" } else { "end" });
ep.kind = if first { Kind::Start } else { Kind::End };
}
fn split_dividers(doc: &mut Vec<Raw>, counter: &mut Counters) {
if !doc.iter().any(|r| matches!(r, Raw::Divider)) {
return;
}
let mut groups: Vec<Vec<Raw>> = Vec::new();
let mut current: Vec<Raw> = Vec::new();
for item in doc.drain(..) {
if matches!(item, Raw::Divider) {
groups.push(std::mem::take(&mut current));
} else {
current.push(item);
}
}
if groups.is_empty() || current.is_empty() {
for g in groups {
doc.extend(g);
}
doc.extend(current);
return;
}
groups.push(current);
for group in groups {
counter.divider += 1;
let mut region =
RawState::new(&format!("divider-id-{}", counter.divider), Kind::Concurrent);
region.doc = Some(group);
doc.push(Raw::State(region));
}
}
#[derive(Default)]
struct Collector {
descriptions: HashMap<String, Vec<String>>,
claimed: HashMap<String, Option<String>>,
transitions: usize,
notes: usize,
}
impl Collector {
fn collect(
&mut self,
out: &mut StateDiagram,
parent: Option<&str>,
doc: &[Raw],
depth: usize,
) -> Vec<String> {
let mut members: Vec<String> = Vec::new();
if depth > MAX_DEPTH {
return members;
}
for item in doc {
match item {
Raw::Divider => {}
Raw::Relation { from, to, descr } => {
self.touch(out, from.id.as_str(), from.kind, parent, &mut members);
self.classes(out, &from.id, &from.classes);
self.touch(out, to.id.as_str(), to.kind, parent, &mut members);
self.classes(out, &to.id, &to.classes);
if self.transitions < MAX_TRANSITIONS {
let id = format!("t{}", self.transitions);
self.transitions += 1;
out.transitions.push(Transition {
id,
from: from.id.clone(),
to: to.id.clone(),
label: descr.clone().filter(|d| !d.trim().is_empty()),
is_note_link: false,
});
}
}
Raw::State(s) => {
if s.id.is_empty() {
continue;
}
let kind = if s.doc.is_some() && !s.kind.is_block() {
Kind::Composite
} else {
s.kind
};
self.touch(out, &s.id, kind, parent, &mut members);
self.classes(out, &s.id, &s.classes);
for d in &s.descriptions {
self.descriptions
.entry(s.id.clone())
.or_default()
.push(d.clone());
}
if let Some((position, text)) = &s.note {
self.add_note(out, &s.id, *position, text, parent, &mut members);
}
if let Some(inner) = &s.doc {
let inner_members =
self.collect(out, Some(s.id.as_str()), inner, depth + 1);
if let Some(state) = out.state_mut(&s.id) {
for m in inner_members {
if !state.members.contains(&m) {
state.members.push(m);
}
}
}
}
}
}
}
members
}
fn touch(
&mut self,
out: &mut StateDiagram,
id: &str,
kind: Kind,
parent: Option<&str>,
members: &mut Vec<String>,
) {
out.intern(id, kind);
match self.claimed.get(id) {
Some(owner) => {
if owner.as_deref() == parent && !members.iter().any(|m| m == id) {
members.push(id.to_string());
}
}
None => {
self.claimed
.insert(id.to_string(), parent.map(str::to_string));
if let Some(state) = out.state_mut(id) {
state.parent = parent.map(str::to_string);
}
members.push(id.to_string());
}
}
}
fn classes(&mut self, out: &mut StateDiagram, id: &str, classes: &[String]) {
if classes.is_empty() {
return;
}
if let Some(state) = out.state_mut(id) {
for c in classes {
if !state.classes.contains(c) {
state.classes.push(c.clone());
}
}
}
}
fn add_note(
&mut self,
out: &mut StateDiagram,
target: &str,
position: NotePosition,
text: &str,
parent: Option<&str>,
members: &mut Vec<String>,
) {
self.notes += 1;
let id = format!("{target}----note-{}", self.notes);
out.intern(&id, Kind::Note);
if let Some(state) = out.state_mut(&id) {
state.label = text.to_string();
state.note_position = Some(position);
state.parent = parent.map(str::to_string);
}
self.claimed.insert(id.clone(), parent.map(str::to_string));
members.push(id.clone());
let (from, to) = match position {
NotePosition::Right => (target.to_string(), id.clone()),
NotePosition::Left => (id.clone(), target.to_string()),
};
out.transitions.push(Transition {
id: format!("note-{}", self.notes),
from,
to,
label: None,
is_note_link: true,
});
}
fn finish(&mut self, out: &mut StateDiagram) {
for state in &mut out.states {
if state.kind == Kind::Note {
continue;
}
let descriptions = self.descriptions.remove(&state.id).unwrap_or_default();
if state.kind.is_textless() {
continue;
}
state.titled = descriptions.len() >= 2;
state.label = if descriptions.is_empty() {
state.id.clone()
} else {
descriptions.join("\n")
};
}
let known: Vec<String> = out.states.iter().map(|s| s.id.clone()).collect();
out.transitions
.retain(|t| known.iter().any(|k| k == &t.from) && known.iter().any(|k| k == &t.to));
}
}