#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Direction {
Down,
Right,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Node {
pub id: String,
pub label: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Edge {
pub from: usize,
pub to: usize,
pub label: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Graph {
pub direction: Direction,
pub nodes: Vec<Node>,
pub edges: Vec<Edge>,
}
const ARROWS: &[&str] = &["-->", "---", "==>", "-.->"];
#[must_use]
pub fn parse(source: &str) -> Option<Graph> {
let mut lines = source
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with("%%"));
let direction = header(lines.next()?)?;
let mut graph = Graph {
direction,
nodes: Vec::new(),
edges: Vec::new(),
};
for line in lines {
if !parse_line(line, &mut graph) {
return None;
}
}
(!graph.nodes.is_empty()).then_some(graph)
}
fn header(line: &str) -> Option<Direction> {
let mut parts = line.split_whitespace();
let kind = parts.next()?;
if !matches!(kind, "flowchart" | "graph") {
return None;
}
match parts.next()? {
"TD" | "TB" => Some(Direction::Down),
"LR" => Some(Direction::Right),
_ => None,
}
}
fn parse_line(line: &str, graph: &mut Graph) -> bool {
let Some((arrow, at)) = ARROWS
.iter()
.filter_map(|a| line.find(a).map(|i| (*a, i)))
.min_by_key(|(_, i)| *i)
else {
return match node_decl(line) {
Some((id, label)) => {
intern(graph, &id, label);
true
}
None => false,
};
};
let (left, rest) = line.split_at(at);
let rest = &rest[arrow.len()..];
let (edge_label, right) = match rest.trim_start().strip_prefix('|') {
Some(tail) => match tail.split_once('|') {
Some((label, right)) => (Some(label.trim().to_string()), right),
None => return false,
},
None => (None, rest),
};
let (Some((lid, llab)), Some((rid, rlab))) = (node_decl(left.trim()), node_decl(right.trim()))
else {
return false;
};
let from = intern(graph, &lid, llab);
let to = intern(graph, &rid, rlab);
graph.edges.push(Edge {
from,
to,
label: edge_label,
});
true
}
fn node_decl(text: &str) -> Option<(String, Option<String>)> {
let text = text.trim();
if text.is_empty() {
return None;
}
for (open, close) in [('[', ']'), ('(', ')'), ('{', '}')] {
if let Some(i) = text.find(open) {
let id = text[..i].trim();
let rest = &text[i + 1..];
let label = rest.strip_suffix(close)?;
return valid_id(id).then(|| (id.to_string(), Some(label.trim().to_string())));
}
}
valid_id(text).then(|| (text.to_string(), None))
}
fn valid_id(id: &str) -> bool {
!id.is_empty()
&& id
.chars()
.all(|c| c.is_alphanumeric() || c == '_' || c == '-')
}
fn intern(graph: &mut Graph, id: &str, label: Option<String>) -> usize {
let at = graph
.nodes
.iter()
.position(|n| n.id == id)
.unwrap_or_else(|| {
graph.nodes.push(Node {
id: id.to_string(),
label: id.to_string(),
});
graph.nodes.len() - 1
});
if let Some(label) = label {
if !label.is_empty() {
graph.nodes[at].label = label;
}
}
at
}