use crate::sequence::{BlockKind, 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 strip_activation_marker(token: &str) -> (String, Option<bool>) {
let t = token.trim_start();
if let Some(rest) = t.strip_prefix('+') {
(rest.trim().to_string(), Some(true))
} else if let Some(rest) = t.strip_prefix('-') {
(rest.trim().to_string(), Some(false))
} else {
(t.trim().to_string(), None)
}
}
pub(crate) fn block_kind_from_keyword(s: &str) -> Option<BlockKind> {
match s {
"loop" => Some(BlockKind::Loop),
"alt" => Some(BlockKind::Alt),
"opt" => Some(BlockKind::Opt),
"par" => Some(BlockKind::Par),
"critical" => Some(BlockKind::Critical),
"break" => Some(BlockKind::Break),
_ => None,
}
}
pub(crate) fn continuation_keyword_for(kind: BlockKind) -> Option<&'static str> {
match kind {
BlockKind::Alt => Some("else"),
BlockKind::Par => Some("and"),
BlockKind::Critical => Some("option"),
BlockKind::Loop | BlockKind::Opt | BlockKind::Break => 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 {
if class_name == "DEFAULT" {
continue;
}
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));
}
let Some(default_style) = graph.class_defs.get("DEFAULT").copied() else {
return;
};
let node_ids: Vec<String> = graph.nodes.iter().map(|n| n.id.clone()).collect();
let sg_ids: Vec<String> = graph.subgraphs.iter().map(|s| s.id.clone()).collect();
for id in node_ids {
let explicit = graph.node_styles.get(&id).copied().unwrap_or_default();
let merged = merge_node_style(default_style, explicit);
graph.node_styles.insert(id, merged);
}
for id in sg_ids {
let explicit = graph.subgraph_styles.get(&id).copied().unwrap_or_default();
let merged = merge_node_style(default_style, explicit);
graph.subgraph_styles.insert(id, merged);
}
}
#[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 strip_activation_marker_plus() {
assert_eq!(strip_activation_marker("+B"), ("B".to_string(), Some(true)));
assert_eq!(
strip_activation_marker("+ B"),
("B".to_string(), Some(true))
);
}
#[test]
fn strip_activation_marker_minus() {
assert_eq!(
strip_activation_marker("-Alice"),
("Alice".to_string(), Some(false))
);
}
#[test]
fn strip_activation_marker_no_marker() {
assert_eq!(strip_activation_marker("B"), ("B".to_string(), None));
assert_eq!(strip_activation_marker(" B "), ("B".to_string(), None));
assert_eq!(strip_activation_marker(""), (String::new(), None));
}
#[test]
fn block_kind_from_keyword_recognises_all_kinds() {
assert_eq!(block_kind_from_keyword("loop"), Some(BlockKind::Loop));
assert_eq!(block_kind_from_keyword("alt"), Some(BlockKind::Alt));
assert_eq!(block_kind_from_keyword("opt"), Some(BlockKind::Opt));
assert_eq!(block_kind_from_keyword("par"), Some(BlockKind::Par));
assert_eq!(
block_kind_from_keyword("critical"),
Some(BlockKind::Critical)
);
assert_eq!(block_kind_from_keyword("break"), Some(BlockKind::Break));
assert_eq!(block_kind_from_keyword("else"), None); assert_eq!(block_kind_from_keyword("end"), None);
assert_eq!(block_kind_from_keyword(""), None);
}
#[test]
fn continuation_keyword_for_multi_branch_kinds() {
assert_eq!(continuation_keyword_for(BlockKind::Alt), Some("else"));
assert_eq!(continuation_keyword_for(BlockKind::Par), Some("and"));
assert_eq!(
continuation_keyword_for(BlockKind::Critical),
Some("option")
);
assert_eq!(continuation_keyword_for(BlockKind::Loop), None);
assert_eq!(continuation_keyword_for(BlockKind::Opt), None);
assert_eq!(continuation_keyword_for(BlockKind::Break), 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)));
}
#[test]
fn default_classdef_merges_into_unstyled_node() {
let mut graph = crate::types::Graph::new(crate::types::Direction::LeftToRight);
graph.nodes.push(crate::types::Node::new(
"A",
"A",
crate::types::NodeShape::Rectangle,
));
graph.class_defs.insert(
"DEFAULT".to_string(),
NodeStyle {
fill: Some(Rgb(0xff, 0, 0)),
..Default::default()
},
);
apply_pending_classes(&mut graph, &[]);
let style = graph.node_styles.get("A").copied().unwrap_or_default();
assert_eq!(
style.fill,
Some(Rgb(0xff, 0, 0)),
"unstyled node must inherit DEFAULT fill"
);
}
#[test]
fn default_classdef_merges_under_explicit_class() {
let mut graph = crate::types::Graph::new(crate::types::Direction::LeftToRight);
graph.nodes.push(crate::types::Node::new(
"A",
"A",
crate::types::NodeShape::Rectangle,
));
graph.class_defs.insert(
"DEFAULT".to_string(),
NodeStyle {
fill: Some(Rgb(0xee, 0xee, 0xee)),
stroke: Some(Rgb(0x99, 0x99, 0x99)),
color: None,
},
);
graph.class_defs.insert(
"important".to_string(),
NodeStyle {
fill: Some(Rgb(0xff, 0, 0)),
stroke: None,
color: None,
},
);
apply_pending_classes(&mut graph, &[("A".to_string(), "important".to_string())]);
let style = graph.node_styles.get("A").copied().unwrap_or_default();
assert_eq!(
style.fill,
Some(Rgb(0xff, 0, 0)),
"explicit class fill must win over DEFAULT"
);
assert_eq!(
style.stroke,
Some(Rgb(0x99, 0x99, 0x99)),
"DEFAULT stroke must be inherited when explicit class doesn't define it"
);
}
#[test]
fn default_classdef_does_not_apply_when_absent() {
let mut graph = crate::types::Graph::new(crate::types::Direction::LeftToRight);
graph.nodes.push(crate::types::Node::new(
"A",
"A",
crate::types::NodeShape::Rectangle,
));
apply_pending_classes(&mut graph, &[]);
assert!(
!graph.node_styles.contains_key("A"),
"no DEFAULT means no style entry for unstyled node"
);
}
#[test]
fn default_classdef_works_in_state_diagrams() {
let mut graph = crate::types::Graph::new(crate::types::Direction::TopToBottom);
graph.nodes.push(crate::types::Node::new(
"Idle",
"Idle",
crate::types::NodeShape::Rounded,
));
graph.nodes.push(crate::types::Node::new(
"Working",
"Working",
crate::types::NodeShape::Rounded,
));
graph.class_defs.insert(
"DEFAULT".to_string(),
NodeStyle {
stroke: Some(Rgb(0x99, 0xcc, 0xff)),
..Default::default()
},
);
apply_pending_classes(&mut graph, &[]);
assert_eq!(
graph.node_styles.get("Idle").and_then(|s| s.stroke),
Some(Rgb(0x99, 0xcc, 0xff)),
"state Idle must inherit DEFAULT stroke"
);
assert_eq!(
graph.node_styles.get("Working").and_then(|s| s.stroke),
Some(Rgb(0x99, 0xcc, 0xff)),
"state Working must inherit DEFAULT stroke"
);
}
}