use std::collections::HashSet;
use mermaid_rs_renderer::Graph;
use super::policy::PaintedKind;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum Defect {
UnclosedBlock,
Invalid,
Misread,
}
pub(super) fn check(source: &str, kind: PaintedKind, ir: &Graph) -> Result<(), Defect> {
let lines = code_lines(source);
match kind {
PaintedKind::Flowchart => flowchart_shapes_close(&lines),
PaintedKind::State => blocks_close(&lines, state_block_opener),
PaintedKind::Class => blocks_close(&lines, class_block_opener),
PaintedKind::Er => blocks_close(&lines, er_block_opener),
PaintedKind::GitGraph => branch_refs_resolve(source, &lines, &ir.gitgraph.main_branch),
PaintedKind::Sequence | PaintedKind::Gantt | PaintedKind::Mindmap => Ok(()),
}
}
fn code_lines(source: &str) -> Vec<&str> {
let mut in_front_matter = false;
let mut lines = Vec::new();
for line in source.lines().map(str::trim) {
if line == "---" {
in_front_matter = !in_front_matter;
continue;
}
if in_front_matter || line.starts_with("%%") {
continue;
}
let code = strip_trailing_comment(line);
if !code.is_empty() {
lines.push(code);
}
}
lines
}
fn strip_trailing_comment(line: &str) -> &str {
let mut quote = None;
let mut chars = line.char_indices().peekable();
while let Some((index, character)) = chars.next() {
match quote {
Some(open) if character == open => quote = None,
Some(_) => {}
None if matches!(character, '"' | '\'') => quote = Some(character),
None if character == '%' && chars.peek().is_some_and(|&(_, next)| next == '%') => {
return line[..index].trim_end();
}
None => {}
}
}
line
}
fn flowchart_shapes_close(lines: &[&str]) -> Result<(), Defect> {
for line in lines {
let opens_block = line
.strip_prefix("accDescr")
.is_some_and(|rest| rest.trim_start().starts_with('{') && !rest.contains('}'));
if opens_block {
return Err(Defect::Misread);
}
let free_text = ["accTitle", "accDescr", "title "]
.iter()
.any(|prefix| line.starts_with(prefix));
if !free_text && !node_shapes_close(line) {
return Err(Defect::Invalid);
}
}
Ok(())
}
fn node_shapes_close(line: &str) -> bool {
let mut expected: Vec<char> = Vec::new();
let mut previous = None;
let mut chars = line.chars();
while let Some(character) = chars.next() {
let after_id =
previous.is_some_and(|c: char| c.is_alphanumeric() || matches!(c, '_' | '@'));
match character {
'<' if expected.is_empty() => {
chars.any(|c| c == '>');
}
'"' => {
if !chars.any(|c| c == '"') {
return false;
}
}
'|' if expected.is_empty() => {
if !chars.any(|c| c == '|') {
return false;
}
}
'[' | '(' | '{' if after_id || !expected.is_empty() => {
expected.push(match character {
'[' => ']',
'(' => ')',
_ => '}',
});
}
'>' if after_id && expected.is_empty() => expected.push(']'),
']' | ')' | '}' if expected.last() == Some(&character) => {
expected.pop();
}
_ => {}
}
previous = Some(character);
}
expected.is_empty()
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum BlockClose {
LoneBrace,
AnyBrace,
}
fn state_block_opener(line: &str) -> Option<BlockClose> {
(line.starts_with("state ") && line.contains('{')).then_some(BlockClose::LoneBrace)
}
fn class_block_opener(line: &str) -> Option<BlockClose> {
if !line.contains('{') {
None
} else if line.starts_with("class ") {
Some(BlockClose::AnyBrace)
} else if line.starts_with("namespace ") {
Some(BlockClose::LoneBrace)
} else {
None
}
}
fn er_block_opener(line: &str) -> Option<BlockClose> {
let relation = line.split_once(':').map_or(line, |(before, _)| before);
let is_relationship = relation.contains("--") || relation.contains("..");
(line.contains('{') && !is_relationship).then_some(BlockClose::AnyBrace)
}
fn blocks_close(lines: &[&str], opener: fn(&str) -> Option<BlockClose>) -> Result<(), Defect> {
let mut open: Vec<BlockClose> = Vec::new();
for &line in lines {
match open.last() {
Some(BlockClose::AnyBrace) => {
if line.contains('}') {
open.pop();
}
continue;
}
Some(BlockClose::LoneBrace) | None if line == "}" => {
open.pop().ok_or(Defect::Invalid)?;
continue;
}
Some(BlockClose::LoneBrace) | None => {}
}
if let Some(close) = opener(line) {
let tail = line.split_once('{').map_or("", |(_, tail)| tail);
if !tail.contains('}') {
open.push(close);
}
}
}
if open.is_empty() {
Ok(())
} else {
Err(Defect::UnclosedBlock)
}
}
fn branch_refs_resolve(source: &str, lines: &[&str], main_branch: &str) -> Result<(), Defect> {
if source
.lines()
.any(|line| line.trim_start().starts_with("%%{") && line.contains("mainBranchName"))
{
return Err(Defect::Misread);
}
let mut branches = HashSet::from([main_branch]);
for line in lines {
let Some((keyword, name)) = line.split_once(char::is_whitespace) else {
continue;
};
let name = name.trim();
let declares = keyword.eq_ignore_ascii_case("branch");
let references = ["checkout", "switch", "merge"]
.iter()
.any(|reference| keyword.eq_ignore_ascii_case(reference));
if !declares && !references {
continue;
}
if name.contains(':') {
return Err(Defect::Misread);
}
if declares {
branches.insert(name);
} else if !branches.contains(name) {
return Err(Defect::Invalid);
}
}
Ok(())
}