use crate::model::Shape;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EdgeTok {
pub dashed: bool,
pub no_arrow: bool,
pub label: String,
}
pub struct Scanner {
chars: Vec<char>,
i: usize,
}
impl Scanner {
pub fn new(s: &str) -> Self {
Scanner {
chars: s.chars().collect(),
i: 0,
}
}
pub fn at_end(&self) -> bool {
self.i >= self.chars.len()
}
fn peek(&self) -> Option<char> {
self.chars.get(self.i).copied()
}
fn peek_at(&self, off: usize) -> Option<char> {
self.chars.get(self.i + off).copied()
}
pub fn skip_ws(&mut self) {
while matches!(self.peek(), Some(c) if c == ' ' || c == '\t') {
self.i += 1;
}
}
pub fn eat(&mut self, c: char) -> bool {
if self.peek() == Some(c) {
self.i += 1;
true
} else {
false
}
}
fn starts_with(&self, pat: &str) -> bool {
pat.chars()
.enumerate()
.all(|(k, c)| self.peek_at(k) == Some(c))
}
pub fn read_id(&mut self) -> Option<String> {
let start = self.i;
while matches!(self.peek(), Some(c) if c.is_ascii_alphanumeric() || c == '_') {
self.i += 1;
}
if self.i == start {
None
} else {
Some(self.chars[start..self.i].iter().collect())
}
}
pub fn read_shape(&mut self) -> Option<(Shape, String)> {
const THREE: &[(&str, &str, Shape)] = &[
("(((", ")))", Shape::Circle), ];
for (open, close, shape) in THREE {
if self.starts_with(open) {
self.i += 3;
let label = self.read_until(close);
return Some((*shape, label));
}
}
const TWO: &[(&str, &str, Shape)] = &[
("[[", "]]", Shape::Box), ("[(", ")]", Shape::Cylinder), ("([", "])", Shape::Badge), ("((", "))", Shape::Circle), ("{{", "}}", Shape::Hex), ];
for (open, close, shape) in TWO {
if self.starts_with(open) {
self.i += 2;
let label = self.read_until(close);
return Some((*shape, label));
}
}
const ONE: &[(char, &str, Shape)] = &[
('[', "]", Shape::Box), ('(', ")", Shape::Box), ('{', "}", Shape::Diamond), ('>', "]", Shape::Box), ];
for (open, close, shape) in ONE {
if self.peek() == Some(*open) {
self.i += 1;
let label = self.read_until(close);
return Some((*shape, label));
}
}
None
}
pub fn read_at_metadata(&mut self) -> Option<(Option<Shape>, Option<String>)> {
if !self.starts_with("@{") {
return None;
}
self.i += 2;
let body = self.read_until("}");
let shape = meta_field(&body, "shape").map(|s| map_shape(&s));
let label = meta_field(&body, "label").or_else(|| meta_field(&body, "text"));
Some((shape, label))
}
pub fn skip_class_suffix(&mut self) {
if self.starts_with(":::") {
self.i += 3;
while let Some(c) = self.peek() {
if c.is_ascii_alphanumeric() || c == '_' {
self.i += 1;
} else if c == '-' && !matches!(self.peek_at(1), Some('-') | Some('>')) {
self.i += 1; } else {
break;
}
}
}
}
pub fn skip_edge_id(&mut self) {
let save = self.i;
while matches!(self.peek(), Some(c) if c.is_ascii_alphanumeric() || c == '_') {
self.i += 1;
}
if self.i > save
&& self.peek() == Some('@')
&& matches!(
self.peek_at(1),
Some('-') | Some('=') | Some('<') | Some('.')
)
{
self.i += 1; return;
}
self.i = save;
}
fn read_until(&mut self, close: &str) -> String {
let start = self.i;
let mut in_quote = false;
while !self.at_end() {
let c = self.chars[self.i];
if c == '"' {
in_quote = !in_quote;
} else if !in_quote && self.starts_with(close) {
break;
}
self.i += 1;
}
let raw: String = self.chars[start..self.i].iter().collect();
if self.starts_with(close) {
self.i += close.chars().count();
}
let t = raw.trim();
if t.len() >= 2 && t.starts_with('"') && t.ends_with('"') {
t[1..t.len() - 1].to_string()
} else {
t.to_string()
}
}
fn try_inline_edge_label(&mut self) -> Option<(String, String)> {
let save = self.i;
self.skip_ws();
let text_start = self.i;
let mut j = self.i;
let close_start = loop {
match self.chars.get(j) {
None => break None,
Some('\n') | Some('|') => break None,
Some('-' | '=' | '.') => {
let mut k = j;
while matches!(self.chars.get(k), Some(c) if matches!(c, '-' | '=' | '.' | '>' | '<'))
{
k += 1;
}
let len = k - j;
let run: String = self.chars[j..k].iter().collect();
if len >= 2 || run.contains('>') {
break Some(j);
}
j = k; }
Some(_) => j += 1,
}
}?;
if close_start == text_start {
self.i = save;
return None;
}
let label: String = self.chars[text_start..close_start].iter().collect();
self.i = close_start;
let run_start = self.i;
while matches!(self.peek(), Some(c) if matches!(c, '-' | '=' | '.' | '>' | '<')) {
self.i += 1;
}
if matches!(self.peek(), Some('x') | Some('o')) && self.i > run_start {
self.i += 1;
}
let run2: String = self.chars[run_start..self.i].iter().collect();
let t = label.trim();
let text = if t.len() >= 2 && t.starts_with('"') && t.ends_with('"') {
t[1..t.len() - 1].to_string()
} else {
t.to_string()
};
Some((text, run2))
}
pub fn read_operator(&mut self) -> Option<EdgeTok> {
let save = self.i;
self.skip_ws();
match self.peek() {
Some('-') | Some('=') | Some('<') | Some('.') | Some('~') => {}
Some('o') | Some('x')
if matches!(self.peek_at(1), Some('-') | Some('=') | Some('.')) => {}
_ => {
self.i = save;
return None;
}
}
let run_start = self.i;
if matches!(self.peek(), Some('o') | Some('x')) {
self.i += 1;
}
while matches!(self.peek(), Some(c) if matches!(c, '-' | '=' | '.' | '>' | '<' | '~')) {
self.i += 1;
}
if matches!(self.peek(), Some('x') | Some('o')) && self.i > run_start {
self.i += 1;
}
let run: String = self.chars[run_start..self.i].iter().collect();
let mut dashed = run.contains('.');
let mut has_arrow = run.contains('>')
|| run.starts_with('<')
|| run.starts_with('o')
|| run.starts_with('x')
|| run.ends_with('x')
|| run.ends_with('o');
let opens = !(run.ends_with('>') || run.ends_with('x') || run.ends_with('o'));
let mut label = String::new();
if opens {
if let Some((text, run2)) = self.try_inline_edge_label() {
label = text;
dashed = dashed || run2.contains('.');
has_arrow =
has_arrow || run2.contains('>') || run2.ends_with('x') || run2.ends_with('o');
}
}
self.skip_ws();
if self.peek() == Some('|') {
self.i += 1;
label = self.read_until("|");
}
Some(EdgeTok {
dashed,
no_arrow: !has_arrow,
label,
})
}
}
fn meta_field(body: &str, key: &str) -> Option<String> {
let fields = split_fields(body);
for (i, part) in fields.iter().enumerate() {
if let Some((k, v)) = part.split_once(':') {
if k.trim() == key {
let v = v.trim();
if v == "|" || v == ">" {
let mut out = Vec::new();
for next in &fields[i + 1..] {
if is_yaml_key(next) {
break;
}
let t = next.trim();
if !t.is_empty() {
out.push(t);
}
}
return Some(out.join(" "));
}
return Some(unquote(v));
}
}
}
None
}
fn is_yaml_key(field: &str) -> bool {
match field.split_once(':') {
Some((k, _)) => {
let k = k.trim();
!k.is_empty()
&& k.len() <= 32
&& k.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
}
None => false,
}
}
fn split_fields(body: &str) -> Vec<String> {
let mut out = Vec::new();
let (mut cur, mut q) = (String::new(), None::<char>);
for c in body.chars() {
match q {
Some(qc) => {
if c == qc {
q = None;
}
cur.push(c);
}
None => match c {
'"' | '\'' => {
q = Some(c);
cur.push(c);
}
',' => {
out.push(std::mem::take(&mut cur));
}
_ => cur.push(c),
},
}
}
if !cur.trim().is_empty() {
out.push(cur);
}
out
}
fn unquote(s: &str) -> String {
let t = s.trim();
if t.len() >= 2
&& ((t.starts_with('"') && t.ends_with('"')) || (t.starts_with('\'') && t.ends_with('\'')))
{
t[1..t.len() - 1].to_string()
} else {
t.to_string()
}
}
fn map_shape(name: &str) -> Shape {
match name.trim().to_ascii_lowercase().as_str() {
"circle" | "circ" | "dbl-circ" | "fr-circ" | "doublecircle" => Shape::Circle,
"diam" | "diamond" | "decision" | "fork" | "join" => Shape::Diamond,
"hex" | "hexagon" | "prepare" => Shape::Hex,
"cyl" | "cylinder" | "db" | "das" | "database" | "disk" | "lin-cyl" => Shape::Cylinder,
"stadium" | "pill" | "term" | "terminal" | "rounded" => Shape::Badge,
_ => Shape::Box,
}
}