Skip to main content

xgraph/core/io/
dot.rs

1//! Graphviz DOT export, written once against [`GraphView`].
2//!
3//! Any view can be rendered, including a filtered one, so "draw only the rail routes" is a
4//! filter away rather than a separate export path.
5//!
6//! This exists because the repository shipped two Python scripts whose only job was to
7//! read a graph back out of CSV and hand it to NetworkX for drawing. A DOT file goes
8//! straight into Graphviz, and into every tool that reads it.
9
10use crate::core::view::{EdgeId, GraphView, NodeId};
11use std::fmt::Write as _;
12
13/// How a node or edge should be labelled and styled in the output.
14///
15/// Returned by the [`DotStyle`] hooks. Attributes are written in the order given, and
16/// values are quoted and escaped, so they may contain anything.
17#[derive(Debug, Clone, Default)]
18pub struct DotAttributes {
19    attributes: Vec<(String, String)>,
20}
21
22impl DotAttributes {
23    /// An empty attribute list.
24    pub fn new() -> Self {
25        Self::default()
26    }
27
28    /// Sets an attribute, replacing any previous value for the same key.
29    pub fn set(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
30        let key = key.into();
31        let value = value.into();
32        match self.attributes.iter_mut().find(|(k, _)| *k == key) {
33            Some(slot) => slot.1 = value,
34            None => self.attributes.push((key, value)),
35        }
36        self
37    }
38
39    /// Convenience for the most common attribute.
40    pub fn label(self, label: impl Into<String>) -> Self {
41        self.set("label", label)
42    }
43
44    /// Whether anything has been set.
45    pub fn is_empty(&self) -> bool {
46        self.attributes.is_empty()
47    }
48
49    /// Renders as a DOT attribute list, without the surrounding brackets.
50    fn render(&self) -> String {
51        self.attributes
52            .iter()
53            .map(|(key, value)| format!("{}={}", key, quote(value)))
54            .collect::<Vec<_>>()
55            .join(", ")
56    }
57}
58
59/// Decides how each node and edge is drawn.
60///
61/// The blanket implementation below labels everything with its identifier, which is enough
62/// to see the shape of a graph. Implement this to add the data a particular graph carries;
63/// the concrete graph types provide implementations that do.
64pub trait DotStyle: GraphView {
65    /// Attributes for one node.
66    fn node_attributes(&self, node: NodeId) -> DotAttributes {
67        DotAttributes::new().label(node.to_string())
68    }
69
70    /// Attributes for one edge.
71    fn edge_attributes(&self, _edge: EdgeId) -> DotAttributes {
72        DotAttributes::new()
73    }
74
75    /// Attributes applied to the graph as a whole.
76    fn graph_attributes(&self) -> DotAttributes {
77        DotAttributes::new()
78    }
79}
80
81/// DOT rendering, available on every [`GraphView`].
82pub trait ToDot: GraphView {
83    /// Renders the graph as a DOT document, using `style` to decide how it is drawn.
84    ///
85    /// The document is named `name`, which Graphviz uses as the title.
86    fn to_dot_with<S: DotStyle>(&self, name: &str, style: &S) -> String
87    where
88        Self: Sized,
89    {
90        // `digraph`/`graph` and `->`/`--` must agree, or the file will not parse.
91        let (keyword, connector) = if self.is_directed() {
92            ("digraph", "->")
93        } else {
94            ("graph", "--")
95        };
96
97        let mut out = String::new();
98        let _ = writeln!(out, "{} {} {{", keyword, quote(name));
99
100        let graph_attributes = style.graph_attributes();
101        if !graph_attributes.is_empty() {
102            let _ = writeln!(out, "    graph [{}];", graph_attributes.render());
103        }
104
105        for node in self.node_ids() {
106            let attributes = style.node_attributes(node);
107            if attributes.is_empty() {
108                let _ = writeln!(out, "    {};", node.index());
109            } else {
110                let _ = writeln!(out, "    {} [{}];", node.index(), attributes.render());
111            }
112        }
113
114        // Edges are walked per node so that a filtered view is honoured, and each
115        // undirected edge is emitted once by taking it from its lower-numbered end.
116        for node in self.node_ids() {
117            for link in self.links(node) {
118                if !self.is_directed() && link.target < node {
119                    continue;
120                }
121                let attributes = style.edge_attributes(link.edge);
122                let line = format!("    {} {} {}", node.index(), connector, link.target.index());
123                if attributes.is_empty() {
124                    let _ = writeln!(out, "{};", line);
125                } else {
126                    let _ = writeln!(out, "{} [{}];", line, attributes.render());
127                }
128            }
129        }
130
131        out.push_str("}\n");
132        out
133    }
134
135    /// Renders the graph as a DOT document with identifiers for labels.
136    fn to_dot(&self, name: &str) -> String
137    where
138        Self: Sized + DotStyle,
139    {
140        self.to_dot_with(name, self)
141    }
142}
143
144impl<G: GraphView + ?Sized> ToDot for G {}
145
146/// Wraps a value in double quotes, escaping what DOT would otherwise misread.
147fn quote(value: &str) -> String {
148    let mut out = String::with_capacity(value.len() + 2);
149    out.push('"');
150    for c in value.chars() {
151        match c {
152            '"' => out.push_str("\\\""),
153            '\\' => out.push_str("\\\\"),
154            '\n' => out.push_str("\\n"),
155            _ => out.push(c),
156        }
157    }
158    out.push('"');
159    out
160}
161
162#[cfg(test)]
163#[cfg(feature = "graph")]
164mod tests {
165    use super::*;
166    use crate::core::EdgeFiltered;
167    use crate::graph::Graph;
168
169    #[test]
170    fn test_an_undirected_graph_uses_the_undirected_syntax() {
171        let mut graph = Graph::<u32, &str, &str>::new(false);
172        let a = graph.add_node("A");
173        let b = graph.add_node("B");
174        graph.add_edge(a, b, 1, "rail").unwrap();
175
176        let dot = graph.to_dot("routes");
177        assert!(dot.starts_with("graph \"routes\" {\n"));
178        assert!(dot.contains("0 -- 1"));
179        assert!(!dot.contains("->"));
180        assert!(dot.ends_with("}\n"));
181    }
182
183    #[test]
184    fn test_a_directed_graph_uses_arrows() {
185        let mut graph = Graph::<u32, &str, &str>::new(true);
186        let a = graph.add_node("A");
187        let b = graph.add_node("B");
188        graph.add_edge(a, b, 1, "rail").unwrap();
189
190        let dot = graph.to_dot("flow");
191        assert!(dot.starts_with("digraph \"flow\" {\n"));
192        assert!(dot.contains("0 -> 1"));
193    }
194
195    /// An undirected edge appears from both ends in the adjacency, and must still be drawn
196    /// once.
197    #[test]
198    fn test_each_undirected_edge_is_emitted_once() {
199        let mut graph = Graph::<u32, &str, &str>::new(false);
200        let a = graph.add_node("A");
201        let b = graph.add_node("B");
202        graph.add_edge(a, b, 1, "rail").unwrap();
203
204        let dot = graph.to_dot("routes");
205        assert_eq!(dot.matches("--").count(), 1);
206    }
207
208    #[test]
209    fn test_parallel_edges_are_all_drawn() {
210        let mut graph = Graph::<u32, &str, &str>::new(false);
211        let a = graph.add_node("A");
212        let b = graph.add_node("B");
213        graph.add_edge(a, b, 1, "rail").unwrap();
214        graph.add_edge(a, b, 2, "road").unwrap();
215
216        assert_eq!(graph.to_dot("routes").matches("0 -- 1").count(), 2);
217    }
218
219    #[test]
220    fn test_labels_are_quoted_and_escaped() {
221        let mut graph = Graph::<u32, String, String>::new(false);
222        let a = graph.add_node("say \"hi\"".into());
223        let b = graph.add_node("two\nlines".into());
224        graph.add_edge(a, b, 1, "a\\b".into()).unwrap();
225
226        let dot = graph.to_dot_with("routes", &graph);
227        assert!(dot.contains(r#"\"hi\""#), "{}", dot);
228        assert!(dot.contains(r"two\nlines"), "{}", dot);
229        assert!(dot.contains(r"a\\b"), "{}", dot);
230    }
231
232    #[test]
233    fn test_a_filtered_view_renders_only_what_it_admits() {
234        let mut graph = Graph::<u32, &str, &str>::new(false);
235        let a = graph.add_node("A");
236        let b = graph.add_node("B");
237        let c = graph.add_node("C");
238        let rail = graph.add_edge(a, b, 1, "rail").unwrap();
239        graph.add_edge(b, c, 1, "road").unwrap();
240
241        let rail_only = EdgeFiltered::new(&graph, move |edge| edge == rail);
242        let dot = rail_only.to_dot_with("rail", &graph);
243        assert_eq!(dot.matches("--").count(), 1);
244        // Nodes are still listed, so an isolated one is visible in the drawing.
245        assert!(dot.contains("    2 ["));
246    }
247
248    #[test]
249    fn test_an_empty_graph_still_produces_a_valid_document() {
250        let graph = Graph::<u32, &str, &str>::new(false);
251        assert_eq!(graph.to_dot("empty"), "graph \"empty\" {\n}\n");
252    }
253}