From 8bbeee57ece77e3465d81a3c1345feb142bed9ca Mon Sep 17 00:00:00 2001 From: stringhandler Date: Wed, 11 Feb 2026 17:18:20 +0200 Subject: [PATCH] feat: add api to output for graphviz and mermaid --- simpcli/src/main.rs | 12 +++ src/node/display.rs | 214 +++++++++++++++++++++++++++++++++++++++++++- src/node/mod.rs | 36 +++++++- 3 files changed, 260 insertions(+), 2 deletions(-) diff --git a/simpcli/src/main.rs b/simpcli/src/main.rs index 36c0bccd..cb7660a7 100644 --- a/simpcli/src/main.rs +++ b/simpcli/src/main.rs @@ -28,6 +28,7 @@ fn usage(process_name: &str) { eprintln!("Usage:"); eprintln!(" {} assemble ", process_name); eprintln!(" {} disassemble ", process_name); + eprintln!(" {} graph ", process_name); eprintln!(" {} relabel ", process_name); eprintln!(); eprintln!("For commands which take an optional expression, the default value is \"main\"."); @@ -43,6 +44,7 @@ fn invalid_usage(process_name: &str) -> Result<(), String> { enum Command { Assemble, Disassemble, + Graph, Relabel, Help, } @@ -53,6 +55,7 @@ impl FromStr for Command { match s { "assemble" => Ok(Command::Assemble), "disassemble" => Ok(Command::Disassemble), + "graphviz" | "dot" | "graph" => Ok(Command::Graph), "relabel" => Ok(Command::Relabel), "help" => Ok(Command::Help), x => Err(format!("unknown command {}", x)), @@ -65,6 +68,7 @@ impl Command { match *self { Command::Assemble => false, Command::Disassemble => false, + Command::Graph => false, Command::Relabel => false, Command::Help => false, } @@ -155,6 +159,14 @@ fn main() -> Result<(), String> { let prog = Forest::::from_program(commit); println!("{}", prog.string_serialize()); } + Command::Graph => { + let v = simplicity::base64::Engine::decode(&STANDARD, first_arg.as_bytes()) + .map_err(|e| format!("failed to parse base64: {}", e))?; + let iter = BitIter::from(v.into_iter()); + let commit = CommitNode::::decode(iter) + .map_err(|e| format!("failed to decode program: {}", e))?; + println!("{}", commit.display_as_dot()); + } Command::Relabel => { let prog = parse_file(&first_arg)?; println!("{}", prog.string_serialize()); diff --git a/src/node/display.rs b/src/node/display.rs index 42a34a6c..0fa0e676 100644 --- a/src/node/display.rs +++ b/src/node/display.rs @@ -1,7 +1,9 @@ use std::fmt; use std::sync::OnceLock; -use crate::dag::{Dag, DagLike, InternalSharing, MaxSharing, NoSharing}; +use crate::dag::{ + Dag, DagLike, InternalSharing, MaxSharing, NoSharing, PostOrderIterItem, SharingTracker, +}; use crate::encode; use crate::node::{Inner, Marker, Node}; use crate::BitWriter; @@ -197,6 +199,166 @@ where } } +/// The output format for [`DisplayAsGraph`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GraphFormat { + /// Graphviz DOT format, renderable with `dot -Tsvg` or similar tools. + Dot, + /// Mermaid diagram format, renderable in Markdown or the Mermaid live editor. + Mermaid, +} + +/// The node-sharing level for [`DisplayAsGraph`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SharingLevel { + /// No sharing: every use of a node is visited separately (may be exponentially large). + None, + /// Internal sharing: nodes shared within the expression are visited once. + Internal, + /// Maximum sharing: maximize sharing across the entire expression. + Max, +} + +/// Display a Simplicity expression as a graph in a chosen format. +/// +/// Construct via [`Node::display_as_dot`], [`Node::display_as_mermaid`], or +/// [`DisplayAsGraph::new`]. The [`fmt::Display`] impl renders using the stored +/// `format` and `sharing` fields; [`to_dot_string`](DisplayAsGraph::to_dot_string) +/// and [`to_mermaid_string`](DisplayAsGraph::to_mermaid_string) always render in +/// the named format using the stored sharing level. +pub struct DisplayAsGraph<'a, M: Marker> { + node: &'a Node, + /// Output format (DOT or Mermaid). + pub format: GraphFormat, + /// Node-sharing level used when rendering. + pub sharing: SharingLevel, +} + +impl<'a, M: Marker> DisplayAsGraph<'a, M> { + /// Create a new `DisplayAsGraph` with the given format and sharing level. + pub fn new(node: &'a Node, format: GraphFormat, sharing: SharingLevel) -> Self { + Self { + node, + format, + sharing, + } + } + + /// Render as a Graphviz DOT string using the stored sharing level. + pub fn to_dot_string(&self) -> String + where + &'a Node: DagLike, + { + let mut result = String::new(); + match self.render(GraphFormat::Dot, &mut result) { + Ok(_) => result, + Err(e) => format!("Could not display as string: {}", e), + } + } + + /// Render as a Mermaid string using the stored sharing level. + pub fn to_mermaid_string(&self) -> String + where + &'a Node: DagLike, + { + let mut result = String::new(); + match self.render(GraphFormat::Mermaid, &mut result) { + Ok(_) => result, + Err(e) => format!("Could not display as string: {}", e), + } + } + + fn render(&self, graph_format: GraphFormat, w: &mut W) -> fmt::Result + where + &'a Node: DagLike, + { + match self.sharing { + SharingLevel::None => self.render_with::(graph_format, w), + SharingLevel::Internal => self.render_with::(graph_format, w), + SharingLevel::Max => self.render_with::, _>(graph_format, w), + } + } + + fn render_with(&self, graph_format: GraphFormat, w: &mut W) -> fmt::Result + where + S: SharingTracker<&'a Node> + Default, + W: fmt::Write, + { + let node_label = |data: &PostOrderIterItem<&Node>| -> String { + match data.node.inner() { + Inner::Witness(_) => format!("witness({})", data.index), + Inner::Word(word) => format!("word({})", shorten(word.to_string(), 12)), + _ => data.node.inner().to_string(), + } + }; + + match graph_format { + GraphFormat::Dot => { + writeln!(w, "digraph G {{")?; + writeln!(w, "ordering=\"out\";")?; + for data in self.node.post_order_iter::() { + writeln!(w, " node{}[label=\"{}\"];", data.index, node_label(&data))?; + if let Some(left) = data.left_index { + writeln!(w, " node{}->node{};", data.index, left)?; + } + if let Some(right) = data.right_index { + writeln!(w, " node{}->node{};", data.index, right)?; + } + } + writeln!(w, "}}")?; + } + GraphFormat::Mermaid => { + writeln!(w, "flowchart TD")?; + for data in self.node.post_order_iter::() { + match data.node.inner() { + Inner::Case(..) => { + writeln!(w, " node{}{{\"{}\"}}", data.index, node_label(&data))?; + } + _ => { + writeln!(w, " node{}[\"{}\"]", data.index, node_label(&data))?; + } + } + + if let Some(left) = data.left_index { + writeln!(w, " node{} --> node{}", data.index, left)?; + } + if let Some(right) = data.right_index { + writeln!(w, " node{} --> node{}", data.index, right)?; + } + } + } + } + + Ok(()) + } +} + +impl<'a, M: Marker> fmt::Display for DisplayAsGraph<'a, M> +where + &'a Node: DagLike, +{ + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.render(self.format, f) + } +} + +fn shorten>(s: S, max_len: usize) -> String { + let s = s.as_ref(); + let chars: Vec = s.chars().collect(); + if chars.len() <= max_len { + s.to_string() + } else { + let dots = "..."; + let available = max_len.saturating_sub(dots.len()); + let start_len = available.div_ceil(2); // Slightly favor the start + let end_len = available / 2; + + let start: String = chars[..start_len].iter().collect(); + let end: String = chars[chars.len() - end_len..].iter().collect(); + format!("{}{}{}", start, dots, end) + } +} + #[cfg(test)] mod tests { use crate::human_encoding::Forest; @@ -241,4 +403,54 @@ mod tests { program.display_expr().to_string() ) } + + #[test] + fn display_as_dot() { + let s = " + oih := take drop iden + input := pair (pair unit unit) unit + output := unit + main := comp input (comp (pair oih (take unit)) output)"; + let program = parse_program(s); + let str = program + .display_as_dot() + .to_string() + .replace(" ", "") + .replace("\n", ""); + let expected = " + digraph G { +ordering=\"out\"; + node0[label=\"unit\"]; + node1[label=\"unit\"]; + node2[label=\"pair\"]; + node2->node0; + node2->node1; + node3[label=\"unit\"]; + node4[label=\"pair\"]; + node4->node2; + node4->node3; + node5[label=\"iden\"]; + node6[label=\"drop\"]; + node6->node5; + node7[label=\"take\"]; + node7->node6; + node8[label=\"unit\"]; + node9[label=\"take\"]; + node9->node8; + node10[label=\"pair\"]; + node10->node7; + node10->node9; + node11[label=\"unit\"]; + node12[label=\"comp\"]; + node12->node10; + node12->node11; + node13[label=\"comp\"]; + node13->node4; + node13->node12; +}" + .replace(" ", "") + .replace("\n", ""); + + assert_eq!(str, expected); + } } diff --git a/src/node/mod.rs b/src/node/mod.rs index bda0997b..0e7f37fa 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -85,7 +85,7 @@ pub use commit::{Commit, CommitData, CommitNode}; pub use construct::{Construct, ConstructData, ConstructNode}; pub use convert::{Converter, Hide, SimpleFinalizer}; pub use disconnect::{Disconnectable, NoDisconnect}; -pub use display::{Display, DisplayExpr}; +pub use display::{Display, DisplayAsGraph, DisplayExpr, GraphFormat, SharingLevel}; pub use hiding::Hiding; pub use inner::Inner; pub use redeem::{Redeem, RedeemData, RedeemNode}; @@ -723,6 +723,40 @@ impl Node { DisplayExpr::from(self) } + /// Display the Simplicity expression as a graph in the given format and sharing level. + /// + /// This is the general form of [`display_as_dot`](Node::display_as_dot) and + /// [`display_as_mermaid`](Node::display_as_mermaid). Use those convenience methods for + /// the common case of DOT or Mermaid output with no sharing. + /// + /// The `format` field of the returned [`DisplayAsGraph`] can be changed after construction, + /// and the [`fmt::Display`] impl will use whatever `format` and `sharing` are set at that + /// point. See also [`DisplayAsGraph::to_dot_string`] and [`DisplayAsGraph::to_mermaid_string`] + /// to render to a specific format regardless of the stored `format` field. + pub fn display_as_graph( + &self, + format: GraphFormat, + sharing_level: SharingLevel, + ) -> DisplayAsGraph<'_, N> { + DisplayAsGraph::new(self, format, sharing_level) + } + + /// Display the Simplicity expression as a Graphviz DOT graph. + /// + /// The DOT output can be rendered with `dot -Tsvg` or similar tools. + /// Shared nodes appear once in the graph with multiple incoming edges. + pub fn display_as_dot(&self) -> DisplayAsGraph<'_, N> { + DisplayAsGraph::new(self, GraphFormat::Dot, SharingLevel::None) + } + + /// Display the Simplicity expression as a Mermaid diagram. + /// + /// The Mermaid output can be rendered in Markdown or the Mermaid live editor. + /// Shared nodes appear once in the diagram with multiple incoming edges. + pub fn display_as_mermaid(&self) -> DisplayAsGraph<'_, N> { + DisplayAsGraph::new(self, GraphFormat::Mermaid, SharingLevel::None) + } + /// Encode a Simplicity expression to bits without any witness data. pub fn encode_without_witness(&self, prog: W) -> io::Result { let mut w = BitWriter::new(prog);