orchestra_proc_macro/
graph.rs

1// Copyright (C) 2022 Parity Technologies (UK) Ltd.
2// SPDX-License-Identifier: Apache-2.0
3
4// Licensed under the Apache License, Version 2.0 (the "License");
5// you may not use this file except in compliance with the License.
6// You may obtain a copy of the License at
7//
8// http://www.apache.org/licenses/LICENSE-2.0
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under the License is distributed on an "AS IS" BASIS,
12// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13// See the License for the specific language governing permissions and
14// limitations under the License.
15
16use quote::ToTokens;
17use syn::{Ident, Path};
18
19use indexmap::IndexMap;
20use petgraph::{graph::NodeIndex, Graph};
21use std::collections::{hash_map::RandomState, HashMap, HashSet};
22
23use super::*;
24
25/// Representation of all subsystem connections
26pub(crate) struct ConnectionGraph<'a> {
27	/// Graph of connected subsystems
28	///
29	/// The graph represents a subsystem as a node or `NodeIndex`
30	/// and edges are messages sent, directed from the sender to
31	/// the receiver of the message.
32	pub(crate) graph: Graph<Ident, Path>,
33	/// Cycles within the graph
34	#[cfg_attr(not(feature = "dotgraph"), allow(dead_code))]
35	pub(crate) sccs: Vec<Vec<NodeIndex>>,
36	/// Messages that are never being sent (and by which subsystem), but are consumed
37	/// Maps the message `Path` to the subsystem `Ident` represented by `NodeIndex`.
38	#[cfg_attr(not(feature = "dotgraph"), allow(dead_code))]
39	pub(crate) unsent_messages: IndexMap<&'a Path, (&'a Ident, NodeIndex)>,
40	/// Messages being sent (and by which subsystem), but not consumed by any subsystem
41	/// Maps the message `Path` to the subsystem `Ident` represented by `NodeIndex`.
42	#[cfg_attr(not(feature = "dotgraph"), allow(dead_code))]
43	pub(crate) unconsumed_messages: IndexMap<&'a Path, Vec<(&'a Ident, NodeIndex)>>,
44}
45
46impl<'a> ConnectionGraph<'a> {
47	/// Generates all subsystem types and related accumulation traits.
48	pub(crate) fn construct(ssfs: &'a [SubSysField]) -> Self {
49		// create a directed graph with all the subsystems as nodes and the messages as edges
50		// key is always the message path, values are node indices in the graph and the subsystem generic identifier
51		// store the message path and the source sender, both in the graph as well as identifier
52		let mut outgoing_lut = IndexMap::<&Path, Vec<(&Ident, NodeIndex)>>::with_capacity(128);
53		// same for consuming the incoming messages
54		let mut consuming_lut = IndexMap::<&Path, (&Ident, NodeIndex)>::with_capacity(128);
55
56		let mut graph = Graph::<Ident, Path>::new();
57
58		// prepare the full index of outgoing and source subsystems
59		for ssf in ssfs {
60			let node_index = graph.add_node(ssf.generic.clone());
61			for outgoing in ssf.messages_to_send.iter() {
62				outgoing_lut.entry(outgoing).or_default().push((&ssf.generic, node_index));
63			}
64			if let Some(ref consumes) = ssf.message_to_consume {
65				if let Some(_first_consument) =
66					consuming_lut.insert(consumes, (&ssf.generic, node_index))
67				{
68					// bail, two subsystems consuming the same message
69				}
70			}
71		}
72
73		for (message_ty, (_consuming_subsystem_ident, consuming_node_index)) in consuming_lut.iter()
74		{
75			// match the outgoing ones that were registered above with the consumed message
76			if let Some(origin_subsystems) = outgoing_lut.get(message_ty) {
77				for (_origin_subsystem_ident, sending_node_index) in origin_subsystems.iter() {
78					graph.add_edge(
79						*sending_node_index,
80						*consuming_node_index,
81						(*message_ty).clone(),
82					);
83				}
84			}
85		}
86
87		// extract unsent and unreceived messages
88		let outgoing_set = HashSet::<_, RandomState>::from_iter(outgoing_lut.keys().cloned());
89		let consuming_set = HashSet::<_, RandomState>::from_iter(consuming_lut.keys().cloned());
90
91		let mut unsent_messages = consuming_lut;
92		unsent_messages.retain(|k, _v| !outgoing_set.contains(k));
93
94		let mut unconsumed_messages = outgoing_lut;
95		unconsumed_messages.retain(|k, _v| !consuming_set.contains(k));
96
97		let scc = Self::extract_scc(&graph);
98
99		Self { graph, sccs: scc, unsent_messages, unconsumed_messages }
100	}
101
102	/// Extract the strongly connected components (`scc`) which each
103	/// includes at least one cycle each.
104	fn extract_scc(graph: &Graph<Ident, Path>) -> Vec<Vec<NodeIndex>> {
105		use petgraph::visit::EdgeRef;
106
107		// there is no guarantee regarding the node indices in the individual sccs
108		let sccs = petgraph::algo::kosaraju_scc(&graph);
109		let sccs = Vec::from_iter(sccs.into_iter().filter(|scc| {
110			match scc.len() {
111				1 => {
112					// contains sccs of length one,
113					// which do not exists, might be an upstream bug?
114					let node_idx = scc[0];
115					graph
116						.edges_directed(node_idx, petgraph::Direction::Outgoing)
117						.find(|edge| edge.target() == node_idx)
118						.is_some()
119				},
120				0 => false,
121				_n => true,
122			}
123		}));
124		match sccs.len() {
125			0 => eprintln!("✅ Found no strongly connected components, hence no cycles exist"),
126			1 => eprintln!(
127				"⚡ Found 1 strongly connected component which includes at least one cycle"
128			),
129			n => eprintln!(
130				"⚡ Found {n} strongly connected components which includes at least one cycle each"
131			),
132		}
133
134		let greek_alphabet = greek_alphabet();
135
136		for (scc_idx, scc) in sccs.iter().enumerate() {
137			let scc_tag = greek_alphabet.get(scc_idx).copied().unwrap_or('_');
138			let mut acc = Vec::with_capacity(scc.len());
139			assert!(scc.len() > 0);
140			let mut node_idx = scc[0].clone();
141			let print_idx = scc_idx + 1;
142			// track which ones were visited and which step
143			// the step is required to truncate the output
144			// which is required to greedily find a cycle in the strongly connected component
145			let mut visited = HashMap::new();
146			for step in 0..scc.len() {
147				if let Some(edge) =
148					graph.edges_directed(node_idx, petgraph::Direction::Outgoing).find(|edge| {
149						scc.iter().find(|&scc_node_idx| *scc_node_idx == edge.target()).is_some()
150					}) {
151					let next = edge.target();
152					visited.insert(node_idx, step);
153
154					let subsystem_name = &graph[node_idx].to_string();
155					let message_name = &graph[edge.id()].to_token_stream().to_string();
156					acc.push(format!("{subsystem_name} ~~{{{message_name:?}}}~~> "));
157					node_idx = next;
158
159					if let Some(step) = visited.get(&next) {
160						// we've been there, so there is a cycle
161						// cut off the extra tail
162						assert!(acc.len() >= *step);
163						acc.drain(..step);
164						// there might be more cycles in this cluster,
165						// but for they are ignored, the graph shows
166						// the entire strongly connected component.
167						break
168					}
169				} else {
170					eprintln!("cycle({print_idx:03}) ∈ {scc_tag}: Missing connection in hypothesized cycle after {step} steps, this is a bug 🐛");
171					break
172				}
173			}
174			let acc = String::from_iter(acc);
175			eprintln!("cycle({print_idx:03}) ∈ {scc_tag}: {acc} *");
176		}
177
178		sccs
179	}
180
181	#[cfg(feature = "dotgraph")]
182	fn convert_and_write_all<'b>(
183		dot: &petgraph::dot::Dot<'b, &'b petgraph::graph::Graph<Ident, Path>>,
184		dest: impl AsRef<std::path::Path>,
185	) -> anyhow::Result<()> {
186		use self::graph_helpers::color_scheme;
187
188		let dot_content = format!(
189			r#"digraph {{
190				fontname="Cantarell"
191				bgcolor="white"
192				label = "orchestra message flow between subsystems"
193node [colorscheme={}]
194{:?}
195}}"#,
196			color_scheme(),
197			&dot
198		);
199
200		let dest = dest.as_ref();
201		let dest = dest.to_path_buf();
202
203		write_to_disk(&dest, "dot", &dot_content)?;
204
205		let svg_content = convert_dot_to_svg(&dot_content)?;
206
207		write_to_disk(&dest, "svg", &svg_content)?;
208
209		Ok(())
210	}
211
212	/// Render a graphviz (aka dot graph) to a file.
213	///
214	/// Cycles are annotated with the lower
215	#[cfg(feature = "dotgraph")]
216	pub(crate) fn render_graphs<'b>(self, dest: impl AsRef<std::path::Path>) -> anyhow::Result<()> {
217		use self::graph_helpers::*;
218		use petgraph::{
219			dot::{self, Dot},
220			visit::{EdgeRef, IntoEdgeReferences, IntoNodeReferences},
221		};
222
223		// only write the grap content, we want a custom color scheme
224		let config = &[
225			dot::Config::GraphContentOnly,
226			dot::Config::EdgeNoLabel,
227			dot::Config::NodeNoLabel,
228		][..];
229
230		let Self { mut graph, unsent_messages, unconsumed_messages, sccs } = self;
231
232		// the greek alphabet, lowercase
233		let greek_alphabet = greek_alphabet();
234
235		const COLOR_SCHEME_N: usize = 10; // rdylgn10
236
237		// Adding more than 10, is _definitely_ too much visual clutter in the graph.
238		const UPPER_BOUND: usize = 10;
239
240		assert!(UPPER_BOUND <= GREEK_ALPHABET_SIZE);
241		assert!(UPPER_BOUND <= COLOR_SCHEME_N);
242
243		let n = sccs.len();
244		let n = if n > UPPER_BOUND {
245			eprintln!("Too many ({n}) strongly connected components, only annotating the first {UPPER_BOUND}");
246			UPPER_BOUND
247		} else {
248			n
249		};
250
251		// restructure for lookups
252		let mut scc_lut = HashMap::<NodeIndex, HashSet<char>>::with_capacity(n);
253		// lookup the color index (which is equiv to the index in the cycle set vector _plus one_)
254		// based on the cycle_tag (the greek char)
255		let mut color_lut = HashMap::<char, usize>::with_capacity(COLOR_SCHEME_N);
256		for (scc_idx, scc) in sccs.into_iter().take(UPPER_BOUND).enumerate() {
257			for node_idx in scc {
258				let _ = scc_lut.entry(node_idx).or_default().insert(greek_alphabet[scc_idx]);
259			}
260			color_lut.insert(greek_alphabet[scc_idx], scc_idx + 1);
261		}
262		let color_lut = &color_lut;
263
264		// Adding nodes is ok, the `NodeIndex` is append only as long
265		// there are no removals.
266
267		// Depict sink for unconsumed messages
268		let unconsumed_idx = graph.add_node(quote::format_ident!("SENT_TO_NONONE"));
269		for (message_name, subsystems) in unconsumed_messages {
270			// iterate over all subsystems that send such a message
271			for (_sub_name, sub_node_idx) in subsystems {
272				graph.add_edge(sub_node_idx, unconsumed_idx, message_name.clone());
273			}
274		}
275
276		// depict source of unsent message, this is legit when
277		// integrated with an external source, and sending messages based
278		// on that
279		let unsent_idx = graph.add_node(quote::format_ident!("NEVER_SENT_ANYWHERE"));
280		for (message_name, (_sub_name, sub_node_idx)) in unsent_messages {
281			graph.add_edge(unsent_idx, sub_node_idx, message_name.clone());
282		}
283		let unsent_node_label = r#"label="✨",fillcolor=black,shape=doublecircle,style=filled,fontname="NotoColorEmoji""#;
284		let unconsumed_node_label = r#"label="💀",fillcolor=black,shape=doublecircle,style=filled,fontname="NotoColorEmoji""#;
285
286		let get_edge_attributes = {
287			|_graph: &Graph<Ident, Path>,
288			 edge: <&Graph<Ident, Path> as IntoEdgeReferences>::EdgeRef|
289			 -> String {
290				let source = edge.source();
291				let sink = edge.target();
292
293				let message_name =
294					edge.weight().get_ident().expect("Must have a trailing identifier. qed");
295
296				// use the intersection only, that's the set of cycles the edge is part of
297				if let Some(edge_intersecting_scc_tags) =
298					scc_lut.get(&source).and_then(|source_set| {
299						scc_lut.get(&sink).and_then(move |sink_set| {
300							let intersection = HashSet::<_, RandomState>::from_iter(
301								source_set.intersection(sink_set),
302							);
303							if intersection.is_empty() {
304								None
305							} else {
306								Some(intersection)
307							}
308						})
309					}) {
310					if edge_intersecting_scc_tags.len() != 1 {
311						unreachable!(
312							"Strongly connected components are disjunct by definition. qed"
313						);
314					}
315					let scc_tag = edge_intersecting_scc_tags.iter().next().unwrap();
316					let color = get_color_by_tag(scc_tag, color_lut);
317					let scc_tag_str =
318						cycle_tags_to_annotation(edge_intersecting_scc_tags, color_lut);
319					format!(
320						r#"color="{color}",fontcolor="{color}",xlabel=<{scc_tag_str}>,label="{message_name}""#,
321					)
322				} else {
323					format!(r#"label="{message_name}""#,)
324				}
325			}
326		};
327		let get_node_attributes = {
328			|_graph: &Graph<Ident, Path>,
329			 (node_index, subsystem_name): <&Graph<Ident, Path> as IntoNodeReferences>::NodeRef|
330			 -> String {
331				if node_index == unsent_idx {
332					unsent_node_label.to_owned().clone()
333				} else if node_index == unconsumed_idx {
334					unconsumed_node_label.to_owned().clone()
335				} else if let Some(edge_intersecting_scc_tags) = scc_lut.get(&node_index) {
336					if edge_intersecting_scc_tags.len() != 1 {
337						unreachable!(
338							"Strongly connected components are disjunct by definition. qed"
339						);
340					};
341					let scc_tag = edge_intersecting_scc_tags.iter().next().unwrap();
342					let color = get_color_by_tag(scc_tag, color_lut);
343
344					let scc_tag_str =
345						cycle_tags_to_annotation(edge_intersecting_scc_tags, color_lut);
346					format!(
347						r#"color="{color}",fontcolor="{color}",xlabel=<{scc_tag_str}>,label="{subsystem_name}""#,
348					)
349				} else {
350					format!(r#"label="{subsystem_name}""#)
351				}
352			}
353		};
354		// let graph = Box::new(graph);
355		let dotgraph = Dot::with_attr_getters(
356			&graph,
357			config,
358			&get_edge_attributes, // with state, the reference is a trouble maker
359			&get_node_attributes,
360		);
361
362		Self::convert_and_write_all(&dotgraph, dest)?;
363
364		Ok(())
365	}
366}
367
368#[cfg(feature = "dotgraph")]
369fn convert_dot_to_svg(dot_content: impl AsRef<str>) -> anyhow::Result<String> {
370	let mut parser = dotlay::gv::DotParser::new(dot_content.as_ref());
371	let graph = parser.process().map_err(|err_msg| {
372		parser.print_error();
373		anyhow::anyhow!(err_msg).context("Failed to parse dotfile content")
374	})?;
375	let mut svg = dotlay::backends::svg::SVGWriter::new();
376	let mut builder = dotlay::gv::GraphBuilder::default();
377	builder.visit_graph(&graph);
378	let mut vg = builder.get();
379	vg.do_it(true, false, false, &mut svg);
380	Ok(svg.finalize())
381}
382
383#[cfg(feature = "dotgraph")]
384fn write_to_disk(
385	dest: impl AsRef<std::path::Path>,
386	ext: &str,
387	content: impl AsRef<[u8]>,
388) -> std::io::Result<()> {
389	use fs_err as fs;
390	let dest = dest.as_ref().with_extension(ext);
391	print!("Writing {} to {} ..", ext, dest.display());
392	fs::write(dest, content.as_ref())?;
393	println!(" OK");
394	Ok(())
395}
396
397const GREEK_ALPHABET_SIZE: usize = 24;
398
399fn greek_alphabet() -> [char; GREEK_ALPHABET_SIZE] {
400	let mut alphabet = ['\u{03B1}'; 24];
401	alphabet
402		.iter_mut()
403		.enumerate()
404		// closure should never return `None`,
405		// but rather safe than sorry
406		.for_each(|(i, c)| {
407			*c = char::from_u32(*c as u32 + i as u32).unwrap();
408		});
409	alphabet
410}
411
412#[cfg(feature = "dotgraph")]
413mod graph_helpers {
414	use super::HashMap;
415
416	pub(crate) const fn color_scheme() -> &'static str {
417		"rdylgn10"
418	}
419
420	pub(crate) fn get_color_by_idx(color_idx: usize) -> String {
421		let scheme = color_scheme();
422		format!("/{scheme}/{color_idx}")
423	}
424
425	pub(crate) fn get_color_by_tag(scc_tag: &char, color_lut: &HashMap<char, usize>) -> String {
426		get_color_by_idx(color_lut.get(scc_tag).copied().unwrap_or_default())
427	}
428
429	/// A node can be member of multiple cycles,
430	/// but only of one strongly connected component.
431	pub(crate) fn cycle_tags_to_annotation<'a>(
432		cycle_tags: impl IntoIterator<Item = &'a char>,
433		color_lut: &HashMap<char, usize>,
434	) -> String {
435		// Must use fully qualified syntax:
436		// <https://github.com/rust-lang/rust/issues/48919>
437		let cycle_annotation = String::from_iter(itertools::Itertools::intersperse(
438			cycle_tags.into_iter().map(|scc_tag| {
439				let color = get_color_by_tag(scc_tag, color_lut);
440				format!(r#"<B><FONT COLOR="{color}">{scc_tag}</FONT></B>"#)
441			}),
442			",".to_owned(),
443		));
444		cycle_annotation
445	}
446}
447
448#[cfg(test)]
449mod tests {
450	// whenever this starts working, we should consider
451	// replacing the all caps idents with something like
452	// the below.
453	// <https://rust-lang.github.io/rfcs/2457-non-ascii-idents.html>
454	//
455	// For now the rendering is modified, the ident is a placeholder.
456	#[test]
457	#[should_panic]
458	fn check_ident() {
459		let _ident = quote::format_ident!("x💀x");
460	}
461
462	#[test]
463	fn kosaraju_scc_check_nodes_cannot_be_part_of_two_clusters() {
464		let mut graph = petgraph::graph::DiGraph::<char, &str>::new();
465
466		let a_idx = graph.add_node('A');
467		let b_idx = graph.add_node('B');
468		let c_idx = graph.add_node('C');
469		let d_idx = graph.add_node('D');
470		let e_idx = graph.add_node('E');
471		let f_idx = graph.add_node('F');
472
473		graph.add_edge(a_idx, b_idx, "10");
474		graph.add_edge(b_idx, c_idx, "11");
475		graph.add_edge(c_idx, a_idx, "12");
476
477		graph.add_edge(a_idx, d_idx, "20");
478		graph.add_edge(d_idx, c_idx, "21");
479
480		graph.add_edge(b_idx, e_idx, "30");
481		graph.add_edge(e_idx, c_idx, "31");
482
483		graph.add_edge(c_idx, f_idx, "40");
484
485		let mut sccs = dbg!(petgraph::algo::kosaraju_scc(&graph));
486
487		dbg!(graph);
488
489		sccs.sort_by(|a, b| {
490			if a.len() < b.len() {
491				std::cmp::Ordering::Greater
492			} else {
493				std::cmp::Ordering::Less
494			}
495		});
496		assert_eq!(sccs.len(), 2); // `f` and everything else
497		assert_eq!(sccs[0].len(), 5); // every node but `f`
498	}
499
500	#[cfg(feature = "dotgraph")]
501	#[ignore]
502	#[test]
503	fn dot_to_svg_works() {
504		use super::convert_dot_to_svg;
505
506		let s = include_str!("../tests/assets/sample.dot");
507		convert_dot_to_svg(s).unwrap();
508	}
509}