use std::fmt;
use crate::model::ids::ObjectId;
use crate::model::{ErAst, ErCardinality, ErEntity, ErRelationship, ErStroke};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MermaidErParseError {
MissingHeader,
EmptyInput,
UnsupportedLine { line_no: usize, line: String },
InvalidRelationship { line_no: usize, line: String },
}
impl fmt::Display for MermaidErParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingHeader => f.write_str("expected 'erDiagram' as the first non-empty line"),
Self::EmptyInput => f.write_str("empty er diagram input"),
Self::UnsupportedLine { line_no, line } => {
write!(f, "unsupported erDiagram line {line_no}: {line}")
}
Self::InvalidRelationship { line_no, line } => {
write!(f, "invalid er relationship on line {line_no}: {line}")
}
}
}
}
impl std::error::Error for MermaidErParseError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MermaidErExportError {
EmptyEntityName { entity_id: ObjectId },
MissingRelationshipEndpoint {
relationship_id: ObjectId,
endpoint: &'static str,
entity_id: ObjectId,
},
}
impl fmt::Display for MermaidErExportError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::EmptyEntityName { entity_id } => {
write!(f, "entity {entity_id} has an empty name")
}
Self::MissingRelationshipEndpoint { relationship_id, endpoint, entity_id } => {
write!(
f,
"er relationship {relationship_id} references missing {endpoint} entity {entity_id}"
)
}
}
}
}
impl std::error::Error for MermaidErExportError {}
fn sanitize_fragment(name: &str) -> String {
let mut out = String::with_capacity(name.len());
for ch in name.chars() {
if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
out.push(ch);
} else {
out.push('_');
}
}
if out.is_empty() {
out.push_str("entity");
}
out
}
fn entity_id_from_name(name: &str) -> ObjectId {
ObjectId::new(format!("e:{}", sanitize_fragment(name))).expect("entity id")
}
fn ensure_entity(ast: &mut ErAst, name: &str) -> ObjectId {
let id = entity_id_from_name(name);
ast.entities_mut().entry(id.clone()).or_insert_with(|| ErEntity::new(name));
id
}
pub fn fold_cardinality_token(token: &str) -> Option<ErCardinality> {
match token {
"||" => Some(ErCardinality::ExactlyOne),
"|o" | "o|" => Some(ErCardinality::ZeroOrOne),
"|{" | "}|" => Some(ErCardinality::OneOrMore),
"}o" | "o{" => Some(ErCardinality::ZeroOrMore),
_ => None,
}
}
fn card_export(card: ErCardinality, left: bool) -> &'static str {
match (card, left) {
(ErCardinality::ExactlyOne, _) => "||",
(ErCardinality::ZeroOrOne, true) => "|o",
(ErCardinality::ZeroOrOne, false) => "o|",
(ErCardinality::OneOrMore, true) => "}|",
(ErCardinality::OneOrMore, false) => "|{",
(ErCardinality::ZeroOrMore, true) => "}o",
(ErCardinality::ZeroOrMore, false) => "o{",
}
}
pub fn parse_er_diagram(input: &str) -> Result<ErAst, MermaidErParseError> {
let mut lines = input.lines().enumerate().filter(|(_, l)| {
let t = l.trim();
!t.is_empty() && !t.starts_with("%%")
});
let Some((_, first)) = lines.next() else {
return Err(MermaidErParseError::EmptyInput);
};
if first.trim() != "erDiagram" {
return Err(MermaidErParseError::MissingHeader);
}
let mut ast = ErAst::default();
let mut rel_seq = 0u32;
for (idx, line) in lines {
let line_no = idx + 1;
let trimmed = line.trim();
let (body, label) = if let Some((left, right)) = trimmed.split_once(" : ") {
(left.trim(), Some(right.trim()))
} else if let Some((left, right)) = trimmed.split_once(':') {
(left.trim(), Some(right.trim()))
} else {
(trimmed, None)
};
let stroke_sep = if body.contains("..") {
".."
} else if body.contains("--") {
"--"
} else {
if body.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-') {
ensure_entity(&mut ast, body);
continue;
}
return Err(MermaidErParseError::UnsupportedLine { line_no, line: trimmed.to_owned() });
};
let stroke =
if stroke_sep == ".." { ErStroke::NonIdentifying } else { ErStroke::Identifying };
let Some(sep_idx) = body.find(stroke_sep) else {
return Err(MermaidErParseError::InvalidRelationship {
line_no,
line: trimmed.to_owned(),
});
};
let left_part = body[..sep_idx].trim();
let right_part = body[sep_idx + stroke_sep.len()..].trim();
let (left_name, left_card_tok) = split_name_card(left_part, true).ok_or_else(|| {
MermaidErParseError::InvalidRelationship { line_no, line: trimmed.to_owned() }
})?;
let (right_card_tok, right_name) = split_name_card(right_part, false).ok_or_else(|| {
MermaidErParseError::InvalidRelationship { line_no, line: trimmed.to_owned() }
})?;
let from_card = fold_cardinality_token(left_card_tok).ok_or_else(|| {
MermaidErParseError::InvalidRelationship { line_no, line: trimmed.to_owned() }
})?;
let to_card = fold_cardinality_token(right_card_tok).ok_or_else(|| {
MermaidErParseError::InvalidRelationship { line_no, line: trimmed.to_owned() }
})?;
let from_id = ensure_entity(&mut ast, left_name);
let to_id = ensure_entity(&mut ast, right_name);
rel_seq = rel_seq.saturating_add(1);
let rel_id = ObjectId::new(format!("r:{rel_seq:04}")).expect("rel id");
let raw = format!("{left_card_tok}{stroke_sep}{right_card_tok}");
let rel = ErRelationship::new(from_id, to_id, from_card, to_card)
.with_stroke(stroke)
.with_label(label.map(str::to_owned))
.with_raw_connector(Some(raw));
ast.relationships_mut().insert(rel_id, rel);
}
Ok(ast)
}
fn split_name_card(part: &str, name_first: bool) -> Option<(&str, &str)> {
let part = part.trim();
if part.len() < 3 {
return None;
}
if name_first {
for tok in ["||", "|o", "o|", "|{", "}|", "}o", "o{"] {
if let Some(idx) = part.rfind(tok) {
let name = part[..idx].trim();
if !name.is_empty() && part[idx..].trim() == tok {
return Some((name, tok));
}
}
}
} else {
for tok in ["||", "|o", "o|", "|{", "}|", "}o", "o{"] {
if let Some(rest) = part.strip_prefix(tok) {
let name = rest.trim();
if !name.is_empty() {
return Some((tok, name));
}
}
}
}
None
}
pub fn export_er_diagram(ast: &ErAst) -> Result<String, MermaidErExportError> {
let mut out = String::from("erDiagram\n");
for (entity_id, entity) in ast.entities() {
if entity.name().is_empty() {
return Err(MermaidErExportError::EmptyEntityName { entity_id: entity_id.clone() });
}
}
for entity in ast.entities().values() {
out.push_str(&format!(" {}\n", entity.name()));
}
for (relationship_id, rel) in ast.relationships() {
let from =
ast.entities().get(rel.from_entity_id()).map(ErEntity::name).ok_or_else(|| {
MermaidErExportError::MissingRelationshipEndpoint {
relationship_id: relationship_id.clone(),
endpoint: "from",
entity_id: rel.from_entity_id().clone(),
}
})?;
let to = ast.entities().get(rel.to_entity_id()).map(ErEntity::name).ok_or_else(|| {
MermaidErExportError::MissingRelationshipEndpoint {
relationship_id: relationship_id.clone(),
endpoint: "to",
entity_id: rel.to_entity_id().clone(),
}
})?;
let left = card_export(rel.from_card(), true);
let right = card_export(rel.to_card(), false);
let sep = match rel.stroke() {
ErStroke::Identifying => "--",
ErStroke::NonIdentifying => "..",
};
if let Some(label) = rel.label() {
out.push_str(&format!(" {from} {left}{sep}{right} {to} : {label}\n"));
} else {
out.push_str(&format!(" {from} {left}{sep}{right} {to}\n"));
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_screenshot_like_er() {
let input = r#"
erDiagram
CUSTOMER ||--o{ ORDER : places
ORDER ||--|{ LINE-ITEM : contains
CUSTOMER }|..|{ DELIVERY-ADDRESS : uses
"#;
let ast = parse_er_diagram(input).expect("parse");
assert_eq!(ast.entities().len(), 4);
assert_eq!(ast.relationships().len(), 3);
let places = ast.relationships().values().find(|r| r.label() == Some("places")).unwrap();
assert_eq!(places.from_card(), ErCardinality::ExactlyOne);
assert_eq!(places.to_card(), ErCardinality::ZeroOrMore);
}
#[test]
fn fold_cardinality_is_exhaustive_for_supported_set() {
for tok in ["||", "|o", "o|", "|{", "}|", "}o", "o{"] {
assert!(fold_cardinality_token(tok).is_some(), "{tok}");
}
}
#[test]
fn export_roundtrip() {
let input = "erDiagram\nA ||--o{ B : r\n";
let a1 = parse_er_diagram(input).expect("p1");
let out = export_er_diagram(&a1).expect("export");
let a2 = parse_er_diagram(&out).expect("p2");
assert_eq!(a1.entities().len(), a2.entities().len());
assert_eq!(a1.relationships().len(), a2.relationships().len());
}
#[test]
fn export_rejects_missing_relationship_endpoint() {
let mut ast = ErAst::default();
let a = entity_id_from_name("A");
ast.entities_mut().insert(a.clone(), ErEntity::new("A"));
ast.relationships_mut().insert(
ObjectId::new("r:1").unwrap(),
ErRelationship::new(
a,
ObjectId::new("e:missing").unwrap(),
ErCardinality::ExactlyOne,
ErCardinality::ExactlyOne,
),
);
assert!(matches!(
export_er_diagram(&ast),
Err(MermaidErExportError::MissingRelationshipEndpoint { endpoint: "to", .. })
));
}
}