use super::model::{
Attribute, Cardinality, Direction, ErDiagram, Identification, ParseError, Relationship,
SubGraph,
};
use crate::preview::mermaid::flowchart::preprocess::preprocess;
use crate::preview::mermaid::flowchart::text::decode_label;
const MAX_RELATIONSHIPS: usize = 500;
#[cfg(test)]
pub const MAX_RELATIONSHIPS_FOR_TESTS: usize = MAX_RELATIONSHIPS;
const MAX_DEPTH: usize = 32;
pub fn parse(src: &str) -> Result<ErDiagram, ParseError> {
let pre = preprocess(src);
let lines: Vec<&str> = pre.text.split('\n').collect();
let header = find_header(&lines)?;
let mut scanner = Scanner::new(pre.title);
scanner.line(&header.trailing, header.line_index + 1)?;
for (i, line) in lines.iter().enumerate().skip(header.line_index + 1) {
scanner.line(line, i + 1)?;
}
scanner.finish()
}
pub fn is_er_diagram(src: &str) -> bool {
let pre = preprocess(src);
let lines: Vec<&str> = pre.text.split('\n').collect();
find_header(&lines).is_ok()
}
struct Header {
line_index: usize,
trailing: String,
}
fn find_header(lines: &[&str]) -> Result<Header, ParseError> {
for (i, line) in lines.iter().enumerate() {
if line.trim().is_empty() {
continue;
}
let t = line.trim_start();
if starts_ci(t, "erDiagram") {
let rest = &t[9..];
if !rest.chars().next().is_some_and(is_plain_id_char) {
return Ok(Header {
line_index: i,
trailing: rest.to_string(),
});
}
}
let header: String = t
.chars()
.take_while(|c| !c.is_whitespace())
.take(40)
.collect();
return Err(ParseError::NotAnErDiagram { header });
}
Err(ParseError::Empty)
}
fn is_plain_id_char(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
fn starts_ci(s: &str, kw: &str) -> bool {
s.len() >= kw.len() && s.is_char_boundary(kw.len()) && s[..kw.len()].eq_ignore_ascii_case(kw)
}
fn ends_ci(s: &str, kw: &str) -> bool {
let Some(cut) = s.len().checked_sub(kw.len()) else {
return false;
};
s.is_char_boundary(cut) && s[cut..].eq_ignore_ascii_case(kw)
}
struct OpenBlock {
entity: String,
line: usize,
}
struct Scanner {
out: ErDiagram,
block: Option<OpenBlock>,
frames: Vec<(String, usize)>,
acc_descr_block: bool,
relationships: usize,
subgraphs: usize,
claimed: std::collections::HashSet<String>,
}
impl Scanner {
fn new(title: Option<String>) -> Scanner {
Scanner {
out: ErDiagram::new(Direction::default(), title),
block: None,
frames: Vec::new(),
acc_descr_block: false,
relationships: 0,
subgraphs: 0,
claimed: std::collections::HashSet::new(),
}
}
fn frame(&self) -> Option<String> {
self.frames.last().map(|(id, _)| id.clone())
}
fn line(&mut self, raw: &str, line: usize) -> Result<(), ParseError> {
if self.acc_descr_block {
match raw.find('}') {
Some(end) => {
self.push_acc_descr(&raw[..end]);
self.acc_descr_block = false;
}
None => self.push_acc_descr(raw),
}
return Ok(());
}
if let Some(open) = &self.block {
let entity = open.entity.clone();
match raw.find('}') {
Some(end) => {
self.attribute(&entity, &raw[..end], line)?;
self.block = None;
let rest = raw[end + 1..].to_string();
return self.line(&rest, line);
}
None => return self.attribute(&entity, raw, line),
}
}
let text = raw.trim();
if text.is_empty() {
return Ok(());
}
if let Some(rest) = acc_descr_block_start(text) {
match rest.find('}') {
Some(end) => self.push_acc_descr(&rest[..end]),
None => {
self.push_acc_descr(rest);
self.acc_descr_block = true;
}
}
return Ok(());
}
if let Some(i) = block_open(text)? {
let head = text[..i].trim().to_string();
let rest = text[i + 1..].to_string();
let Some(id) = self.declare_entity(&head, line)? else {
return Ok(());
};
self.block = Some(OpenBlock { entity: id, line });
return self.line(&rest, line);
}
self.statement(text, line)
}
fn push_acc_descr(&mut self, text: &str) {
let t = text.trim();
if t.is_empty() {
return;
}
match &mut self.out.acc_descr {
Some(existing) => {
existing.push('\n');
existing.push_str(t);
}
None => self.out.acc_descr = Some(t.to_string()),
}
}
fn statement(&mut self, t: &str, line: usize) -> Result<(), ParseError> {
if let Some(dir) = direction_statement(t) {
if self.frames.is_empty() {
self.out.direction = dir;
}
return Ok(());
}
if let Some(v) = keyword_value(t, "accTitle") {
self.out.acc_title = Some(v);
return Ok(());
}
if let Some(v) = keyword_value(t, "accDescr") {
self.out.acc_descr = Some(v);
return Ok(());
}
if t.eq_ignore_ascii_case("accDescr") || starts_with_word(t, "accDescr") {
return Ok(());
}
for kw in ["classDef", "class", "style"] {
if starts_with_word(t, kw) {
return Ok(());
}
}
if t.eq_ignore_ascii_case("end") {
self.frames.pop();
return Ok(());
}
if starts_with_word(t, "subgraph") {
return self.open_subgraph(&t[8..], line);
}
if let Some((r, from_css, to_css)) = read_relationship(t) {
self.add_relationship(r, &from_css, &to_css);
return Ok(());
}
self.declare_entity(t, line)?;
Ok(())
}
fn open_subgraph(&mut self, rest: &str, line: usize) -> Result<(), ParseError> {
if self.frames.len() > MAX_DEPTH {
return Ok(());
}
let t = rest.trim();
let (name, title) = match read_bracket_title(t) {
Alias::Absent => (t.to_string(), None),
Alias::Unclosed => {
return Err(ParseError::UnclosedBracket {
text: format!("subgraph {t}"),
line,
})
}
Alias::Present {
name,
title,
rest: "",
} => (name, Some(title)),
Alias::Present { .. } => {
return Err(ParseError::BracketIsNotADeclaration {
text: format!("subgraph {t}"),
line,
})
}
};
let id = if name.trim().is_empty() {
self.subgraphs += 1;
format!("subGraph{}", self.subgraphs - 1)
} else {
unquote(&name)
};
let title = title
.map(|t| decode_label(&t))
.unwrap_or_else(|| id.clone());
let parent = self.frame();
if !self.out.subgraphs.iter().any(|s| s.id == id) {
self.out.subgraphs.push(SubGraph {
id: id.clone(),
title,
members: Vec::new(),
parent: parent.clone(),
});
if let Some(p) = &parent {
let child = id.clone();
if let Some(s) = self.out.subgraphs.iter_mut().find(|s| &s.id == p) {
if !s.members.contains(&child) {
s.members.push(child);
}
}
}
}
self.frames.push((id, line));
Ok(())
}
fn declare_entity(&mut self, raw: &str, line: usize) -> Result<Option<String>, ParseError> {
let t = raw.trim();
if t.is_empty() {
return Ok(None);
}
let (head, alias, mut css) = match read_bracket_title(t) {
Alias::Absent => (t.to_string(), None, Vec::new()),
Alias::Unclosed => {
return Err(ParseError::UnclosedBracket {
text: t.to_string(),
line,
})
}
Alias::Present { name, title, rest } => {
let (before, classes) = split_style_separator(rest);
if !before.trim().is_empty() {
return Err(ParseError::BracketIsNotADeclaration {
text: t.to_string(),
line,
});
}
(name, Some(title), classes)
}
};
let (name, own_css) = split_style_separator(&head);
css.extend(own_css);
let id = unquote(&name);
if id.is_empty() || !(is_quoted(&name) || is_entity_name(&id)) {
return Ok(None);
}
let alias = alias.map(|a| decode_label(&unquote(&a)));
self.out.intern(&id, alias.as_deref());
self.place(&id);
if !css.is_empty() {
if let Some(e) = self.out.entity_mut(&id) {
for name in css {
if !e.css_classes.contains(&name) {
e.css_classes.push(name);
}
}
}
}
Ok(Some(id))
}
fn attribute(&mut self, entity: &str, raw: &str, line: usize) -> Result<(), ParseError> {
let t = raw.trim();
if t.is_empty() {
return Ok(());
}
let (words, comment) = split_comment(t, line)?;
let mut parts = words.split_whitespace();
let (Some(kind), Some(name)) = (parts.next(), parts.next()) else {
return Ok(());
};
let keys: Vec<String> = parts
.flat_map(|p| p.split(','))
.map(|k| k.trim().to_uppercase())
.filter(|k| matches!(k.as_str(), "PK" | "FK" | "UK"))
.collect();
if let Some(e) = self.out.entity_mut(entity) {
e.attributes.push(Attribute {
kind: kind.to_string(),
name: name.to_string(),
keys,
comment,
});
}
Ok(())
}
fn place(&mut self, id: &str) {
if self.claimed.contains(id) {
return;
}
self.claimed.insert(id.to_string());
let Some(frame) = self.frame() else { return };
if let Some(e) = self.out.entity_mut(id) {
e.parent = Some(frame.clone());
}
if let Some(s) = self.out.subgraphs.iter_mut().find(|s| s.id == frame) {
s.members.push(id.to_string());
}
}
fn add_relationship(&mut self, mut r: Relationship, from_css: &[String], to_css: &[String]) {
if self.relationships >= MAX_RELATIONSHIPS {
return;
}
for (id, css) in [(r.from.clone(), from_css), (r.to.clone(), to_css)] {
if self.out.subgraphs.iter().any(|s| s.id == id) {
continue;
}
self.out.intern(&id, None);
self.place(&id);
if let Some(e) = self.out.entity_mut(&id) {
for name in css {
if !e.css_classes.contains(name) {
e.css_classes.push(name.clone());
}
}
}
}
r.id = format!("r{}", self.relationships);
self.relationships += 1;
self.out.relationships.push(r);
}
fn finish(mut self) -> Result<ErDiagram, ParseError> {
if let Some(open) = &self.block {
return Err(ParseError::UnclosedBlock {
id: open.entity.clone(),
line: open.line,
});
}
if let Some((id, line)) = self.frames.first() {
return Err(ParseError::UnclosedSubgraph {
id: id.clone(),
line: *line,
});
}
let known: Vec<String> = self.out.entities.iter().map(|e| e.id.clone()).collect();
let frames: Vec<String> = self.out.subgraphs.iter().map(|s| s.id.clone()).collect();
let placed = |id: &String| known.iter().any(|k| k == id) || frames.iter().any(|f| f == id);
self.out
.relationships
.retain(|r| placed(&r.from) && placed(&r.to));
if self.out.entities.is_empty() {
return Err(ParseError::NoEntities);
}
Ok(self.out)
}
}
const CARDINALITY_WORDS: &[(&str, Cardinality)] = &[
("one or zero", Cardinality::ZeroOrOne),
("one or more", Cardinality::OneOrMore),
("one or many", Cardinality::OneOrMore),
("zero or one", Cardinality::ZeroOrOne),
("zero or more", Cardinality::ZeroOrMore),
("zero or many", Cardinality::ZeroOrMore),
("only one", Cardinality::OnlyOne),
("many(0)", Cardinality::ZeroOrMore),
("many(1)", Cardinality::OneOrMore),
("many", Cardinality::ZeroOrMore),
("one", Cardinality::OnlyOne),
("1+", Cardinality::OneOrMore),
("0+", Cardinality::ZeroOrMore),
("1", Cardinality::OnlyOne),
];
const CARDINALITY_LEFT: &[(&str, Cardinality)] = &[
("||", Cardinality::OnlyOne),
("|o", Cardinality::ZeroOrOne),
("}o", Cardinality::ZeroOrMore),
("}|", Cardinality::OneOrMore),
("u", Cardinality::MdParent),
];
const CARDINALITY_RIGHT: &[(&str, Cardinality)] = &[
("||", Cardinality::OnlyOne),
("o|", Cardinality::ZeroOrOne),
("o{", Cardinality::ZeroOrMore),
("|{", Cardinality::OneOrMore),
];
pub fn read_relationship(t: &str) -> Option<(Relationship, Vec<String>, Vec<String>)> {
let (head, label) = split_role(t);
let (start, end, identification) = find_operator(head)?;
let left = head[..start].trim_end();
let right = head[end..].trim_start();
let (from_raw, from_cardinality) = strip_trailing_cardinality(left);
let (to_cardinality, to_raw) = strip_leading_cardinality(right);
let (from_name, from_css) = split_style_separator(from_raw.trim());
let (to_name, to_css) = split_style_separator(to_raw.trim());
let from = unquote(&from_name);
let to = unquote(&to_name);
let usable = |raw: &str, id: &str| !id.is_empty() && (is_quoted(raw) || is_entity_name(id));
if !usable(&from_name, &from) || !usable(&to_name, &to) {
return None;
}
Some((
Relationship {
id: String::new(),
from,
to,
from_cardinality,
to_cardinality,
identification,
label,
},
from_css,
to_css,
))
}
fn split_role(t: &str) -> (&str, Option<String>) {
let bytes = t.as_bytes();
let mut i = 0;
let mut quoted = false;
while i < bytes.len() {
match bytes[i] {
b'"' => quoted = !quoted,
b':' if !quoted => {
if t[i..].starts_with(":::") {
i += 3;
continue;
}
let role = t[i + 1..].trim();
let role = unquote(role);
return (
&t[..i],
(!role.trim().is_empty()).then(|| decode_label(&role)),
);
}
_ => {}
}
i += 1;
}
(t, None)
}
fn find_operator(s: &str) -> Option<(usize, usize, Identification)> {
let bytes = s.as_bytes();
let mut quoted = false;
for (i, c) in s.char_indices() {
if c == '"' {
quoted = !quoted;
continue;
}
if quoted {
continue;
}
if i + 1 < bytes.len() {
let kind = match (bytes[i], bytes[i + 1]) {
(b'-', b'-') => Some(Identification::Identifying),
(b'.', b'.') | (b'.', b'-') | (b'-', b'.') => Some(Identification::NonIdentifying),
_ => None,
};
if let Some(kind) = kind {
return Some((i, i + 2, kind));
}
}
for (word, kind) in [
("optionally to", Identification::NonIdentifying),
("to", Identification::Identifying),
] {
if word_at(s, i, word) {
return Some((i, i + word.len(), kind));
}
}
}
None
}
fn word_at(s: &str, i: usize, word: &str) -> bool {
if !s.is_char_boundary(i) || i + word.len() > s.len() {
return false;
}
if !s.is_char_boundary(i + word.len()) || !s[i..i + word.len()].eq_ignore_ascii_case(word) {
return false;
}
let before = s[..i].chars().next_back();
let after = s[i + word.len()..].chars().next();
!before.is_some_and(is_name_char) && !after.is_some_and(is_name_char)
}
fn strip_trailing_cardinality(s: &str) -> (String, Cardinality) {
let t = s.trim_end();
for (token, card) in CARDINALITY_LEFT {
if let Some(rest) = t.strip_suffix(*token) {
if *token == "u" && rest.chars().next_back().is_some_and(is_name_char) {
continue;
}
if !rest.trim().is_empty() {
return (rest.trim().to_string(), *card);
}
}
}
for (word, card) in CARDINALITY_WORDS {
if ends_ci(t, word) {
let cut = t.len() - word.len();
let rest = &t[..cut];
if rest.trim().is_empty() || rest.chars().next_back().is_some_and(is_name_char) {
continue;
}
return (rest.trim().to_string(), *card);
}
}
(t.to_string(), Cardinality::default())
}
fn strip_leading_cardinality(s: &str) -> (Cardinality, String) {
let t = s.trim_start();
for (token, card) in CARDINALITY_RIGHT {
if let Some(rest) = t.strip_prefix(*token) {
if !rest.trim().is_empty() {
return (*card, rest.trim().to_string());
}
}
}
for (word, card) in CARDINALITY_WORDS {
if starts_ci(t, word) {
let rest = &t[word.len()..];
if rest.trim().is_empty() || rest.chars().next().is_some_and(is_name_char) {
continue;
}
return (*card, rest.trim().to_string());
}
}
(Cardinality::default(), t.to_string())
}
fn is_name_char(c: char) -> bool {
c.is_alphanumeric() || matches!(c, '_' | '-' | '*' | '.') || !c.is_ascii()
}
fn is_entity_name(id: &str) -> bool {
!id.is_empty() && id.chars().all(is_name_char)
}
fn is_quoted(s: &str) -> bool {
let t = s.trim();
t.len() >= 2 && t.starts_with('"') && t.ends_with('"')
}
fn block_open(s: &str) -> Result<Option<usize>, ParseError> {
let bytes = s.as_bytes();
let mut i = 0;
let mut quoted = false;
while i < bytes.len() {
match bytes[i] {
b'"' => quoted = !quoted,
b'{' if !quoted => {
if i > 0 && matches!(bytes[i - 1], b'o' | b'O' | b'|') {
i += 1;
continue;
}
return Ok(Some(i));
}
_ => {}
}
i += 1;
}
Ok(None)
}
fn split_comment(s: &str, line: usize) -> Result<(String, String), ParseError> {
let Some(i) = s.find('"') else {
return Ok((s.to_string(), String::new()));
};
let Some(end) = s[i + 1..].find('"') else {
return Err(ParseError::UnclosedString { line });
};
Ok((
s[..i].to_string(),
decode_label(&s[i + 1..i + 1 + end]).to_string(),
))
}
enum Alias<'a> {
Absent,
Present {
name: String,
title: String,
rest: &'a str,
},
Unclosed,
}
fn read_bracket_title(s: &str) -> Alias<'_> {
let t = s.trim();
let mut quoted = false;
let mut open = None;
for (i, c) in t.char_indices() {
match c {
'"' => quoted = !quoted,
'[' if !quoted => {
open = Some(i);
break;
}
_ => {}
}
}
let Some(open) = open else {
return Alias::Absent;
};
let mut quoted = false;
let mut close = None;
for (i, c) in t[open + 1..].char_indices() {
match c {
'"' => quoted = !quoted,
']' if !quoted => {
close = Some(open + 1 + i);
break;
}
_ => {}
}
}
let Some(close) = close else {
return Alias::Unclosed;
};
Alias::Present {
name: t[..open].trim().to_string(),
title: t[open + 1..close].trim().to_string(),
rest: t[close + 1..].trim(),
}
}
fn unquote(s: &str) -> String {
let t = s.trim();
match (t.strip_prefix('"'), t.strip_suffix('"')) {
(Some(_), Some(_)) if t.len() >= 2 => t[1..t.len() - 1].to_string(),
_ => t.to_string(),
}
}
fn split_style_separator(s: &str) -> (String, Vec<String>) {
match s.find(":::") {
Some(i) => {
let classes: Vec<String> = s[i + 3..]
.split(',')
.map(|c| c.trim().to_string())
.filter(|c| !c.is_empty())
.collect();
(s[..i].trim().to_string(), classes)
}
None => (s.trim().to_string(), Vec::new()),
}
}
fn starts_with_word(s: &str, kw: &str) -> bool {
starts_ci(s, kw) && s[kw.len()..].starts_with(char::is_whitespace)
}
fn keyword_value(s: &str, kw: &str) -> Option<String> {
if !starts_ci(s, kw) {
return None;
}
let rest = s[kw.len()..].trim_start();
let rest = rest.strip_prefix(':')?;
Some(rest.trim().to_string())
}
fn direction_statement(s: &str) -> Option<Direction> {
if !starts_with_word(s, "direction") {
return None;
}
Direction::parse(s[9..].trim())
}
fn acc_descr_block_start(line: &str) -> Option<&str> {
let t = line.trim_start();
if !starts_ci(t, "accDescr") {
return None;
}
t[8..].trim_start().strip_prefix('{')
}