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 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()));
}
}
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 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 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)));
}
}