weavatrix_graph/format/
graphml.rs1use crate::{
2 EdgeEndpoints, GraphError, IndexGraphView, IndexUndirectedGraphView, NodeIndex, Result,
3 Topology, UndirectedTopology,
4};
5use crate::{String, Vec};
6use alloc::collections::BTreeMap;
7use core::fmt::Write as _;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum GraphMlTopology {
11 Directed(Topology),
12 Undirected(UndirectedTopology),
13}
14
15impl GraphMlTopology {
16 #[must_use]
17 pub const fn is_directed(&self) -> bool {
18 matches!(self, Self::Directed(_))
19 }
20}
21
22#[must_use]
24pub fn topology_to_graphml<G>(graph: &G) -> String
25where
26 G: IndexGraphView,
27{
28 render(
29 true,
30 graph.node_indices().map(G::node_slot).collect(),
31 graph
32 .edge_references()
33 .map(|(_, edge)| (G::node_slot(edge.source()), G::node_slot(edge.target())))
34 .collect(),
35 )
36}
37
38#[must_use]
40pub fn undirected_to_graphml<G>(graph: &G) -> String
41where
42 G: IndexUndirectedGraphView,
43{
44 render(
45 false,
46 graph.node_indices().map(G::node_slot).collect(),
47 graph
48 .edge_indices()
49 .filter_map(|edge| graph.edge_endpoints(edge))
50 .map(|edge| (G::node_slot(edge.source()), G::node_slot(edge.target())))
51 .collect(),
52 )
53}
54
55pub fn graphml_decode(input: &str) -> Result<GraphMlTopology> {
66 if !input.contains("<graphml") {
67 return Err(invalid("missing graphml root"));
68 }
69 let graphs = elements(input, "graph")?;
70 if graphs.len() != 1 {
71 return Err(invalid("exactly one graph element is required"));
72 }
73 let directed = match attribute(graphs[0], "edgedefault") {
74 Some("directed") => true,
75 Some("undirected") => false,
76 _ => return Err(invalid("edgedefault must be directed or undirected")),
77 };
78 reject_elements(input, ["hyperedge", "port", "locator"])?;
79 let nodes = elements(input, "node")?;
80 let mut by_id = BTreeMap::new();
81 for tag in nodes {
82 let id = required_attribute(tag, "id")?;
83 if by_id.insert(String::from(id), by_id.len()).is_some() {
84 return Err(invalid("duplicate node id"));
85 }
86 }
87 let mut edges = Vec::new();
88 for tag in elements(input, "edge")? {
89 if attribute(tag, "directed").is_some() {
90 return Err(unsupported("per-edge direction overrides"));
91 }
92 let source = required_attribute(tag, "source")?;
93 let target = required_attribute(tag, "target")?;
94 let source = by_id
95 .get(source)
96 .copied()
97 .ok_or_else(|| invalid("edge references an unknown source"))?;
98 let target = by_id
99 .get(target)
100 .copied()
101 .ok_or_else(|| invalid("edge references an unknown target"))?;
102 edges.push(EdgeEndpoints::new(node(source)?, node(target)?));
103 }
104 if directed {
105 Ok(GraphMlTopology::Directed(Topology::try_from_edges(
106 by_id.len(),
107 edges,
108 )?))
109 } else {
110 Ok(GraphMlTopology::Undirected(
111 UndirectedTopology::try_from_edges(by_id.len(), edges)?,
112 ))
113 }
114}
115
116fn render(directed: bool, mut nodes: Vec<usize>, mut edges: Vec<(usize, usize)>) -> String {
117 nodes.sort_unstable();
118 edges.sort_unstable();
119 let default = if directed { "directed" } else { "undirected" };
120 let mut output = String::from(
121 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
122 <graphml xmlns=\"http://graphml.graphdrawing.org/xmlns\">\n",
123 );
124 let _ = writeln!(output, " <graph id=\"G\" edgedefault=\"{default}\">");
125 for node in nodes {
126 let _ = writeln!(output, " <node id=\"n{node}\"/>");
127 }
128 for (index, (source, target)) in edges.into_iter().enumerate() {
129 let _ = writeln!(
130 output,
131 " <edge id=\"e{index}\" source=\"n{source}\" target=\"n{target}\"/>"
132 );
133 }
134 output.push_str(" </graph>\n</graphml>\n");
135 output
136}
137
138fn elements<'a>(input: &'a str, name: &str) -> Result<Vec<&'a str>> {
139 let mut result = Vec::new();
140 let mut offset = 0_usize;
141 while let Some(relative) = input[offset..].find('<') {
142 let start = offset + relative;
143 let rest = &input[start + 1..];
144 offset = start + 1;
145 if rest.starts_with('/') || rest.starts_with('?') || rest.starts_with('!') {
146 continue;
147 }
148 let Some(after_name) = rest.strip_prefix(name) else {
149 continue;
150 };
151 if !after_name.chars().next().is_some_and(|character| {
152 character.is_ascii_whitespace() || matches!(character, '/' | '>')
153 }) {
154 continue;
155 }
156 let end = rest
157 .find('>')
158 .ok_or_else(|| invalid("unterminated XML tag"))?;
159 result.push(&rest[..end]);
160 offset = start + end + 2;
161 }
162 Ok(result)
163}
164
165fn attribute<'a>(tag: &'a str, name: &str) -> Option<&'a str> {
166 let mut search = tag;
167 while let Some(position) = search.find(name) {
168 let before_ok = position == 0 || search.as_bytes()[position - 1].is_ascii_whitespace();
169 let after = search.get(position + name.len()..)?;
170 let after = after.trim_start();
171 if before_ok {
172 let after = after.strip_prefix('=')?.trim_start();
173 let quote = after.chars().next()?;
174 if matches!(quote, '"' | '\'') {
175 let value = &after[quote.len_utf8()..];
176 let end = value.find(quote)?;
177 return Some(&value[..end]);
178 }
179 }
180 search = &after[name.len().min(after.len())..];
181 }
182 None
183}
184
185fn required_attribute<'a>(tag: &'a str, name: &str) -> Result<&'a str> {
186 attribute(tag, name).ok_or_else(|| invalid(&format!("missing {name} attribute")))
187}
188
189fn reject_elements<const N: usize>(input: &str, names: [&'static str; N]) -> Result<()> {
190 for name in names {
191 if !elements(input, name)?.is_empty() {
192 return Err(unsupported(name));
193 }
194 }
195 Ok(())
196}
197
198fn node(index: usize) -> Result<NodeIndex> {
199 u32::try_from(index)
200 .map(NodeIndex::new)
201 .map_err(|_| GraphError::IndexCapacityExceeded {
202 category: "GraphML nodes",
203 count: index.saturating_add(1),
204 })
205}
206
207fn invalid(reason: &str) -> GraphError {
208 GraphError::InvalidFormat {
209 format: "GraphML",
210 reason: String::from(reason),
211 }
212}
213
214fn unsupported(feature: &'static str) -> GraphError {
215 GraphError::UnsupportedGraphFeature {
216 format: "GraphML",
217 feature,
218 }
219}