use crate::Error;
use crate::er::{Attribute, AttributeKey, Cardinality, ErDiagram, LineStyle, Relationship};
use crate::parser::common::strip_inline_comment;
pub fn parse(src: &str) -> Result<ErDiagram, Error> {
let mut diag = ErDiagram::default();
let mut header_seen = false;
let mut current_entity: Option<usize> = None;
for raw in src.lines() {
let line = strip_inline_comment(raw).trim();
if line.is_empty() {
continue;
}
if !header_seen {
if !line.eq_ignore_ascii_case("erdiagram") {
return Err(Error::ParseError(format!(
"expected `erDiagram` header, got {line:?}"
)));
}
header_seen = true;
continue;
}
if let Some(entity_idx) = current_entity {
if line == "}" {
current_entity = None;
continue;
}
let attribute = parse_attribute_row(line)?;
diag.entities[entity_idx].attributes.push(attribute);
continue;
}
if line == "}" {
return Err(Error::ParseError(
"stray `}` outside any entity block".to_string(),
));
}
let has_connector = line.contains("--") || line.contains("..");
if !has_connector {
if let Some(name_part) = line.strip_suffix('{') {
let name = name_part.trim();
if name.is_empty() {
return Err(Error::ParseError(
"entity block opener missing entity name".to_string(),
));
}
if name
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
let idx = diag.ensure_entity(name);
current_entity = Some(idx);
continue;
}
}
if let Some(brace_pos) = line.find(" {") {
let name = line[..brace_pos].trim();
let after_open = line[brace_pos + 2..].trim(); if !name.is_empty()
&& name
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
&& after_open.ends_with('}')
{
let attrs_str = after_open[..after_open.len() - 1].trim();
let idx = diag.ensure_entity(name);
if !attrs_str.is_empty() {
let attrs = parse_inline_attribute_list(attrs_str)?;
diag.entities[idx].attributes.extend(attrs);
}
continue;
}
}
}
let rel = parse_relationship_line(line)?;
diag.ensure_entity(&rel.from);
diag.ensure_entity(&rel.to);
diag.relationships.push(rel);
}
if !header_seen {
return Err(Error::ParseError(
"missing `erDiagram` header line".to_string(),
));
}
if let Some(idx) = current_entity {
return Err(Error::ParseError(format!(
"unclosed entity block for `{}` (missing `}}`)",
diag.entities[idx].name
)));
}
Ok(diag)
}
fn parse_inline_attribute_list(attrs_str: &str) -> Result<Vec<Attribute>, Error> {
let mut result: Vec<Attribute> = Vec::new();
let mut type_name: Option<String> = None;
let mut attr_name: Option<String> = None;
let mut keys: Vec<AttributeKey> = Vec::new();
let mut comment: Option<String> = None;
let flush = |result: &mut Vec<Attribute>,
type_name: &mut Option<String>,
attr_name: &mut Option<String>,
keys: &mut Vec<AttributeKey>,
comment: &mut Option<String>|
-> Result<(), Error> {
if let (Some(t), Some(n)) = (type_name.take(), attr_name.take()) {
result.push(Attribute {
type_name: t,
name: n,
keys: std::mem::take(keys),
comment: comment.take(),
});
}
Ok(())
};
let mut working = attrs_str.to_string();
let mut quoted_comments: Vec<String> = Vec::new();
while let Some(open) = working.find('"') {
let after_open = &working[open + 1..];
if let Some(rel_close) = after_open.find('"') {
let close = open + 1 + rel_close;
quoted_comments.push(working[open + 1..close].to_string());
let replacement = format!("__COMMENT{}__", quoted_comments.len() - 1);
working = format!(
"{}{}{}",
&working[..open],
replacement,
&working[close + 1..]
);
} else {
break; }
}
for token in working.split_whitespace() {
if let Some(idx_str) = token
.strip_prefix("__COMMENT")
.and_then(|s| s.strip_suffix("__"))
&& let Ok(idx) = idx_str.parse::<usize>()
&& idx < quoted_comments.len()
{
comment = Some(quoted_comments[idx].clone());
continue;
}
let maybe_key = match token {
"PK" => Some(AttributeKey::PrimaryKey),
"FK" => Some(AttributeKey::ForeignKey),
"UK" => Some(AttributeKey::UniqueKey),
_ => None,
};
match (&type_name, &attr_name) {
(None, _) => {
type_name = Some(token.to_string());
}
(Some(_), None) => {
attr_name = Some(token.to_string());
}
(Some(_), Some(_)) => {
if let Some(k) = maybe_key {
keys.push(k);
} else {
flush(
&mut result,
&mut type_name,
&mut attr_name,
&mut keys,
&mut comment,
)?;
type_name = Some(token.to_string());
}
}
}
}
flush(
&mut result,
&mut type_name,
&mut attr_name,
&mut keys,
&mut comment,
)?;
Ok(result)
}
fn parse_attribute_row(line: &str) -> Result<Attribute, Error> {
let (head, comment) = match line.rfind('"') {
Some(close) if close > 0 => match line[..close].rfind('"') {
Some(open) => (
line[..open].trim_end(),
Some(line[open + 1..close].to_string()),
),
None => (line, None),
},
_ => (line, None),
};
let mut tokens = head.split_whitespace();
let type_name = tokens
.next()
.ok_or_else(|| Error::ParseError(format!("attribute row missing type: {line:?}")))?;
let name = tokens
.next()
.ok_or_else(|| Error::ParseError(format!("attribute row missing name: {line:?}")))?;
let mut keys = Vec::new();
for tok in tokens {
for piece in tok.split(',') {
let piece = piece.trim();
if piece.is_empty() {
continue;
}
keys.push(parse_attribute_key(piece, line)?);
}
}
Ok(Attribute {
type_name: type_name.to_string(),
name: name.to_string(),
keys,
comment,
})
}
fn parse_attribute_key(token: &str, line: &str) -> Result<AttributeKey, Error> {
match token {
"PK" => Ok(AttributeKey::PrimaryKey),
"FK" => Ok(AttributeKey::ForeignKey),
"UK" => Ok(AttributeKey::UniqueKey),
other => Err(Error::ParseError(format!(
"unknown attribute key {other:?} (expected PK / FK / UK) in {line:?}"
))),
}
}
fn parse_relationship_line(line: &str) -> Result<Relationship, Error> {
let (head, label) = match line.split_once(':') {
Some((h, t)) => (h.trim_end(), Some(t.trim().trim_matches('"').to_string())),
None => (line, None),
};
let (connector_pos, line_style) = find_connector(head).ok_or_else(|| {
Error::ParseError(format!(
"relationship line missing `--` or `..` connector: {line:?}"
))
})?;
let left_block = head[..connector_pos].trim_end();
let right_block = head[connector_pos + 2..].trim_start();
let (from_name, left_card_str) = split_last_token(left_block).ok_or_else(|| {
Error::ParseError(format!(
"left side missing entity name + cardinality: {line:?}"
))
})?;
let from_cardinality = parse_left_cardinality(left_card_str, line)?;
let (right_card_str, to_name) = split_first_token(right_block).ok_or_else(|| {
Error::ParseError(format!(
"right side missing cardinality + entity name: {line:?}"
))
})?;
let to_cardinality = parse_right_cardinality(right_card_str, line)?;
Ok(Relationship {
from: from_name.to_string(),
to: to_name.to_string(),
from_cardinality,
to_cardinality,
line_style,
label,
})
}
fn find_connector(s: &str) -> Option<(usize, LineStyle)> {
let bytes = s.as_bytes();
for i in 0..bytes.len().saturating_sub(1) {
match (bytes[i], bytes[i + 1]) {
(b'-', b'-') => return Some((i, LineStyle::Identifying)),
(b'.', b'.') => return Some((i, LineStyle::NonIdentifying)),
_ => {}
}
}
None
}
fn parse_left_cardinality(token: &str, line: &str) -> Result<Cardinality, Error> {
match token {
"||" => Ok(Cardinality::ExactlyOne),
"|o" => Ok(Cardinality::ZeroOrOne),
"}|" => Ok(Cardinality::OneOrMany),
"}o" => Ok(Cardinality::ZeroOrMany),
other => Err(Error::ParseError(format!(
"invalid left-side cardinality {other:?} (expected ||, |o, }}|, or }}o) in {line:?}"
))),
}
}
fn parse_right_cardinality(token: &str, line: &str) -> Result<Cardinality, Error> {
match token {
"||" => Ok(Cardinality::ExactlyOne),
"o|" => Ok(Cardinality::ZeroOrOne),
"|{" => Ok(Cardinality::OneOrMany),
"o{" => Ok(Cardinality::ZeroOrMany),
other => Err(Error::ParseError(format!(
"invalid right-side cardinality {other:?} (expected ||, o|, |{{, or o{{) in {line:?}"
))),
}
}
fn split_last_token(s: &str) -> Option<(&str, &str)> {
let trimmed = s.trim_end();
let last_space = trimmed.rfind(char::is_whitespace)?;
let head = trimmed[..last_space].trim_end();
let tail = trimmed[last_space + 1..].trim_start();
if head.is_empty() || tail.is_empty() {
return None;
}
Some((head, tail))
}
fn split_first_token(s: &str) -> Option<(&str, &str)> {
let trimmed = s.trim_start();
let first_space = trimmed.find(char::is_whitespace)?;
let head = trimmed[..first_space].trim_end();
let tail = trimmed[first_space + 1..].trim_start();
if head.is_empty() || tail.is_empty() {
return None;
}
Some((head, tail))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_minimal_with_header_only_errors() {
let diag = parse("erDiagram").unwrap();
assert!(diag.entities.is_empty());
assert!(diag.relationships.is_empty());
}
#[test]
fn parse_missing_header_errors() {
let err = parse("CUSTOMER ||--o{ ORDER").unwrap_err();
assert!(err.to_string().contains("erDiagram"));
}
#[test]
fn parse_one_relationship_creates_two_entities() {
let diag = parse("erDiagram\nCUSTOMER ||--o{ ORDER : places").unwrap();
assert_eq!(diag.entities.len(), 2);
assert_eq!(diag.entities[0].name, "CUSTOMER");
assert_eq!(diag.entities[1].name, "ORDER");
assert_eq!(diag.relationships.len(), 1);
let r = &diag.relationships[0];
assert_eq!(r.from, "CUSTOMER");
assert_eq!(r.to, "ORDER");
assert_eq!(r.from_cardinality, Cardinality::ExactlyOne);
assert_eq!(r.to_cardinality, Cardinality::ZeroOrMany);
assert_eq!(r.line_style, LineStyle::Identifying);
assert_eq!(r.label.as_deref(), Some("places"));
}
#[test]
fn parse_all_cardinality_codes_round_trip() {
let diag = parse(
"erDiagram\n\
A ||--|| B : exact\n\
A |o--o| B : optional\n\
A }|--|{ B : many\n\
A }o--o{ B : optionalMany",
)
.unwrap();
assert_eq!(
diag.relationships[0].from_cardinality,
Cardinality::ExactlyOne
);
assert_eq!(
diag.relationships[0].to_cardinality,
Cardinality::ExactlyOne
);
assert_eq!(
diag.relationships[1].from_cardinality,
Cardinality::ZeroOrOne
);
assert_eq!(diag.relationships[1].to_cardinality, Cardinality::ZeroOrOne);
assert_eq!(
diag.relationships[2].from_cardinality,
Cardinality::OneOrMany
);
assert_eq!(diag.relationships[2].to_cardinality, Cardinality::OneOrMany);
assert_eq!(
diag.relationships[3].from_cardinality,
Cardinality::ZeroOrMany
);
assert_eq!(
diag.relationships[3].to_cardinality,
Cardinality::ZeroOrMany
);
}
#[test]
fn parse_non_identifying_line_style() {
let diag = parse("erDiagram\nA ||..o{ B").unwrap();
assert_eq!(diag.relationships[0].line_style, LineStyle::NonIdentifying);
}
#[test]
fn parse_relationship_without_label() {
let diag = parse("erDiagram\nA ||--o{ B").unwrap();
assert!(diag.relationships[0].label.is_none());
}
#[test]
fn parse_quoted_label_strips_quotes() {
let diag = parse("erDiagram\nCUSTOMER ||--o{ ORDER : \"places multiple\"").unwrap();
assert_eq!(
diag.relationships[0].label.as_deref(),
Some("places multiple")
);
}
#[test]
fn parse_entity_block_with_attributes() {
let diag = parse(
"erDiagram\n\
CUSTOMER {\n\
string name\n\
string email PK\n\
int age FK,UK\n\
}",
)
.unwrap();
assert_eq!(diag.entities.len(), 1);
let e = &diag.entities[0];
assert_eq!(e.name, "CUSTOMER");
assert_eq!(e.attributes.len(), 3);
assert_eq!(e.attributes[0].type_name, "string");
assert_eq!(e.attributes[0].name, "name");
assert!(e.attributes[0].keys.is_empty());
assert_eq!(e.attributes[1].keys, vec![AttributeKey::PrimaryKey]);
assert_eq!(
e.attributes[2].keys,
vec![AttributeKey::ForeignKey, AttributeKey::UniqueKey]
);
}
#[test]
fn parse_attribute_with_comment() {
let diag = parse("erDiagram\nA {\n string id PK \"the unique identifier\"\n}").unwrap();
let a = &diag.entities[0].attributes[0];
assert_eq!(a.comment.as_deref(), Some("the unique identifier"));
assert_eq!(a.keys, vec![AttributeKey::PrimaryKey]);
}
#[test]
fn parse_unknown_attribute_key_errors() {
let err = parse("erDiagram\nA {\n string foo XX\n}").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("XX") && msg.contains("PK"));
}
#[test]
fn parse_unclosed_entity_block_errors() {
let err = parse("erDiagram\nA {\n string name").unwrap_err();
assert!(err.to_string().contains("unclosed"));
}
#[test]
fn parse_stray_close_brace_errors() {
let err = parse("erDiagram\n}").unwrap_err();
assert!(err.to_string().contains("stray"));
}
#[test]
fn parse_missing_connector_errors() {
let err = parse("erDiagram\nA || o{ B").unwrap_err();
assert!(err.to_string().contains("connector"));
}
#[test]
fn parse_invalid_left_cardinality_errors() {
let err = parse("erDiagram\nA xy--o{ B").unwrap_err();
assert!(err.to_string().contains("left-side"));
}
#[test]
fn parse_skips_comments_and_blanks() {
let diag = parse(
"%% header comment\n\
erDiagram\n\
\n\
%% middle comment\n\
A ||--|| B",
)
.unwrap();
assert_eq!(diag.relationships.len(), 1);
}
#[test]
fn parse_entity_referenced_in_relationship_then_declared_keeps_attributes() {
let diag = parse(
"erDiagram\n\
CUSTOMER ||--o{ ORDER : places\n\
ORDER {\n int orderNumber PK\n}",
)
.unwrap();
let order_idx = diag.entity_index("ORDER").unwrap();
assert_eq!(diag.entities[order_idx].attributes.len(), 1);
}
#[test]
fn accepts_inline_attribute_block() {
let diag = parse("erDiagram\nCUSTOMER { int id PK string name }").unwrap();
let idx = diag.entity_index("CUSTOMER").unwrap();
let attrs = &diag.entities[idx].attributes;
assert_eq!(
attrs.len(),
2,
"expected 2 attributes, got {}: {attrs:?}",
attrs.len()
);
assert_eq!(attrs[0].type_name, "int");
assert_eq!(attrs[0].name, "id");
assert_eq!(attrs[0].keys, vec![AttributeKey::PrimaryKey]);
assert_eq!(attrs[1].type_name, "string");
assert_eq!(attrs[1].name, "name");
assert!(attrs[1].keys.is_empty());
}
#[test]
fn accepts_inline_attribute_block_with_multiple_keys() {
let diag = parse("erDiagram\nFOO { int id PK FK string label }").unwrap();
let attrs = &diag.entities[0].attributes;
assert_eq!(attrs.len(), 2);
assert_eq!(
attrs[0].keys,
vec![AttributeKey::PrimaryKey, AttributeKey::ForeignKey]
);
}
#[test]
fn wide_er_gallery_block_parses_successfully() {
let src = "erDiagram
CUSTOMER ||--o{ ORDER : places
ORDER ||--|{ ITEM : contains
PRODUCT ||--o{ ITEM : describes
CATEGORY ||--o{ PRODUCT : groups
ACCOUNT ||--|| CUSTOMER : owns
INVOICE ||--|{ ORDER : bills
CUSTOMER { int id PK string name }
ORDER { int id PK int customerId FK }
PRODUCT { int id PK string name int categoryId FK }
CATEGORY { int id PK string label }
ACCOUNT { int id PK }
INVOICE { int id PK }
ITEM { int orderId FK int productId FK }";
let diag = parse(src).unwrap();
assert_eq!(diag.relationships.len(), 6);
let customer_idx = diag.entity_index("CUSTOMER").unwrap();
assert_eq!(diag.entities[customer_idx].attributes.len(), 2);
let product_idx = diag.entity_index("PRODUCT").unwrap();
assert_eq!(diag.entities[product_idx].attributes.len(), 3);
}
}