Skip to main content

libpetri_export/
dot_renderer.rs

1use crate::graph::*;
2
3/// DOT keywords that must be quoted when used as identifiers.
4const DOT_KEYWORDS: &[&str] = &["graph", "digraph", "subgraph", "node", "edge", "strict"];
5
6/// Renders a Graph to a DOT format string.
7pub fn render_dot(graph: &Graph) -> String {
8    let mut out = String::new();
9    out.push_str(&format!("digraph {} {{\n", quote_id(&graph.name)));
10
11    // Rankdir
12    out.push_str(&format!("  rankdir={};\n", graph.rankdir.as_dot()));
13
14    // Graph attributes
15    for (k, v) in &graph.graph_attrs {
16        out.push_str(&format!("  {}={};\n", k, quote_attr(v)));
17    }
18
19    // Node defaults
20    if !graph.node_defaults.is_empty() {
21        out.push_str("  node [");
22        render_attrs(&graph.node_defaults, &mut out);
23        out.push_str("];\n");
24    }
25
26    // Edge defaults
27    if !graph.edge_defaults.is_empty() {
28        out.push_str("  edge [");
29        render_attrs(&graph.edge_defaults, &mut out);
30        out.push_str("];\n");
31    }
32
33    out.push('\n');
34
35    // Subgraphs
36    for sg in &graph.subgraphs {
37        render_subgraph(sg, &mut out, 2);
38    }
39
40    // Nodes
41    for node in &graph.nodes {
42        render_node(node, &mut out, 2);
43    }
44
45    out.push('\n');
46
47    // Edges
48    for edge in &graph.edges {
49        render_edge(edge, &mut out, 2);
50    }
51
52    out.push_str("}\n");
53    out
54}
55
56fn render_subgraph(sg: &Subgraph, out: &mut String, indent: usize) {
57    let prefix = " ".repeat(indent);
58    out.push_str(&format!("{prefix}subgraph {} {{\n", quote_id(&sg.id)));
59
60    if let Some(ref label) = sg.label {
61        out.push_str(&format!("{prefix}  label={};\n", quote_attr(label)));
62    }
63
64    for (k, v) in &sg.attrs {
65        out.push_str(&format!("{prefix}  {}={};\n", k, quote_attr(v)));
66    }
67
68    for node in &sg.nodes {
69        render_node(node, out, indent + 2);
70    }
71
72    for edge in &sg.edges {
73        render_edge(edge, out, indent + 2);
74    }
75
76    out.push_str(&format!("{prefix}}}\n"));
77}
78
79fn render_node(node: &GraphNode, out: &mut String, indent: usize) {
80    let prefix = " ".repeat(indent);
81    out.push_str(&format!("{prefix}{} [", quote_id(&node.id)));
82
83    let mut attrs = Vec::new();
84    attrs.push(("label".to_string(), escape_dot(&node.label)));
85    attrs.push(("shape".to_string(), node.shape.as_dot().to_string()));
86
87    if let Some(ref fill) = node.fill {
88        attrs.push(("fillcolor".to_string(), fill.clone()));
89        attrs.push(("style".to_string(), {
90            let mut s = "filled".to_string();
91            if let Some(ref extra) = node.style {
92                s.push_str(&format!(",{extra}"));
93            }
94            s
95        }));
96    } else if let Some(ref style) = node.style {
97        attrs.push(("style".to_string(), style.clone()));
98    }
99
100    if let Some(ref stroke) = node.stroke {
101        attrs.push(("color".to_string(), stroke.clone()));
102    }
103
104    if let Some(pw) = node.penwidth {
105        attrs.push(("penwidth".to_string(), format!("{pw}")));
106    }
107
108    if let Some(h) = node.height {
109        attrs.push(("height".to_string(), format!("{h}")));
110    }
111
112    if let Some(w) = node.width {
113        attrs.push(("width".to_string(), format!("{w}")));
114    }
115
116    for (k, v) in &node.attrs {
117        attrs.push((k.clone(), v.clone()));
118    }
119
120    render_attrs_owned(&attrs, out);
121    out.push_str("];\n");
122}
123
124fn render_edge(edge: &GraphEdge, out: &mut String, indent: usize) {
125    let prefix = " ".repeat(indent);
126    out.push_str(&format!(
127        "{prefix}{} -> {} [",
128        quote_id(&edge.from),
129        quote_id(&edge.to)
130    ));
131
132    let mut attrs = Vec::new();
133
134    if let Some(ref label) = edge.label {
135        attrs.push(("label".to_string(), escape_dot(label)));
136    }
137
138    if let Some(ref color) = edge.color {
139        attrs.push(("color".to_string(), color.clone()));
140    }
141
142    if let Some(ref style) = edge.style {
143        attrs.push(("style".to_string(), style.as_dot().to_string()));
144    }
145
146    if let Some(ref arrowhead) = edge.arrowhead {
147        attrs.push(("arrowhead".to_string(), arrowhead.as_dot().to_string()));
148    }
149
150    if let Some(pw) = edge.penwidth {
151        attrs.push(("penwidth".to_string(), format!("{pw}")));
152    }
153
154    for (k, v) in &edge.attrs {
155        attrs.push((k.clone(), v.clone()));
156    }
157
158    render_attrs_owned(&attrs, out);
159    out.push_str("];\n");
160}
161
162fn render_attrs(attrs: &[(String, String)], out: &mut String) {
163    for (i, (k, v)) in attrs.iter().enumerate() {
164        if i > 0 {
165            out.push_str(", ");
166        }
167        out.push_str(&format!("{}={}", k, quote_attr(v)));
168    }
169}
170
171fn render_attrs_owned(attrs: &[(String, String)], out: &mut String) {
172    for (i, (k, v)) in attrs.iter().enumerate() {
173        if i > 0 {
174            out.push_str(", ");
175        }
176        out.push_str(&format!("{}={}", k, quote_attr(v)));
177    }
178}
179
180/// Quotes a DOT identifier if needed.
181fn quote_id(id: &str) -> String {
182    if needs_quoting(id) {
183        format!("\"{}\"", id.replace('\"', "\\\""))
184    } else {
185        id.to_string()
186    }
187}
188
189/// Quotes a DOT attribute value.
190fn quote_attr(value: &str) -> String {
191    format!("\"{}\"", value.replace('\"', "\\\""))
192}
193
194/// Escapes special characters in DOT labels.
195fn escape_dot(s: &str) -> String {
196    s.replace('\\', "\\\\")
197        .replace('"', "\\\"")
198        .replace('\n', "\\n")
199}
200
201fn needs_quoting(id: &str) -> bool {
202    if id.is_empty() {
203        return true;
204    }
205    if is_dot_keyword(id) {
206        return true;
207    }
208    // Must start with letter or underscore
209    let first = id.chars().next().unwrap();
210    if !first.is_ascii_alphabetic() && first != '_' {
211        return true;
212    }
213    // Must contain only alphanumeric or underscore
214    !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
215}
216
217fn is_dot_keyword(id: &str) -> bool {
218    DOT_KEYWORDS.iter().any(|kw| kw.eq_ignore_ascii_case(id))
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    #[test]
226    fn empty_graph() {
227        let graph = Graph::new("test");
228        let dot = render_dot(&graph);
229        assert!(dot.contains("digraph test"));
230        assert!(dot.contains("rankdir=TB"));
231    }
232
233    #[test]
234    fn quotes_keywords() {
235        assert_eq!(quote_id("graph"), "\"graph\"");
236        assert_eq!(quote_id("node"), "\"node\"");
237        assert_eq!(quote_id("my_node"), "my_node");
238    }
239
240    #[test]
241    fn escapes_special_chars() {
242        assert_eq!(escape_dot("a\"b"), "a\\\"b");
243        assert_eq!(escape_dot("line1\nline2"), "line1\\nline2");
244    }
245
246    #[test]
247    fn renders_node() {
248        let mut graph = Graph::new("test");
249        graph.nodes.push(GraphNode {
250            id: "n1".into(),
251            label: "Node 1".into(),
252            shape: NodeShape::Circle,
253            fill: Some("#fff".into()),
254            stroke: Some("#000".into()),
255            penwidth: Some(1.5),
256            semantic_id: None,
257            style: None,
258            height: None,
259            width: None,
260            attrs: Vec::new(),
261        });
262        let dot = render_dot(&graph);
263        assert!(dot.contains("n1 ["));
264        assert!(dot.contains("shape=\"circle\""));
265        assert!(dot.contains("fillcolor=\"#fff\""));
266    }
267
268    #[test]
269    fn renders_edge() {
270        let mut graph = Graph::new("test");
271        graph.edges.push(GraphEdge {
272            from: "a".into(),
273            to: "b".into(),
274            label: Some("edge".into()),
275            color: Some("#333".into()),
276            style: Some(EdgeLineStyle::Solid),
277            arrowhead: Some(ArrowHead::Normal),
278            penwidth: Some(1.0),
279            arc_type: None,
280            attrs: Vec::new(),
281        });
282        let dot = render_dot(&graph);
283        assert!(dot.contains("a -> b ["));
284        assert!(dot.contains("label=\"edge\""));
285    }
286}