1use crate::graph::*;
2
3const DOT_KEYWORDS: &[&str] = &["graph", "digraph", "subgraph", "node", "edge", "strict"];
5
6pub 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 out.push_str(&format!(" rankdir={};\n", graph.rankdir.as_dot()));
13
14 for (k, v) in &graph.graph_attrs {
16 out.push_str(&format!(" {}={};\n", k, quote_attr(v)));
17 }
18
19 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 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 for sg in &graph.subgraphs {
37 render_subgraph(sg, &mut out, 2);
38 }
39
40 for node in &graph.nodes {
42 render_node(node, &mut out, 2);
43 }
44
45 out.push('\n');
46
47 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
180fn quote_id(id: &str) -> String {
182 if needs_quoting(id) {
183 format!("\"{}\"", id.replace('\"', "\\\""))
184 } else {
185 id.to_string()
186 }
187}
188
189fn quote_attr(value: &str) -> String {
191 format!("\"{}\"", value.replace('\"', "\\\""))
192}
193
194fn 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 let first = id.chars().next().unwrap();
210 if !first.is_ascii_alphabetic() && first != '_' {
211 return true;
212 }
213 !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}