Skip to main content

weavatrix_graph/format/
graphml.rs

1use 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/// Exports a directed numeric topology as deterministic `GraphML`.
23#[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/// Exports an undirected numeric topology as deterministic `GraphML`.
39#[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
55/// Imports the structural `GraphML` subset: one graph, nodes, and edges.
56///
57/// Arbitrary node ids are accepted and mapped by node declaration order.
58/// Nested graphs, ports, hyperedges, and per-edge direction overrides are
59/// rejected instead of being silently discarded.
60///
61/// # Errors
62///
63/// Returns an error for malformed XML tags, references, or unsupported graph
64/// features.
65pub 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}