use crate::{
Error,
types::{Direction, Edge, Graph, Node, NodeShape},
};
pub fn parse(input: &str) -> Result<Graph, Error> {
let normalised = input.replace('\n', ";").replace('\r', "");
let mut statements = normalised
.split(';')
.map(str::trim)
.filter(|s| !s.is_empty() && !s.starts_with("%%"));
let direction = parse_header_stmt(&mut statements)?;
let mut graph = Graph::new(direction);
for stmt in statements {
parse_statement(stmt, &mut graph);
}
Ok(graph)
}
fn parse_header_stmt<'a>(stmts: &mut impl Iterator<Item = &'a str>) -> Result<Direction, Error> {
let stmt = stmts
.next()
.ok_or_else(|| Error::ParseError("no 'graph'/'flowchart' header found".to_string()))?;
let mut parts = stmt.splitn(3, |c: char| c.is_whitespace());
let keyword = parts.next().unwrap_or("").to_lowercase();
if keyword != "graph" && keyword != "flowchart" {
return Err(Error::ParseError(format!(
"expected 'graph' or 'flowchart', got '{keyword}'"
)));
}
let dir_str = parts
.next()
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or("TD");
Direction::parse(dir_str)
.ok_or_else(|| Error::ParseError(format!("unknown direction '{dir_str}'")))
}
fn parse_statement(stmt: &str, graph: &mut Graph) {
let first_word = stmt.split_whitespace().next().unwrap_or("");
if matches!(
first_word,
"subgraph" | "end" | "direction" | "style" | "classDef" | "class"
| "click" | "linkStyle" | "accTitle" | "accDescr"
) {
return;
}
if looks_like_edge_chain(stmt) {
parse_edge_chain(stmt, graph);
} else {
if let Some(node) = parse_node_definition(stmt) {
graph.upsert_node(node);
}
}
}
fn looks_like_edge_chain(s: &str) -> bool {
s.contains("-->")
|| s.contains("---")
|| s.contains("-.->")
|| s.contains("==>")
|| s.contains("-- ") }
fn parse_edge_chain(stmt: &str, graph: &mut Graph) {
let tokens = tokenise_chain(stmt);
if tokens.is_empty() {
return;
}
let mut i = 0;
let mut prev_id: Option<String> = None;
let mut pending_edge_label: Option<String> = None;
while i < tokens.len() {
let tok = tokens[i].trim();
if i % 2 == 0 {
if tok.is_empty() {
i += 1;
continue;
}
let node = parse_node_definition(tok).unwrap_or_else(|| {
Node::new(tok, tok, NodeShape::Rectangle)
});
let node_id = node.id.clone();
graph.upsert_node(node);
if let Some(ref from) = prev_id {
let edge = Edge::new(from.clone(), node_id.clone(), pending_edge_label.take());
graph.edges.push(edge);
}
prev_id = Some(node_id);
} else {
pending_edge_label = extract_arrow_label(tok);
}
i += 1;
}
}
fn tokenise_chain(stmt: &str) -> Vec<String> {
let mut tokens: Vec<String> = Vec::new();
let chars: Vec<char> = stmt.chars().collect();
let len = chars.len();
let mut i = 0;
let mut current = String::new();
while i < len {
let ch = chars[i];
if (ch == '-' || ch == '=') && !current.trim().is_empty() {
if is_arrow_start(&chars, i) {
tokens.push(current.trim().to_string());
current = String::new();
let (arrow_tok, consumed) = consume_arrow(&chars, i);
tokens.push(arrow_tok);
i += consumed;
continue;
}
}
current.push(ch);
i += 1;
}
let last = current.trim().to_string();
if !last.is_empty() {
tokens.push(last);
}
tokens
}
fn is_arrow_start(chars: &[char], i: usize) -> bool {
let remaining: String = chars[i..].iter().collect();
remaining.starts_with("-->")
|| remaining.starts_with("---")
|| remaining.starts_with("-.->")
|| remaining.starts_with("==>")
|| remaining.starts_with("-- ") || remaining.starts_with("--")
}
fn consume_arrow(chars: &[char], start: usize) -> (String, usize) {
let remaining: String = chars[start..].iter().collect();
if let Some(arrow) = try_consume_labeled_dash_arrow(&remaining) {
let len = arrow.chars().count();
return (arrow, len);
}
if remaining.starts_with("-.-") {
let base = if remaining.starts_with("-.->") { 4 } else { 3 };
let (label_part, extra) = try_consume_pipe_label(&remaining[base..]);
let tok = format!("{}{label_part}", &remaining[..base]);
return (tok, base + extra);
}
if let Some(rest) = remaining.strip_prefix("==>") {
let (label_part, extra) = try_consume_pipe_label(rest);
let tok = format!("==>{label_part}");
return (tok, 3 + extra);
}
if let Some(rest) = remaining.strip_prefix("-->") {
let (label_part, extra) = try_consume_pipe_label(rest);
let tok = format!("-->{label_part}");
return (tok, 3 + extra);
}
if let Some(rest) = remaining.strip_prefix("---") {
let (label_part, extra) = try_consume_pipe_label(rest);
let tok = format!("---{label_part}");
return (tok, 3 + extra);
}
(remaining[..2].to_string(), 2)
}
fn try_consume_labeled_dash_arrow(s: &str) -> Option<String> {
if !s.starts_with("-- ") {
return None;
}
let rest = &s[3..];
rest.find("-->").map(|end| {
let full_len = 3 + end + 3; s[..full_len].to_string()
})
}
fn try_consume_pipe_label(s: &str) -> (String, usize) {
if let Some(inner) = s.strip_prefix('|')
&& let Some(end) = inner.find('|')
{
let portion = &s[..end + 2]; return (portion.to_string(), end + 2);
}
(String::new(), 0)
}
fn extract_arrow_label(arrow: &str) -> Option<String> {
if let Some(start) = arrow.find('|')
&& let Some(end) = arrow[start + 1..].find('|')
{
let label = arrow[start + 1..start + 1 + end].trim().to_string();
if !label.is_empty() {
return Some(label);
}
}
if arrow.starts_with("-- ")
&& let Some(end) = arrow.rfind("-->")
{
let label = arrow[3..end].trim().to_string();
if !label.is_empty() {
return Some(label);
}
}
None
}
pub fn parse_node_definition(token: &str) -> Option<Node> {
let token = token.trim();
if token.is_empty() {
return None;
}
let shape_start = token.find(['[', '{', '(']);
let (id, label, shape) = if let Some(pos) = shape_start {
let id = token[..pos].trim().to_string();
let rest = &token[pos..];
if rest.starts_with("((") && rest.ends_with("))") {
let inner = rest[2..rest.len() - 2].trim().to_string();
(id, inner, NodeShape::Circle)
} else if rest.starts_with('{') && rest.ends_with('}') {
let inner = rest[1..rest.len() - 1].trim().to_string();
(id, inner, NodeShape::Diamond)
} else if rest.starts_with('[') && rest.ends_with(']') {
let inner = rest[1..rest.len() - 1].trim().to_string();
(id, inner, NodeShape::Rectangle)
} else if rest.starts_with('(') && rest.ends_with(')') {
let inner = rest[1..rest.len() - 1].trim().to_string();
(id, inner, NodeShape::Rounded)
} else {
let id = token.to_string();
(id.clone(), id, NodeShape::Rectangle)
}
} else {
(token.to_string(), token.to_string(), NodeShape::Rectangle)
};
if id.is_empty() {
return None;
}
let label = label
.replace("<br/>", " ")
.replace("<br>", " ")
.replace("<br />", " ");
Some(Node::new(id, label, shape))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::NodeShape;
#[test]
fn parse_simple_lr() {
let g = parse("graph LR\nA-->B-->C").unwrap();
assert_eq!(g.direction, Direction::LeftToRight);
assert!(g.has_node("A"));
assert!(g.has_node("B"));
assert!(g.has_node("C"));
assert_eq!(g.edges.len(), 2);
}
#[test]
fn parse_semicolons() {
let g = parse("graph LR; A-->B; B-->C").unwrap();
assert_eq!(g.edges.len(), 2);
}
#[test]
fn parse_labeled_nodes() {
let g = parse("graph LR\nA[Start] --> B[End]").unwrap();
assert_eq!(g.node("A").unwrap().label, "Start");
assert_eq!(g.node("B").unwrap().label, "End");
}
#[test]
fn parse_diamond_node() {
let g = parse("graph LR\nA{Decision}").unwrap();
assert_eq!(g.node("A").unwrap().shape, NodeShape::Diamond);
assert_eq!(g.node("A").unwrap().label, "Decision");
}
#[test]
fn parse_circle_node() {
let g = parse("graph LR\nA((Circle))").unwrap();
assert_eq!(g.node("A").unwrap().shape, NodeShape::Circle);
}
#[test]
fn parse_rounded_node() {
let g = parse("graph LR\nA(Rounded)").unwrap();
assert_eq!(g.node("A").unwrap().shape, NodeShape::Rounded);
}
#[test]
fn parse_edge_label_pipe() {
let g = parse("graph LR\nA -->|yes| B").unwrap();
assert_eq!(g.edges[0].label.as_deref(), Some("yes"));
}
#[test]
fn parse_edge_label_dash() {
let g = parse("graph LR\nA -- hello --> B").unwrap();
assert_eq!(g.edges[0].label.as_deref(), Some("hello"));
}
#[test]
fn parse_flowchart_keyword() {
let g = parse("flowchart TD\nA-->B").unwrap();
assert_eq!(g.direction, Direction::TopToBottom);
}
#[test]
fn bad_direction_returns_error() {
assert!(parse("graph XY\nA-->B").is_err());
}
#[test]
fn no_header_returns_error() {
assert!(parse("A-->B").is_err());
}
}