use crate::sequence::NoteAnchor;
use crate::types::{EdgeStyleColors, Graph, NodeStyle, Rgb};
pub(crate) fn strip_inline_comment(line: &str) -> &str {
let bytes = line.as_bytes();
let mut in_quote = false;
let mut i = 0;
while i + 1 < bytes.len() {
let c = bytes[i];
if c == b'"' {
in_quote = !in_quote;
} else if !in_quote && c == b'%' && bytes[i + 1] == b'%' {
return &line[..i];
}
i += 1;
}
line
}
pub(crate) fn strip_keyword_prefix<'a>(line: &'a str, keyword: &str) -> Option<&'a str> {
let len = keyword.len();
if line.len() > len
&& line[..len].eq_ignore_ascii_case(keyword)
&& line.as_bytes()[len].is_ascii_whitespace()
{
Some(line[len..].trim())
} else {
None
}
}
pub(crate) fn matches_keyword(stmt: &str, keyword: &str) -> bool {
if let Some(rest) = stmt.strip_prefix(keyword) {
rest.is_empty() || rest.starts_with(char::is_whitespace) || rest.starts_with(':')
} else {
false
}
}
pub(crate) fn apply_color_pairs(payload: &str, mut f: impl FnMut(&str, &str)) {
for pair in payload.split(',') {
let pair = pair.trim();
let Some((key, value)) = pair.split_once(':') else {
continue;
};
f(key.trim(), value.trim());
}
}
pub(crate) fn parse_node_style_payload(payload: &str) -> NodeStyle {
let mut style = NodeStyle::default();
apply_color_pairs(payload, |key, value| match key {
"fill" => style.fill = Rgb::parse_hex(value),
"stroke" => style.stroke = Rgb::parse_hex(value),
"color" => style.color = Rgb::parse_hex(value),
_ => {}
});
style
}
pub(crate) fn parse_edge_color_payload(payload: &str) -> EdgeStyleColors {
let mut colors = EdgeStyleColors::default();
apply_color_pairs(payload, |key, value| match key {
"stroke" => colors.stroke = Rgb::parse_hex(value),
"color" => colors.color = Rgb::parse_hex(value),
_ => {}
});
colors
}
pub(crate) fn extract_class_modifier(token: &str) -> (String, Vec<String>) {
let mut classes: Vec<String> = Vec::new();
let mut remainder = token;
while let Some(idx) = remainder.rfind(":::") {
let after = &remainder[idx + 3..];
if after.is_empty()
|| !after
.chars()
.all(|c| c.is_alphanumeric() || c == '_' || c == '-')
{
break;
}
classes.push(after.to_string());
remainder = &remainder[..idx];
}
classes.reverse(); (remainder.to_string(), classes)
}
pub(crate) fn merge_node_style(base: NodeStyle, overlay: NodeStyle) -> NodeStyle {
NodeStyle {
fill: overlay.fill.or(base.fill),
stroke: overlay.stroke.or(base.stroke),
color: overlay.color.or(base.color),
}
}
pub(crate) fn parse_style_directive(stmt: &str, graph: &mut Graph) {
let mut parts = stmt.splitn(3, char::is_whitespace);
let _ = parts.next(); let Some(id) = parts.next().map(str::trim).filter(|s| !s.is_empty()) else {
return;
};
let rest = parts.next().unwrap_or("");
let overlay = parse_node_style_payload(rest);
let base = graph.node_styles.get(id).copied().unwrap_or_default();
graph
.node_styles
.insert(id.to_string(), merge_node_style(base, overlay));
}
pub(crate) fn parse_link_style_directive(stmt: &str, graph: &mut Graph) {
let mut parts = stmt.splitn(3, char::is_whitespace);
let _ = parts.next(); let Some(indexes) = parts.next().map(str::trim).filter(|s| !s.is_empty()) else {
return;
};
let rest = parts.next().unwrap_or("");
let target_indexes: Vec<usize> = if indexes == "default" {
(0..graph.edges.len()).collect()
} else {
indexes
.split(',')
.filter_map(|s| s.trim().parse::<usize>().ok())
.collect()
};
if target_indexes.is_empty() {
return;
}
let delta = parse_edge_color_payload(rest);
for idx in target_indexes {
let entry = graph.edge_styles.entry(idx).or_default();
if delta.stroke.is_some() {
entry.stroke = delta.stroke;
}
if delta.color.is_some() {
entry.color = delta.color;
}
}
}
pub(crate) fn parse_class_def_directive(stmt: &str, graph: &mut Graph) {
let mut parts = stmt.splitn(3, char::is_whitespace);
let _ = parts.next(); let Some(name) = parts.next().map(str::trim).filter(|s| !s.is_empty()) else {
return;
};
let payload = parts.next().unwrap_or("");
let style = parse_node_style_payload(payload);
graph.class_defs.insert(name.to_string(), style);
}
pub(crate) fn parse_class_directive(stmt: &str, pending_classes: &mut Vec<(String, String)>) {
let mut parts = stmt.splitn(3, char::is_whitespace);
let _ = parts.next(); let Some(ids_part) = parts.next().map(str::trim).filter(|s| !s.is_empty()) else {
return;
};
let Some(class_name) = parts.next().map(str::trim).filter(|s| !s.is_empty()) else {
return;
};
for id in ids_part.split(',').map(str::trim).filter(|s| !s.is_empty()) {
pending_classes.push((id.to_string(), class_name.to_string()));
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum NoteSide {
Left,
Right,
Over,
}
pub(crate) fn parse_note_anchor(s: &str) -> Option<(NoteSide, String)> {
let s = s.trim();
let (side_word, rest) = s.split_once(char::is_whitespace)?;
let side = match side_word {
"left" => NoteSide::Left,
"right" => NoteSide::Right,
"over" => NoteSide::Over,
_ => return None,
};
let rest = rest.trim();
let id_str = if let Some(stripped) = rest.strip_prefix("of ") {
stripped.trim()
} else if matches!(side, NoteSide::Over) {
rest
} else {
return None;
};
if id_str.is_empty() {
return None;
}
Some((side, id_str.to_string()))
}
pub(crate) fn parse_sequence_note_anchor(s: &str) -> Option<NoteAnchor> {
let s = s.trim();
if let Some(rest) = s.strip_prefix("left of ") {
let id = rest.trim();
if id.is_empty() {
return None;
}
return Some(NoteAnchor::LeftOf(id.to_string()));
}
if let Some(rest) = s.strip_prefix("right of ") {
let id = rest.trim();
if id.is_empty() {
return None;
}
return Some(NoteAnchor::RightOf(id.to_string()));
}
if let Some(rest) = s.strip_prefix("over ") {
let body = rest.trim();
if body.is_empty() {
return None;
}
if let Some((a, b)) = body.split_once(',') {
let a = a.trim();
let b = b.trim();
if a.is_empty() || b.is_empty() {
return None;
}
return Some(NoteAnchor::OverPair(a.to_string(), b.to_string()));
}
return Some(NoteAnchor::Over(body.to_string()));
}
None
}
pub(crate) fn apply_pending_classes(graph: &mut Graph, pending: &[(String, String)]) {
let subgraph_ids: std::collections::HashSet<String> =
graph.subgraphs.iter().map(|s| s.id.clone()).collect();
for (target, class_name) in pending {
let Some(overlay) = graph.class_defs.get(class_name).copied() else {
continue;
};
let target_map = if subgraph_ids.contains(target) {
&mut graph.subgraph_styles
} else {
&mut graph.node_styles
};
let base = target_map.get(target).copied().unwrap_or_default();
target_map.insert(target.clone(), merge_node_style(base, overlay));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strip_inline_comment_outside_quotes() {
assert_eq!(strip_inline_comment("foo %% bar"), "foo ");
assert_eq!(strip_inline_comment("foo"), "foo");
}
#[test]
fn strip_inline_comment_preserves_quoted_percent() {
assert_eq!(
strip_inline_comment(r#"state "A %% B" as X"#),
r#"state "A %% B" as X"#
);
}
#[test]
fn strip_keyword_prefix_basic() {
assert_eq!(strip_keyword_prefix("note left of A", "note"), Some("left of A"));
assert_eq!(strip_keyword_prefix("Note left of A", "note"), Some("left of A"));
assert_eq!(strip_keyword_prefix("note", "note"), None); assert_eq!(strip_keyword_prefix("notes", "note"), None); assert_eq!(strip_keyword_prefix("class A foo", "class"), Some("A foo"));
assert_eq!(strip_keyword_prefix("", "note"), None);
}
#[test]
fn matches_keyword_recognises_word_followed_by_space_or_colon() {
assert!(matches_keyword("classDef foo …", "classDef"));
assert!(matches_keyword("accTitle: hi", "accTitle"));
assert!(matches_keyword("end note", "end"));
assert!(!matches_keyword("classDeffoo", "classDef"));
}
#[test]
fn parse_node_style_payload_recognised_keys() {
let s = parse_node_style_payload("fill:#336,stroke:#fff,color:#000");
assert_eq!(s.fill, Some(Rgb(0x33, 0x33, 0x66)));
assert_eq!(s.stroke, Some(Rgb(0xff, 0xff, 0xff)));
assert_eq!(s.color, Some(Rgb(0, 0, 0)));
}
#[test]
fn parse_node_style_payload_ignores_unknown_keys_and_bad_hex() {
let s = parse_node_style_payload("font-size:14,fill:#zzz,stroke:#abc");
assert_eq!(s.fill, None);
assert_eq!(s.stroke, Some(Rgb(0xaa, 0xbb, 0xcc)));
}
#[test]
fn parse_edge_color_payload_only_picks_edge_keys() {
let c = parse_edge_color_payload("stroke:#f00,color:#fff,fill:#000");
assert_eq!(c.stroke, Some(Rgb(0xff, 0, 0)));
assert_eq!(c.color, Some(Rgb(0xff, 0xff, 0xff)));
}
#[test]
fn extract_class_modifier_no_modifier() {
let (id, classes) = extract_class_modifier("A");
assert_eq!(id, "A");
assert!(classes.is_empty());
}
#[test]
fn extract_class_modifier_single_class() {
let (id, classes) = extract_class_modifier("A:::cache");
assert_eq!(id, "A");
assert_eq!(classes, vec!["cache"]);
}
#[test]
fn extract_class_modifier_multiple_classes_preserve_order() {
let (id, classes) = extract_class_modifier("A:::first:::second:::third");
assert_eq!(id, "A");
assert_eq!(classes, vec!["first", "second", "third"]);
}
#[test]
fn extract_class_modifier_keeps_shape_brackets() {
let (id, classes) = extract_class_modifier("A[Label]:::cache");
assert_eq!(id, "A[Label]");
assert_eq!(classes, vec!["cache"]);
}
#[test]
fn extract_class_modifier_handles_star_marker() {
let (id, classes) = extract_class_modifier("[*]:::started");
assert_eq!(id, "[*]");
assert_eq!(classes, vec!["started"]);
}
#[test]
fn extract_class_modifier_invalid_suffix_is_ignored() {
let (id, classes) = extract_class_modifier("A:::not a class");
assert_eq!(id, "A:::not a class");
assert!(classes.is_empty());
}
#[test]
fn merge_node_style_overlay_wins_on_present_fields() {
let base = NodeStyle {
fill: Some(Rgb(1, 1, 1)),
stroke: Some(Rgb(2, 2, 2)),
color: None,
};
let overlay = NodeStyle {
fill: Some(Rgb(9, 9, 9)),
stroke: None,
color: Some(Rgb(5, 5, 5)),
};
let merged = merge_node_style(base, overlay);
assert_eq!(merged.fill, Some(Rgb(9, 9, 9))); assert_eq!(merged.stroke, Some(Rgb(2, 2, 2))); assert_eq!(merged.color, Some(Rgb(5, 5, 5))); }
#[test]
fn parse_note_anchor_left_of() {
assert_eq!(
parse_note_anchor("left of MyState"),
Some((NoteSide::Left, "MyState".to_string()))
);
}
#[test]
fn parse_note_anchor_right_of() {
assert_eq!(
parse_note_anchor("right of OPEN"),
Some((NoteSide::Right, "OPEN".to_string()))
);
}
#[test]
fn parse_note_anchor_over_no_of() {
assert_eq!(
parse_note_anchor("over Active"),
Some((NoteSide::Over, "Active".to_string()))
);
}
#[test]
fn parse_note_anchor_left_without_of_is_rejected() {
assert_eq!(parse_note_anchor("left X"), None);
}
#[test]
fn parse_note_anchor_floating_note_form_returns_none() {
assert_eq!(parse_note_anchor("\"some text\" as N1"), None);
}
#[test]
fn parse_note_anchor_empty_id_returns_none() {
assert_eq!(parse_note_anchor("left of "), None);
assert_eq!(parse_note_anchor("over"), None);
}
#[test]
fn parse_sequence_note_anchor_left_of() {
assert_eq!(
parse_sequence_note_anchor("left of Alice"),
Some(NoteAnchor::LeftOf("Alice".to_string()))
);
}
#[test]
fn parse_sequence_note_anchor_right_of() {
assert_eq!(
parse_sequence_note_anchor("right of Bob"),
Some(NoteAnchor::RightOf("Bob".to_string()))
);
}
#[test]
fn parse_sequence_note_anchor_over_single() {
assert_eq!(
parse_sequence_note_anchor("over Alice"),
Some(NoteAnchor::Over("Alice".to_string()))
);
}
#[test]
fn parse_sequence_note_anchor_over_pair() {
assert_eq!(
parse_sequence_note_anchor("over Alice,Bob"),
Some(NoteAnchor::OverPair(
"Alice".to_string(),
"Bob".to_string()
))
);
assert_eq!(
parse_sequence_note_anchor("over Alice , Bob"),
Some(NoteAnchor::OverPair(
"Alice".to_string(),
"Bob".to_string()
))
);
}
#[test]
fn parse_sequence_note_anchor_invalid_returns_none() {
assert_eq!(parse_sequence_note_anchor("left A"), None); assert_eq!(parse_sequence_note_anchor("over"), None); assert_eq!(parse_sequence_note_anchor(""), None);
assert_eq!(parse_sequence_note_anchor("over Alice,"), None); }
#[test]
fn merge_node_style_default_overlay_preserves_base() {
let base = NodeStyle {
fill: Some(Rgb(1, 2, 3)),
stroke: None,
color: None,
};
let merged = merge_node_style(base, NodeStyle::default());
assert_eq!(merged.fill, Some(Rgb(1, 2, 3)));
}
}