use crate::flowchart::{Direction, FlowEdge, FlowNode, Flowchart, Subgraph};
use crate::model::Shape;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum D2Error {
Empty,
}
impl std::fmt::Display for D2Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
D2Error::Empty => write!(f, "empty D2 source"),
}
}
}
impl std::error::Error for D2Error {}
fn shape_of(kw: &str) -> Shape {
match kw.trim() {
"circle" => Shape::Circle,
"diamond" => Shape::Diamond,
"hexagon" => Shape::Hex,
"cylinder" | "stored_data" | "page" => Shape::Cylinder,
"oval" => Shape::Badge,
_ => Shape::Box, }
}
fn leaf(id: &str) -> &str {
id.rsplit('.').next().unwrap_or(id).trim()
}
fn unquote(s: &str) -> &str {
let t = s.trim();
if t.len() >= 2 && t.starts_with('"') && t.ends_with('"') {
&t[1..t.len() - 1]
} else {
t
}
}
fn strip_comment(line: &str) -> &str {
let mut in_q = false;
for (i, c) in line.char_indices() {
match c {
'"' => in_q = !in_q,
'#' if !in_q => return &line[..i],
_ => {}
}
}
line
}
const CONNECTORS: &[(&str, bool, bool)] = &[
("<->", false, false),
("->", false, false),
("<-", true, false),
("--", false, true),
];
struct Builder {
fc: Flowchart,
index: Vec<(String, usize)>, stack: Vec<usize>, }
impl Builder {
fn touch(&mut self, id: &str, label: Option<&str>, shape: Option<Shape>) {
if let Some((_, idx)) = self.index.iter().find(|(k, _)| k == id) {
let n = &mut self.fc.nodes[*idx];
if let Some(l) = label {
n.label = l.to_string();
}
if let Some(s) = shape {
n.shape = s;
}
} else {
let idx = self.fc.nodes.len();
self.fc.nodes.push(FlowNode {
id: id.to_string(),
label: label.unwrap_or(id).to_string(),
shape: shape.unwrap_or(Shape::Box),
});
self.index.push((id.to_string(), idx));
}
if let Some(&sub) = self.stack.last() {
let m = &mut self.fc.subgraphs[sub].members;
if !m.iter().any(|x| x == id) {
m.push(id.to_string());
}
}
}
}
pub fn parse(src: &str) -> Result<Flowchart, D2Error> {
let mut b = Builder {
fc: Flowchart {
direction: Direction::Tb,
nodes: Vec::new(),
edges: Vec::new(),
subgraphs: Vec::new(),
},
index: Vec::new(),
stack: Vec::new(),
};
let mut saw = false;
let mut auto = 0usize;
for raw in src.lines() {
let line = strip_comment(raw).trim();
if line.is_empty() {
continue;
}
saw = true;
if line == "}" {
b.stack.pop();
continue;
}
if let Some(rest) = line.strip_prefix("direction:") {
if b.stack.is_empty() {
b.fc.direction = match rest.trim() {
"up" => Direction::Bt,
"right" => Direction::Lr,
"left" => Direction::Rl,
_ => Direction::Tb, };
}
continue;
}
if let Some(()) = handle_edge(&mut b, line) {
continue;
}
if line.ends_with('{') && !line.contains('}') {
let head = line[..line.len() - 1].trim();
let (id, title) = split_key_label(head);
let id = if id.is_empty() {
auto += 1;
format!("g{auto}")
} else {
leaf(id).to_string()
};
let title = title.unwrap_or_else(|| id.clone());
let sub_idx = b.fc.subgraphs.len();
b.fc.subgraphs.push(Subgraph {
parent: None,
id: id.clone(),
title,
members: Vec::new(),
});
b.stack.push(sub_idx);
continue;
}
handle_node(&mut b, line);
}
if !saw {
return Err(D2Error::Empty);
}
Ok(b.fc)
}
fn find_connector(s: &str, tok: &str) -> Option<usize> {
let bytes = s.as_bytes();
let mut i = 0;
while let Some(rel) = s[i..].find(tok) {
let at = i + rel;
let before_ok = at == 0 || bytes[at - 1] == b' ';
let after = at + tok.len();
let after_ok = after >= s.len() || bytes[after] == b' ';
if before_ok && after_ok {
return Some(at);
}
i = at + 1;
}
None
}
fn handle_edge(b: &mut Builder, line: &str) -> Option<()> {
let (body, dashed) = match line.rfind('{') {
Some(i) if line.ends_with('}') => (line[..i].trim(), line[i..].contains("stroke-dash")),
_ => (line, false),
};
let mut segs: Vec<&str> = Vec::new();
let mut ops: Vec<(bool, bool)> = Vec::new(); let mut cur = body;
loop {
let next = CONNECTORS
.iter()
.filter_map(|&(t, r, n)| find_connector(cur, t).map(|i| (i, t, r, n)))
.min_by_key(|&(i, _, _, _)| i);
match next {
Some((i, t, r, n)) => {
segs.push(cur[..i].trim());
ops.push((r, n));
cur = cur[i + t.len()..].trim_start();
}
None => {
segs.push(cur.trim());
break;
}
}
}
if ops.is_empty() {
return None; }
let last = segs.len() - 1;
let mut label = String::new();
if let Some(j) = segs[last].find(':') {
label = unquote(segs[last][j + 1..].trim()).to_string();
segs[last] = segs[last][..j].trim();
}
for k in 0..ops.len() {
let (rev, no_arrow) = ops[k];
let a = leaf(segs[k]).to_string();
let c = leaf(segs[k + 1]).to_string();
if a.is_empty() || c.is_empty() {
continue;
}
b.touch(&a, None, None);
b.touch(&c, None, None);
let (src, dst) = if rev { (c, a) } else { (a, c) };
let lbl = if k == ops.len() - 1 {
label.clone()
} else {
String::new()
};
b.fc.edges.push(FlowEdge {
src,
dst,
label: lbl,
dashed,
no_arrow,
});
}
Some(())
}
fn split_key_label(s: &str) -> (&str, Option<String>) {
match s.find(':') {
Some(i) => (s[..i].trim(), Some(unquote(s[i + 1..].trim()).to_string())),
None => (s.trim(), None),
}
}
fn handle_node(b: &mut Builder, line: &str) {
if let Some(p) = line.find(".shape:") {
let id = leaf(line[..p].trim()).to_string();
b.touch(
&id,
None,
Some(shape_of(line[p + ".shape:".len()..].trim())),
);
return;
}
let (head, shape) = match (line.find('{'), line.rfind('}')) {
(Some(o), Some(c)) if c > o => {
let inner = &line[o + 1..c];
let sh = inner.find("shape:").map(|k| {
shape_of(
inner[k + "shape:".len()..]
.split(';')
.next()
.unwrap_or("")
.trim(),
)
});
(line[..o].trim(), sh)
}
_ => (line.trim(), None),
};
let (id, label) = split_key_label(head);
if id.is_empty() {
return;
}
let id = leaf(id).to_string();
b.touch(&id, label.as_deref(), shape);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nodes_shapes_edges() {
let fc = parse(
"direction: right\n\
A: \"Start\" { shape: circle }\n\
B: \"ok?\" { shape: diamond }\n\
A -> B: \"go\"\n\
B -- A\n",
)
.unwrap();
assert_eq!(fc.direction, Direction::Lr);
assert_eq!(fc.nodes.len(), 2);
assert_eq!(fc.nodes[0].label, "Start");
assert_eq!(fc.nodes[0].shape, Shape::Circle);
assert_eq!(fc.nodes[1].shape, Shape::Diamond);
assert_eq!(fc.edges.len(), 2);
assert_eq!(
(
fc.edges[0].src.as_str(),
fc.edges[0].dst.as_str(),
fc.edges[0].label.as_str()
),
("A", "B", "go")
);
assert!(fc.edges[1].no_arrow); }
#[test]
fn container_and_qualified_edge_and_dash() {
let fc = parse(
"G: \"Group\" {\n A: \"a\"\n B: \"b\"\n}\n\
Start -> G.A\n\
A -> B { style.stroke-dash: 3 }\n",
)
.unwrap();
assert_eq!(fc.subgraphs.len(), 1);
assert_eq!(fc.subgraphs[0].title, "Group");
assert_eq!(fc.subgraphs[0].members, ["A", "B"]);
assert_eq!(
(fc.edges[0].src.as_str(), fc.edges[0].dst.as_str()),
("Start", "A")
);
assert!(fc.edges[1].dashed);
}
#[test]
fn bare_ids_and_default_shape() {
let fc = parse("A\nA -> B\n").unwrap();
assert_eq!(fc.nodes.len(), 2);
assert_eq!(fc.nodes[0].shape, Shape::Box); assert_eq!(fc.nodes[1].label, "B"); }
#[test]
fn empty_is_error() {
assert_eq!(
parse(" \n # only a comment\n").unwrap_err(),
D2Error::Empty
);
}
}