use crate::data::{CanvasData, CanvasEdge, CanvasNode, TextNode};
use anyhow::Result;
use regex::Regex;
use std::collections::HashMap;
pub fn parse(content: &str) -> Result<CanvasData> {
let mut nodes_map = HashMap::new();
let mut edges = Vec::new();
let mut positions = HashMap::new();
let mut shapes_map = HashMap::new();
let mut orientation = crate::data::DiagramOrientation::TopDown;
let meta_re = Regex::new(
r"^'\s*pinstar_layout:\s+(\S+)\s+([\d\.-]+)\s+([\d\.-]+)\s+([\d\.-]+)\s+([\d\.-]+)",
)
.unwrap();
let decl_re = Regex::new(
r"(?i)^\s*(object|class|usecase|state|node|rectangle|agent|database)\s+([a-zA-Z0-9_\-]+)",
)
.unwrap();
let label_assign_re = Regex::new(r"^\s*([a-zA-Z0-9_\-]+)\s*:\s*(.*?)\s*$").unwrap();
let edge_re = Regex::new(r"([a-zA-Z0-9_\-]+)\s*(?:-+>|\.+>)\s*([a-zA-Z0-9_\-]+)").unwrap();
let edge_label_re =
Regex::new(r"([a-zA-Z0-9_\-]+)\s*(?:-+>|\.+>)\s*([a-zA-Z0-9_\-]+)\s*:\s*(.*?)\s*$")
.unwrap();
let id_pattern = Regex::new(r"^[a-zA-Z0-9_\-]+$").unwrap();
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if let Some(caps) = meta_re.captures(trimmed) {
let id = caps[1].to_string();
let x: f64 = caps[2].parse().unwrap_or(0.0);
let y: f64 = caps[3].parse().unwrap_or(0.0);
let w: f64 = caps[4].parse().unwrap_or(200.0);
let h: f64 = caps[5].parse().unwrap_or(100.0);
positions.insert(id, (x, y, w, h));
continue;
}
if trimmed.to_lowercase().contains("left to right direction") {
orientation = crate::data::DiagramOrientation::LeftRight;
continue;
} else if trimmed.to_lowercase().contains("down to top direction")
|| trimmed.to_lowercase().contains("bottom to top direction")
{
orientation = crate::data::DiagramOrientation::DownTop;
continue;
}
if trimmed.starts_with("@start") || trimmed.starts_with("@end") || trimmed.starts_with("'")
{
continue;
}
if let Some(caps) = edge_label_re.captures(trimmed) {
let from = caps[1].to_string();
let to = caps[2].to_string();
let mut label = caps[3].to_string();
if label.starts_with('"') && label.ends_with('"') && label.len() >= 2 {
label = label[1..label.len() - 1].to_string();
}
let style = if trimmed.contains("..") {
crate::data::EdgeStyle::Dotted
} else {
crate::data::EdgeStyle::Solid
};
nodes_map
.entry(from.clone())
.or_insert_with(|| from.clone());
nodes_map.entry(to.clone()).or_insert_with(|| to.clone());
edges.push(CanvasEdge {
id: format!("edge_{}_{}", from, to),
from_node: from,
to_node: to,
from_side: Some("right".to_string()),
to_side: Some("left".to_string()),
label: Some(label),
color: None,
style,
});
continue;
}
if let Some(caps) = edge_re.captures(trimmed) {
let from = caps[1].to_string();
let to = caps[2].to_string();
let style = if trimmed.contains("..") {
crate::data::EdgeStyle::Dotted
} else {
crate::data::EdgeStyle::Solid
};
nodes_map
.entry(from.clone())
.or_insert_with(|| from.clone());
nodes_map.entry(to.clone()).or_insert_with(|| to.clone());
edges.push(CanvasEdge {
id: format!("edge_{}_{}", from, to),
from_node: from,
to_node: to,
from_side: Some("right".to_string()),
to_side: Some("left".to_string()),
label: None,
color: None,
style,
});
continue;
}
if let Some(caps) = decl_re.captures(trimmed) {
let kind = caps[1].to_lowercase();
let id = caps[2].to_string();
let shape = match kind.as_str() {
"database" => crate::data::NodeShape::Cylinder,
"usecase" => crate::data::NodeShape::Circle,
_ => crate::data::NodeShape::Rectangle,
};
shapes_map.insert(id.clone(), shape);
nodes_map.entry(id.clone()).or_insert(id);
continue;
}
if let Some(caps) = label_assign_re.captures(trimmed) {
let id = caps[1].to_string();
let mut text = caps[2].to_string();
if text.starts_with('"') && text.ends_with('"') && text.len() >= 2 {
text = text[1..text.len() - 1].to_string();
}
nodes_map.insert(id, text);
continue;
}
let clean_id = trimmed.trim_matches(|c| c == ';' || c == ':').trim();
if id_pattern.is_match(clean_id) {
nodes_map
.entry(clean_id.to_string())
.or_insert_with(|| clean_id.to_string());
}
}
let mut canvas_nodes = Vec::new();
for (id, label) in nodes_map {
let (x, y, w, h) = positions
.get(&id)
.copied()
.unwrap_or((0.0, 0.0, 200.0, 100.0));
let shape = shapes_map
.get(&id)
.copied()
.unwrap_or(crate::data::NodeShape::Rectangle);
canvas_nodes.push(CanvasNode::Text(TextNode {
id,
x,
y,
width: w,
height: h,
text: label,
title: None,
color: None,
shape,
}));
}
Ok(CanvasData {
nodes: canvas_nodes,
edges,
orientation,
})
}
pub fn serialize(data: &CanvasData, write_layout: bool) -> Result<String> {
let mut buf = String::new();
buf.push_str("@startuml\n");
if data.orientation == crate::data::DiagramOrientation::LeftRight {
buf.push_str("left to right direction\n");
} else if data.orientation == crate::data::DiagramOrientation::DownTop {
buf.push_str("down to top direction\n");
}
if write_layout {
for node in &data.nodes {
let (x, y) = node.pos();
let (w, h) = node.size();
buf.push_str(&format!(
"' pinstar_layout: {} {:.1} {:.1} {:.1} {:.1}\n",
node.id(),
x,
y,
w,
h
));
}
}
for node in &data.nodes {
let keyword = match node.shape() {
crate::data::NodeShape::Cylinder => "database",
crate::data::NodeShape::Circle => "usecase",
_ => "object",
};
buf.push_str(&format!("{} {}\n", keyword, node.id()));
}
for node in &data.nodes {
let escaped = node.text().replace('"', "\\\"");
buf.push_str(&format!("{} : \"{}\"\n", node.id(), escaped));
}
for edge in &data.edges {
let arrow = match edge.style {
crate::data::EdgeStyle::Dotted | crate::data::EdgeStyle::Dashed => "..>",
_ => "-->",
};
if let Some(ref lbl) = edge.label {
let escaped_lbl = lbl.replace('"', "\\\"");
buf.push_str(&format!(
"{} {} {} : \"{}\"\n",
edge.from_node, arrow, edge.to_node, escaped_lbl
));
} else {
buf.push_str(&format!("{} {} {}\n", edge.from_node, arrow, edge.to_node));
}
}
buf.push_str("@enduml\n");
Ok(buf)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_plantuml_basic() {
let content = "@startuml\nA --> B\n@enduml";
let data = parse(content).unwrap();
assert_eq!(data.nodes.len(), 2);
assert_eq!(data.edges.len(), 1);
let output = serialize(&data, false).unwrap();
assert!(output.contains("A --> B"));
}
}