use indexmap::IndexMap;
use std::collections::HashMap;
#[derive(Debug, Clone, Default)]
pub struct NodeStyle {
pub fill: Option<String>,
pub stroke: Option<String>,
pub stroke_width: Option<String>,
pub color: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum NodeShape {
Rectangle, RoundedRect, Diamond, Circle, Asymmetric, Cylinder, Subroutine, Stadium, Hexagon, Default, }
#[derive(Debug, Clone)]
pub struct FlowNode {
pub label: String,
pub shape: NodeShape,
}
#[derive(Debug, Clone, PartialEq)]
pub enum EdgeStyle {
Arrow, Line, DotArrow, ThickArrow, DotLine, OpenArrow, CrossArrow, }
#[derive(Debug, Clone)]
pub struct FlowEdge {
pub from: String,
pub to: String,
pub label: Option<String>,
pub style: EdgeStyle,
}
#[derive(Debug, Clone, Default)]
pub struct Subgraph {
pub id: String,
pub label: Option<String>,
pub direction: Option<String>,
pub members: Vec<String>, }
#[derive(Debug)]
pub struct FlowchartDiagram {
pub direction: String,
pub nodes: IndexMap<String, FlowNode>,
pub edges: Vec<FlowEdge>,
pub subgraphs: Vec<Subgraph>,
pub node_subgraph: HashMap<String, String>, pub node_styles: HashMap<String, NodeStyle>, }
pub fn parse(input: &str) -> crate::error::ParseResult<FlowchartDiagram> {
let mut diag = FlowchartDiagram {
direction: "TB".to_string(),
nodes: IndexMap::new(),
edges: Vec::new(),
subgraphs: Vec::new(),
node_subgraph: HashMap::new(),
node_styles: HashMap::new(),
};
let mut parse_errors: Vec<crate::error::ParseError> = Vec::new();
let mut subgraph_stack: Vec<Subgraph> = Vec::new();
for (line_number, raw_line) in input.lines().enumerate() {
let line_number = line_number + 1; let line = strip_comment(raw_line).trim().to_string();
if line.is_empty() {
continue;
}
if line.starts_with("flowchart ") || line.starts_with("graph ") {
let parts: Vec<&str> = line.splitn(2, ' ').collect();
if parts.len() == 2 {
diag.direction = parts[1].trim().to_uppercase();
}
continue;
}
if let Some(stripped) = line.strip_prefix("subgraph") {
let rest = stripped.trim();
let (id, label) = parse_subgraph_header(rest);
subgraph_stack.push(Subgraph {
id,
label,
direction: None,
members: Vec::new(),
});
continue;
}
if line == "end" {
if let Some(finished) = subgraph_stack.pop() {
let sg_id = finished.id.clone();
if let Some(parent) = subgraph_stack.last_mut() {
parent.members.push(sg_id.clone());
}
diag.subgraphs.push(finished);
}
continue;
}
if let Some(stripped) = line.strip_prefix("direction ") {
let dir = stripped.trim().to_uppercase();
if let Some(sg) = subgraph_stack.last_mut() {
sg.direction = Some(dir);
}
continue;
}
if line.starts_with("classDef ")
|| line.starts_with("class ")
|| line.starts_with("linkStyle ")
|| line.starts_with("%%")
{
continue;
}
if let Some(stripped) = line.strip_prefix("style ") {
let rest = stripped.trim();
let mut parts = rest.splitn(2, ' ');
if let (Some(node_id), Some(attrs)) = (parts.next(), parts.next()) {
let style = parse_style_attrs(attrs);
diag.node_styles.insert(node_id.trim().to_string(), style);
}
continue;
}
let nodes_before = diag.nodes.len();
let edges_before = diag.edges.len();
let members_before: Vec<String> = diag.nodes.keys().cloned().collect();
parse_statement(&line, &mut diag);
let produced_something =
diag.nodes.len() != nodes_before || diag.edges.len() != edges_before;
if !produced_something {
let first_char = line.chars().next().unwrap_or(' ');
let looks_like_node_start = first_char.is_alphanumeric() || first_char == '_';
if !looks_like_node_start {
parse_errors.push(crate::error::ParseError::at_line(
line_number,
format!("Unrecognized syntax: '{}'", line),
));
}
}
if let Some(sg) = subgraph_stack.last_mut() {
let sg_id = sg.id.clone();
for id in diag.nodes.keys() {
if !members_before.contains(id) && !diag.node_subgraph.contains_key(id) {
sg.members.push(id.clone());
diag.node_subgraph.insert(id.clone(), sg_id.clone());
}
}
}
}
crate::error::ParseResult::with_errors(diag, parse_errors)
}
fn strip_comment(line: &str) -> &str {
if let Some(pos) = line.find("%%") {
&line[..pos]
} else {
line
}
}
fn parse_subgraph_header(rest: &str) -> (String, Option<String>) {
let rest = rest.trim();
if rest.is_empty() {
return (format!("_sg_{}", rand_id()), None);
}
if let Some(bracket) = rest.find('[') {
let id = rest[..bracket].trim().to_string();
let label_raw = &rest[bracket + 1..];
let label = label_raw.trim_end_matches(']').trim().to_string();
let id = if id.is_empty() {
format!("_sg_{}", rand_id())
} else {
id
};
return (id, Some(label));
}
(rest.to_string(), None)
}
static RAND_CTR: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
fn rand_id() -> u32 {
RAND_CTR.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
}
fn parse_statement(line: &str, diag: &mut FlowchartDiagram) {
let mut pos = 0;
let chars: Vec<char> = line.chars().collect();
let mut left_nodes: Vec<String> = Vec::new();
loop {
skip_ws(&chars, &mut pos);
if pos >= chars.len() {
break;
}
let (id, shape_opt, label_opt) = parse_node_token(&chars, &mut pos);
if id.is_empty() {
break;
}
ensure_node(diag, &id, shape_opt, label_opt);
left_nodes.push(id);
skip_ws(&chars, &mut pos);
if pos < chars.len() && chars[pos] == '&' {
pos += 1;
continue;
}
break;
}
if left_nodes.is_empty() {
return;
}
skip_ws(&chars, &mut pos);
if pos >= chars.len() {
return;
}
if let Some((style, label, after_pos)) = try_parse_edge(&chars, pos) {
pos = after_pos;
let mut right_nodes: Vec<String> = Vec::new();
loop {
skip_ws(&chars, &mut pos);
if pos >= chars.len() {
break;
}
let (id, shape_opt, label_opt) = parse_node_token(&chars, &mut pos);
if id.is_empty() {
break;
}
ensure_node(diag, &id, shape_opt, label_opt);
right_nodes.push(id);
skip_ws(&chars, &mut pos);
if pos < chars.len() && chars[pos] == '&' {
pos += 1;
continue;
}
break;
}
for l in &left_nodes {
for r in &right_nodes {
diag.edges.push(FlowEdge {
from: l.clone(),
to: r.clone(),
label: label.clone(),
style: style.clone(),
});
}
}
skip_ws(&chars, &mut pos);
if pos < chars.len() {
let rest: String = chars[pos..].iter().collect();
let mut chain = right_nodes
.iter()
.map(|id| id.to_string())
.collect::<Vec<_>>()
.join(" & ");
chain.push(' ');
chain.push_str(&rest);
parse_statement(&chain, diag);
}
}
}
fn ensure_node(
diag: &mut FlowchartDiagram,
id: &str,
shape: Option<NodeShape>,
label: Option<String>,
) {
if !diag.nodes.contains_key(id) {
let label = label.unwrap_or_else(|| id.to_string());
let shape = shape.unwrap_or(NodeShape::Default);
diag.nodes.insert(id.to_string(), FlowNode { label, shape });
} else if let Some(node) = diag.nodes.get_mut(id) {
if let Some(s) = shape {
node.shape = s;
}
if let Some(l) = label {
node.label = l;
}
}
}
fn skip_ws(chars: &[char], pos: &mut usize) {
while *pos < chars.len() && chars[*pos].is_whitespace() {
*pos += 1;
}
}
fn parse_node_token(
chars: &[char],
pos: &mut usize,
) -> (String, Option<NodeShape>, Option<String>) {
skip_ws(chars, pos);
if *pos >= chars.len() {
return (String::new(), None, None);
}
let id = parse_node_id(chars, pos);
if id.is_empty() {
return (String::new(), None, None);
}
if *pos >= chars.len() {
return (id, None, None);
}
let (shape, label) = parse_shape(chars, pos);
(id, shape, label)
}
fn parse_node_id(chars: &[char], pos: &mut usize) -> String {
let mut id = String::new();
while *pos < chars.len() {
let c = chars[*pos];
let next = chars.get(*pos + 1).copied();
if c == '-' && matches!(next, Some('-') | Some('.')) {
break;
}
if c == '=' && next == Some('=') {
break;
}
if c.is_alphanumeric() || c == '_' || c == '-' || c == '.' {
if id.is_empty() && c == '-' {
break;
} id.push(c);
*pos += 1;
} else {
break;
}
}
id
}
fn parse_shape(chars: &[char], pos: &mut usize) -> (Option<NodeShape>, Option<String>) {
if *pos >= chars.len() {
return (None, None);
}
match chars[*pos] {
'[' => {
if *pos + 1 < chars.len() && chars[*pos + 1] == '[' {
*pos += 2;
let text = read_until(chars, pos, "]]");
(Some(NodeShape::Subroutine), Some(text))
} else if *pos + 1 < chars.len() && chars[*pos + 1] == '(' {
*pos += 2;
let text = read_until(chars, pos, ")]");
(Some(NodeShape::Cylinder), Some(text))
} else {
*pos += 1;
let text = read_bracket_text(chars, pos, ']');
(Some(NodeShape::Rectangle), Some(text))
}
}
'(' => {
if *pos + 1 < chars.len() && chars[*pos + 1] == '(' {
*pos += 2;
let text = read_until(chars, pos, "))");
(Some(NodeShape::Circle), Some(text))
} else if *pos + 1 < chars.len() && chars[*pos + 1] == '[' {
*pos += 2;
let text = read_until(chars, pos, "])");
(Some(NodeShape::Stadium), Some(text))
} else {
*pos += 1;
let text = read_bracket_text(chars, pos, ')');
(Some(NodeShape::RoundedRect), Some(text))
}
}
'{' => {
if *pos + 1 < chars.len() && chars[*pos + 1] == '{' {
*pos += 2;
let text = read_until(chars, pos, "}}");
(Some(NodeShape::Hexagon), Some(text))
} else {
*pos += 1;
let text = read_bracket_text(chars, pos, '}');
(Some(NodeShape::Diamond), Some(text))
}
}
'>' => {
*pos += 1;
let text = read_bracket_text(chars, pos, ']');
(Some(NodeShape::Asymmetric), Some(text))
}
_ => (None, None),
}
}
fn read_bracket_text(chars: &[char], pos: &mut usize, close: char) -> String {
let mut text = String::new();
let mut depth = 1i32;
let open = match close {
']' => '[',
')' => '(',
'}' => '{',
c => c,
};
while *pos < chars.len() {
let c = chars[*pos];
*pos += 1;
if c == '"' {
while *pos < chars.len() {
let q = chars[*pos];
*pos += 1;
if q == '"' {
break;
}
text.push(q);
}
continue;
}
if c == open {
depth += 1;
}
if c == close {
depth -= 1;
if depth == 0 {
break;
}
}
text.push(c);
}
text.trim().to_string()
}
fn read_until(chars: &[char], pos: &mut usize, end: &str) -> String {
let end_chars: Vec<char> = end.chars().collect();
let mut text = String::new();
while *pos < chars.len() {
if *pos + end_chars.len() <= chars.len()
&& chars[*pos..*pos + end_chars.len()] == end_chars[..]
{
*pos += end_chars.len();
break;
}
text.push(chars[*pos]);
*pos += 1;
}
text.trim().to_string()
}
fn parse_style_attrs(attrs: &str) -> NodeStyle {
let mut style = NodeStyle::default();
for part in attrs.split(',') {
let part = part.trim();
if let Some(v) = part.strip_prefix("fill:") {
style.fill = Some(normalize_color(v.trim()));
} else if let Some(v) = part.strip_prefix("stroke-width:") {
style.stroke_width = Some(v.trim().to_string());
} else if let Some(v) = part.strip_prefix("stroke:") {
style.stroke = Some(normalize_color(v.trim()));
} else if let Some(v) = part.strip_prefix("color:") {
style.color = Some(normalize_color(v.trim()));
}
}
style
}
fn normalize_color(c: &str) -> String {
if c.starts_with('#') && c.len() == 4 {
let chars: Vec<char> = c.chars().collect();
return format!("#{0}{0}{1}{1}{2}{2}", chars[1], chars[2], chars[3]);
}
c.to_string()
}
fn try_parse_edge(chars: &[char], pos: usize) -> Option<(EdgeStyle, Option<String>, usize)> {
let mut p = pos;
while p < chars.len() && chars[p].is_whitespace() {
p += 1;
}
if p >= chars.len() {
return None;
}
let rest: String = chars[p..].iter().collect();
if rest.starts_with("==>") {
let pp = p + 3;
let (label, end) = try_pipe_label(chars, pp);
return Some((EdgeStyle::ThickArrow, label, end));
}
if rest.starts_with("==") {
let inner_start = p + 2;
if let Some((text, end)) = parse_text_arrow(chars, inner_start, "==>") {
return Some((EdgeStyle::ThickArrow, Some(text), end));
}
}
if rest.starts_with("-.->") {
let pp = p + 4;
let (label, end) = try_pipe_label(chars, pp);
return Some((EdgeStyle::DotArrow, label, end));
}
if rest.starts_with("-.") {
let inner_start = p + 2;
if let Some((text, end)) = parse_text_arrow(chars, inner_start, ".->") {
return Some((EdgeStyle::DotArrow, Some(text), end));
}
if let Some(pos2) = find_seq(chars, inner_start, ".-") {
let text: String = chars[inner_start..pos2].iter().collect();
return Some((EdgeStyle::DotLine, Some(text.trim().to_string()), pos2 + 2));
}
}
if rest.starts_with("--o") {
return Some((EdgeStyle::OpenArrow, None, p + 3));
}
if rest.starts_with("--x") {
return Some((EdgeStyle::CrossArrow, None, p + 3));
}
if rest.starts_with("-->") {
let pp = p + 3;
let (label, end) = try_pipe_label(chars, pp);
return Some((EdgeStyle::Arrow, label, end));
}
if rest.starts_with("--") {
let inner_start = p + 2;
if let Some((text, end)) = parse_text_arrow(chars, inner_start, "-->") {
return Some((EdgeStyle::Arrow, Some(text), end));
}
if rest.starts_with("---") {
return Some((EdgeStyle::Line, None, p + 3));
}
if let Some(pos2) = find_seq(chars, inner_start, "--") {
let text: String = chars[inner_start..pos2].iter().collect();
return Some((EdgeStyle::Line, Some(text.trim().to_string()), pos2 + 2));
}
}
None
}
fn try_pipe_label(chars: &[char], pos: usize) -> (Option<String>, usize) {
let mut p = pos;
while p < chars.len() && chars[p].is_whitespace() {
p += 1;
}
if p < chars.len() && chars[p] == '|' {
p += 1;
let mut label = String::new();
while p < chars.len() && chars[p] != '|' {
label.push(chars[p]);
p += 1;
}
if p < chars.len() {
p += 1;
} return (Some(label.trim().to_string()), p);
}
(None, pos)
}
fn parse_text_arrow(chars: &[char], start: usize, end_seq: &str) -> Option<(String, usize)> {
if let Some(pos) = find_seq(chars, start, end_seq) {
let text: String = chars[start..pos].iter().collect();
Some((text.trim().to_string(), pos + end_seq.len()))
} else {
None
}
}
fn find_seq(chars: &[char], start: usize, seq: &str) -> Option<usize> {
let seq_chars: Vec<char> = seq.chars().collect();
let n = seq_chars.len();
for i in start..chars.len().saturating_sub(n - 1) {
if chars[i..i + n] == seq_chars[..] {
return Some(i);
}
}
None
}