use crate::model::{
Attr, Card, Direction, Document, EdgeKind, ErDiagram, Graph, Key, NodeStyle, Relation, Shape,
};
use std::collections::HashMap;
#[derive(Debug)]
pub struct ParseError {
pub line: usize,
pub message: String,
}
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "line {}: {}", self.line, self.message)
}
}
fn err(line: usize, message: String) -> ParseError {
ParseError { line, message }
}
struct Cur<'a> {
s: &'a str,
pos: usize,
}
impl<'a> Cur<'a> {
fn new(s: &'a str) -> Self {
Cur { s, pos: 0 }
}
fn rest(&self) -> &'a str {
&self.s[self.pos..]
}
fn at_end(&self) -> bool {
self.pos >= self.s.len()
}
fn peek(&self) -> Option<char> {
self.rest().chars().next()
}
fn bump(&mut self) -> Option<char> {
let c = self.peek()?;
self.pos += c.len_utf8();
Some(c)
}
fn skip_ws(&mut self) {
while let Some(c) = self.peek() {
if c.is_whitespace() {
self.bump();
} else {
break;
}
}
}
fn eat(&mut self, prefix: &str) -> bool {
if self.rest().starts_with(prefix) {
self.pos += prefix.len();
true
} else {
false
}
}
fn take_until(&mut self, close: &str) -> Option<String> {
let idx = self.rest().find(close)?;
let out = self.rest()[..idx].to_string();
self.pos += idx + close.len();
Some(out)
}
}
fn clean_label(s: &str) -> String {
let t = s.trim();
if t.len() >= 2 && t.starts_with('"') && t.ends_with('"') {
t[1..t.len() - 1].to_string()
} else {
t.to_string()
}
}
pub fn parse_document(source: &str) -> Result<Document, ParseError> {
for (i, raw) in source.lines().enumerate() {
let line = raw.trim();
if line.is_empty() || line.starts_with("%%") {
continue;
}
return match diagram_type(line) {
Some("erDiagram") => parse_er(source, i + 1).map(Document::Er),
Some(t) => Err(err(
i + 1,
format!(
"diagram type '{}' is not supported yet (supported: flowchart, graph, erDiagram)",
t
),
)),
None => parse(source).map(Document::Flowchart),
};
}
Ok(Document::Flowchart(Graph::default()))
}
pub fn parse(source: &str) -> Result<Graph, ParseError> {
let mut g = Graph::default();
let mut header_seen = false;
let mut class_defs: HashMap<String, NodeStyle> = HashMap::new();
let mut assigns: Vec<(usize, String)> = Vec::new(); let mut styles: Vec<(usize, NodeStyle)> = Vec::new();
for (i, raw) in source.lines().enumerate() {
let lineno = i + 1;
let line = raw.trim();
if line.is_empty() || line.starts_with("%%") {
continue;
}
if !header_seen {
header_seen = true;
if let Some(t) = diagram_type(line) {
let hint = if t == "erDiagram" {
"this parser is flowchart-only — use parse_document() or render_svg()"
} else {
"not supported yet (supported: flowchart, graph, erDiagram)"
};
return Err(err(lineno, format!("diagram type '{}': {}", t, hint)));
}
let rest = strip_keyword(line, "flowchart").or_else(|| strip_keyword(line, "graph"));
if let Some(rest) = rest {
g.direction = match rest.trim().to_uppercase().as_str() {
"" | "TD" | "TB" => Direction::TD,
"LR" => Direction::LR,
"RL" => Direction::RL,
"BT" => Direction::BT,
other => {
return Err(err(lineno, format!("unknown direction: '{}'", other)))
}
};
continue;
}
}
if let Some(rest) = strip_keyword(line, "classDef") {
let rest = rest.trim();
let (names, props) = rest.split_once(char::is_whitespace).ok_or_else(|| {
err(lineno, "classDef needs a name and properties".to_string())
})?;
let st = parse_props(props.trim(), lineno)?;
for name in names.split(',').filter(|n| !n.is_empty()) {
class_defs.insert(name.to_string(), st.clone());
}
continue;
}
if let Some(rest) = strip_keyword(line, "class") {
let rest = rest.trim();
let (ids, name) = rest.split_once(char::is_whitespace).ok_or_else(|| {
err(lineno, "class needs node ids and a class name".to_string())
})?;
for id in ids.split(',').filter(|i| !i.is_empty()) {
let n = g.ensure_node(id.trim(), None, None);
assigns.push((n, name.trim().to_string()));
}
continue;
}
if let Some(rest) = strip_keyword(line, "style") {
let rest = rest.trim();
let (id, props) = rest.split_once(char::is_whitespace).ok_or_else(|| {
err(lineno, "style needs a node id and properties".to_string())
})?;
let n = g.ensure_node(id.trim(), None, None);
styles.push((n, parse_props(props.trim(), lineno)?));
continue;
}
parse_statement(&mut g, line, lineno, &mut assigns)?;
}
for (n, name) in assigns {
if let Some(def) = class_defs.get(&name) {
g.nodes[n].style.apply_over(def);
}
}
for (n, st) in styles {
g.nodes[n].style.apply_over(&st);
}
Ok(g)
}
fn parse_props(s: &str, lineno: usize) -> Result<NodeStyle, ParseError> {
let mut st = NodeStyle::default();
for item in s.split(',') {
let item = item.trim();
if item.is_empty() {
continue;
}
let Some((k, v)) = item.split_once(':') else {
return Err(err(
lineno,
format!("expected 'property:value', got '{}'", item),
));
};
let v = v.trim();
match k.trim() {
"fill" => st.fill = Some(v.to_string()),
"stroke" => st.stroke = Some(v.to_string()),
"color" => st.color = Some(v.to_string()),
"stroke-width" => {
let n: f64 = v.trim_end_matches("px").trim().parse().map_err(|_| {
err(lineno, format!("invalid stroke-width: '{}'", v))
})?;
st.stroke_width = Some(n);
}
_ => {}
}
}
Ok(st)
}
fn diagram_type(line: &str) -> Option<&'static str> {
const TYPES: &[&str] = &[
"erDiagram",
"sequenceDiagram",
"classDiagram",
"stateDiagram-v2",
"stateDiagram",
"gantt",
"pie",
"journey",
"mindmap",
"timeline",
];
TYPES.iter().copied().find(|t| {
line.get(..t.len()) == Some(*t)
&& line[t.len()..].chars().next().map_or(true, char::is_whitespace)
})
}
fn strip_keyword<'a>(line: &'a str, kw: &str) -> Option<&'a str> {
match line.get(..kw.len()) {
Some(head) if head.eq_ignore_ascii_case(kw) => {
let rest = &line[kw.len()..];
if rest.is_empty() || rest.starts_with(char::is_whitespace) {
Some(rest)
} else {
None
}
}
_ => None,
}
}
fn parse_statement(
g: &mut Graph,
line: &str,
lineno: usize,
assigns: &mut Vec<(usize, String)>,
) -> Result<(), ParseError> {
let mut cur = Cur::new(line);
let mut prev = parse_node(&mut cur, g, lineno, assigns)?;
loop {
cur.skip_ws();
if cur.at_end() {
break;
}
if cur.eat(";") {
cur.skip_ws();
if cur.at_end() {
break;
}
prev = parse_node(&mut cur, g, lineno, assigns)?;
continue;
}
let kind = parse_edge_op(&mut cur).ok_or_else(|| {
err(
lineno,
format!("unknown edge operator near: '{}'", cur.rest()),
)
})?;
cur.skip_ws();
let label = if cur.eat("|") {
let l = cur.take_until("|").ok_or_else(|| {
err(lineno, "edge label opened with '|' but never closed".to_string())
})?;
Some(clean_label(&l))
} else {
None
};
let next = parse_node(&mut cur, g, lineno, assigns)?;
g.add_edge(prev, next, label, kind);
prev = next;
}
Ok(())
}
fn parse_node(
cur: &mut Cur<'_>,
g: &mut Graph,
lineno: usize,
assigns: &mut Vec<(usize, String)>,
) -> Result<usize, ParseError> {
cur.skip_ws();
let start = cur.pos;
while let Some(c) = cur.peek() {
if c.is_alphanumeric() || c == '_' {
cur.bump();
} else {
break;
}
}
if cur.pos == start {
return Err(err(
lineno,
format!("expected a node id, found: '{}'", cur.rest()),
));
}
let id = cur.s[start..cur.pos].to_string();
let parsed: Option<(Shape, String)> = if cur.eat("((") {
Some((Shape::Circle, close(cur, "))", lineno)?))
} else if cur.eat("([") {
Some((Shape::Stadium, close(cur, "])", lineno)?))
} else if cur.eat("[") {
Some((Shape::Rect, close(cur, "]", lineno)?))
} else if cur.eat("(") {
Some((Shape::Rounded, close(cur, ")", lineno)?))
} else if cur.eat("{") {
Some((Shape::Diamond, close(cur, "}", lineno)?))
} else {
None
};
let (shape, label) = match parsed {
Some((s, l)) => (Some(s), Some(clean_label(&l))),
None => (None, None),
};
let n = g.ensure_node(&id, label, shape);
if cur.eat(":::") {
let start = cur.pos;
while let Some(c) = cur.peek() {
if c.is_alphanumeric() || c == '_' || c == '-' {
cur.bump();
} else {
break;
}
}
if cur.pos == start {
return Err(err(lineno, "expected a class name after ':::'".to_string()));
}
assigns.push((n, cur.s[start..cur.pos].to_string()));
}
Ok(n)
}
fn close(cur: &mut Cur<'_>, closer: &str, lineno: usize) -> Result<String, ParseError> {
if cur.rest().starts_with('"') {
cur.bump();
let inner = cur
.take_until("\"")
.ok_or_else(|| err(lineno, "unclosed label quote".to_string()))?;
cur.skip_ws();
if !cur.eat(closer) {
return Err(err(lineno, format!("closing '{}' not found", closer)));
}
return Ok(inner);
}
cur.take_until(closer)
.ok_or_else(|| err(lineno, format!("closing '{}' not found", closer)))
}
fn parse_edge_op(cur: &mut Cur<'_>) -> Option<EdgeKind> {
let rest = cur.rest();
if rest.starts_with("-.") {
let after = &rest[2..];
let dots = after.chars().take_while(|&c| c == '.').count();
let tail = &after[dots..];
if tail.starts_with("->") {
cur.pos += 2 + dots + 2;
return Some(EdgeKind::Dotted);
}
return None;
}
if rest.starts_with("==") {
let eqs = rest.chars().take_while(|&c| c == '=').count();
let tail = &rest[eqs..];
if tail.starts_with('>') {
cur.pos += eqs + 1;
return Some(EdgeKind::Thick);
}
return None;
}
if rest.starts_with('-') {
let dashes = rest.chars().take_while(|&c| c == '-').count();
let tail = &rest[dashes..];
if dashes >= 2 && tail.starts_with('>') {
cur.pos += dashes + 1;
return Some(EdgeKind::Arrow);
}
if dashes >= 3 {
cur.pos += dashes;
return Some(EdgeKind::Open);
}
}
None
}
fn parse_er(source: &str, header_line: usize) -> Result<ErDiagram, ParseError> {
let mut d = ErDiagram::default();
let mut open: Option<(usize, usize)> = None; for (i, raw) in source.lines().enumerate() {
let lineno = i + 1;
if lineno <= header_line {
continue;
}
let line = raw.trim();
if line.is_empty() || line.starts_with("%%") {
continue;
}
if let Some((ent, _)) = open {
if line == "}" {
open = None;
continue;
}
let attr = parse_attr(line, lineno)?;
d.entities[ent].attrs.push(attr);
continue;
}
if line == "}" {
return Err(err(lineno, "'}' without an open entity block".to_string()));
}
if let Some(head) = line.strip_suffix('{') {
let name = head.trim();
if !name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-') {
open = Some((d.ensure_entity(name), lineno));
continue;
}
return Err(err(lineno, format!("invalid entity name before '{{': '{}'", name)));
}
parse_er_statement(&mut d, line, lineno)?;
}
if let Some((ent, opened)) = open {
return Err(err(
opened,
format!(
"entity block '{}' is never closed with '}}'",
d.entities[ent].name
),
));
}
Ok(d)
}
fn parse_er_statement(d: &mut ErDiagram, line: &str, lineno: usize) -> Result<(), ParseError> {
let mut cur = Cur::new(line);
let a = parse_er_name(&mut cur, lineno)?;
cur.skip_ws();
if cur.at_end() {
d.ensure_entity(&a);
return Ok(());
}
let (card_from, identifying, card_to) = parse_rel_op(&mut cur).ok_or_else(|| {
err(
lineno,
format!("unknown relationship operator near: '{}'", cur.rest()),
)
})?;
cur.skip_ws();
let b = parse_er_name(&mut cur, lineno)?;
cur.skip_ws();
let label = if cur.eat(":") {
let l = cur.rest().trim();
if l.is_empty() {
None
} else {
Some(clean_label(l))
}
} else if cur.at_end() {
None
} else {
return Err(err(
lineno,
format!("unexpected text after relationship: '{}'", cur.rest()),
));
};
let from = d.ensure_entity(&a);
let to = d.ensure_entity(&b);
d.relations.push(Relation {
from,
to,
card_from,
card_to,
identifying,
label,
});
Ok(())
}
fn parse_er_name(cur: &mut Cur<'_>, lineno: usize) -> Result<String, ParseError> {
cur.skip_ws();
let start = cur.pos;
while let Some(c) = cur.peek() {
if c.is_alphanumeric() || c == '_' || c == '-' {
cur.bump();
} else {
break;
}
}
if cur.pos == start {
return Err(err(
lineno,
format!("expected an entity name, found: '{}'", cur.rest()),
));
}
Ok(cur.s[start..cur.pos].to_string())
}
fn parse_rel_op(cur: &mut Cur<'_>) -> Option<(Card, bool, Card)> {
const LEFT: &[(&str, Card)] = &[
("||", Card::One),
("|o", Card::ZeroOne),
("}o", Card::ZeroMany),
("}|", Card::OneMany),
];
const RIGHT: &[(&str, Card)] = &[
("||", Card::One),
("o|", Card::ZeroOne),
("o{", Card::ZeroMany),
("|{", Card::OneMany),
];
let rest = cur.rest();
let (lt, lc) = LEFT.iter().find(|(t, _)| rest.starts_with(t))?;
let after_left = &rest[lt.len()..];
let identifying = if after_left.starts_with("--") {
true
} else if after_left.starts_with("..") {
false
} else {
return None;
};
let after_line = &after_left[2..];
let (rt, rc) = RIGHT.iter().find(|(t, _)| after_line.starts_with(t))?;
cur.pos += lt.len() + 2 + rt.len();
Some((*lc, identifying, *rc))
}
fn parse_attr(line: &str, lineno: usize) -> Result<Attr, ParseError> {
let (head, comment) = match line.find('"') {
Some(q0) => {
let q1 = line.rfind('"').unwrap();
if q1 <= q0 {
return Err(err(lineno, "unclosed attribute comment quote".to_string()));
}
(line[..q0].trim_end(), Some(line[q0 + 1..q1].to_string()))
}
None => (line, None),
};
let mut toks = head.split_whitespace();
let ty = toks
.next()
.ok_or_else(|| err(lineno, "expected an attribute type".to_string()))?
.to_string();
let name = toks
.next()
.ok_or_else(|| err(lineno, format!("expected an attribute name after type '{}'", ty)))?
.to_string();
let mut keys = Vec::new();
for t in toks {
match t.trim_end_matches(',') {
"PK" => keys.push(Key::Pk),
"FK" => keys.push(Key::Fk),
"UK" => keys.push(Key::Uk),
other => {
return Err(err(
lineno,
format!("unknown attribute key: '{}' (expected PK, FK, or UK)", other),
))
}
}
}
Ok(Attr {
ty,
name,
keys,
comment,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::Shape;
#[test]
fn basic_parse() {
let g = parse("flowchart TD\nA[Start] --> B{Check?}\nB -->|yes| C((Done))\n").unwrap();
assert_eq!(g.nodes.len(), 3);
assert_eq!(g.edges.len(), 2);
assert_eq!(g.nodes[1].shape, Shape::Diamond);
assert_eq!(g.nodes[2].shape, Shape::Circle);
assert_eq!(g.edges[1].label.as_deref(), Some("yes"));
}
#[test]
fn chains_and_semicolons() {
let g = parse("graph LR\nA --> B --> C; D -.-> A\n").unwrap();
assert_eq!(g.nodes.len(), 4);
assert_eq!(g.edges.len(), 3);
assert_eq!(g.edges[2].kind, EdgeKind::Dotted);
}
#[test]
fn quoted_labels() {
let g = parse("A[\"odd [text]?\"] --> B").unwrap();
assert_eq!(g.nodes.len(), 2);
assert_eq!(g.nodes[0].label, "odd [text]?");
}
#[test]
fn errors_carry_line_numbers() {
let e = parse("flowchart TD\nA --> \n").unwrap_err();
assert_eq!(e.line, 2);
}
#[test]
fn unsupported_diagram_types_get_explicit_errors() {
for src in ["sequenceDiagram\nA->>B: hi", "gantt\ntitle x", "stateDiagram-v2\n[*] --> A"] {
for res in [
parse(src).map(|_| ()),
parse_document(src).map(|_| ()),
] {
let e = res.unwrap_err();
assert_eq!(e.line, 1);
assert!(
e.message.contains("not supported yet"),
"message should say the type is unsupported: {}",
e.message
);
}
}
let e = parse("erDiagram\nA ||--o{ B : has").unwrap_err();
assert!(e.message.contains("parse_document"), "{}", e.message);
let g = parse("pies[Pie Chart] --> B").unwrap();
assert_eq!(g.nodes[0].id, "pies");
}
fn er(src: &str) -> ErDiagram {
match parse_document(src).unwrap() {
Document::Er(d) => d,
other => panic!("expected an ER document, got {:?}", other),
}
}
#[test]
fn er_fixture_parses_with_expected_counts() {
let d = er(include_str!("../examples/er.mmd"));
let counts: Vec<(&str, usize)> = d
.entities
.iter()
.map(|e| (e.name.as_str(), e.attrs.len()))
.collect();
assert_eq!(
counts,
[
("categories", 9),
("questions", 11),
("schedules", 13),
("settings", 9)
]
);
assert_eq!(d.relations.len(), 1);
let r = &d.relations[0];
assert_eq!(d.entities[r.from].name, "categories");
assert_eq!(d.entities[r.to].name, "questions");
assert_eq!(r.card_from, Card::One);
assert_eq!(r.card_to, Card::ZeroMany);
assert!(r.identifying);
assert_eq!(r.label.as_deref(), Some("has"));
}
#[test]
fn er_type_tokens_with_parens_stay_whole() {
let d = er("erDiagram\nT {\n varchar(255) name \"not null\"\n varchar(20) code\n}");
assert_eq!(d.entities[0].attrs[0].ty, "varchar(255)");
assert_eq!(d.entities[0].attrs[1].ty, "varchar(20)");
}
#[test]
fn er_comment_survives_commas_parens_and_single_quotes() {
let d = er("erDiagram\nT {\n varchar(20) difficulty \"not null, default 'medium' (see docs)\"\n}");
assert_eq!(
d.entities[0].attrs[0].comment.as_deref(),
Some("not null, default 'medium' (see docs)")
);
}
#[test]
fn er_keys_parse_and_relation_only_entities_exist() {
let d = er("erDiagram\nA ||..|{ B\nA {\n uuid id PK\n uuid b_id FK\n}");
assert_eq!(d.entities[0].attrs[0].keys, vec![Key::Pk]);
assert_eq!(d.entities[0].attrs[1].keys, vec![Key::Fk]);
assert_eq!(d.entities[1].name, "B");
assert!(d.entities[1].attrs.is_empty());
assert!(!d.relations[0].identifying);
assert_eq!(d.relations[0].card_to, Card::OneMany);
assert_eq!(d.relations[0].label, None);
}
#[test]
fn er_errors_have_line_numbers() {
let e = parse_document("erDiagram\nA {\n uuid id\n").unwrap_err();
assert_eq!(e.line, 2);
let e = parse_document("erDiagram\n}\n").unwrap_err();
assert_eq!(e.line, 2);
let e = parse_document("erDiagram\nA >>-- B\n").unwrap_err();
assert_eq!(e.line, 2);
}
#[test]
fn style_classdef_and_triple_colon() {
let g = parse(
"flowchart TD\n\
A[Server] --> B{Ok?}\n\
B --> C:::hot\n\
style A fill:#f9f,stroke:#333,stroke-width:4px\n\
classDef hot fill:#ffe3e3,stroke:#e03131,color:#c92a2a\n\
class B hot\n\
style B stroke:#000\n",
)
.unwrap();
assert_eq!(g.nodes[0].style.fill.as_deref(), Some("#f9f"));
assert_eq!(g.nodes[0].style.stroke_width, Some(4.0));
assert_eq!(g.nodes[1].style.fill.as_deref(), Some("#ffe3e3"));
assert_eq!(g.nodes[1].style.stroke.as_deref(), Some("#000"));
assert_eq!(g.nodes[1].style.color.as_deref(), Some("#c92a2a"));
assert_eq!(g.nodes[2].style.fill.as_deref(), Some("#ffe3e3"));
assert!(parse("A --> B\nstyle A rounded").is_err());
assert!(parse("A --> B\nstyle A glow:heavy,fill:#fff").unwrap().nodes[0]
.style
.fill
.is_some());
}
#[test]
fn custom_fill_reaches_the_svg() {
let svg = crate::render_svg("A[X] --> B\nstyle A fill:#123456,color:#ffffff").unwrap();
assert!(svg.contains("fill=\"#123456\""));
assert!(svg.contains("fill=\"#ffffff\">X</text>"));
}
#[test]
fn keyword_like_id_is_not_a_header() {
let g = parse("graphics[Graphics] --> B").unwrap();
assert_eq!(g.nodes.len(), 2);
assert_eq!(g.nodes[0].id, "graphics");
assert_eq!(g.direction, crate::model::Direction::TD);
}
}