Skip to content

Commit

Permalink
feat: Mermaid renderer for hugrs (#852)
Browse files Browse the repository at this point in the history
Adds a `HugrView::mermaid_string` which produces things like
```mermaid
graph LR
    subgraph 0 ["(0) DFG"]
        direction LR
        1["(1) Input"]
        1--0:0-->3
        1--1:1-->3
        2["(2) Output"]
        3["(3) test.quantum.CX"]
        3--0:1-->4
        3--1:0-->4
        3-.2:2.-4
        4["(4) test.quantum.CX"]
        4--0:0-->2
        4--1:1-->2
    end
```
Note that edges in mermaid are unordered, so I had to add the port
indices explicitly.

The new code in `src/hugr/views/render.rs` is just moved from
`src/hugr/views.rs`.

Closes #696 

Requires CQCL/portgraph#125
  • Loading branch information
aborgna-q authored Mar 5, 2024
1 parent c02ab92 commit 3fb833a
Show file tree
Hide file tree
Showing 12 changed files with 282 additions and 54 deletions.
6 changes: 3 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ extension_inference = []

[dependencies]
thiserror = "1.0.28"
portgraph = { version = "0.11.0", features = ["serde", "petgraph"] }
portgraph = { version = "0.12.0", features = ["serde", "petgraph"] }
regex = "1.9.5"
cgmath = { version = "0.18.0", features = ["serde"] }
num-rational = { version = "0.4.1", features = ["serde"] }
Expand Down Expand Up @@ -66,5 +66,5 @@ name = "bench_main"
harness = false


[profile.dev.package]
insta.opt-level = 3
[profile.dev.package.insta]
opt-level = 3
12 changes: 10 additions & 2 deletions src/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,8 +178,8 @@ pub(crate) mod test {

use super::handle::BuildHandle;
use super::{
BuildError, Container, DFGBuilder, Dataflow, DataflowHugr, FuncID, FunctionBuilder,
ModuleBuilder,
BuildError, CFGBuilder, Container, DFGBuilder, Dataflow, DataflowHugr, FuncID,
FunctionBuilder, ModuleBuilder,
};
use super::{DataflowSubContainer, HugrBuilder};

Expand Down Expand Up @@ -214,6 +214,14 @@ pub(crate) mod test {
dfg_builder.finish_prelude_hugr_with_outputs([i1]).unwrap()
}

#[fixture]
pub(crate) fn simple_cfg_hugr() -> Hugr {
let mut cfg_builder =
CFGBuilder::new(FunctionType::new(type_row![NAT], type_row![NAT])).unwrap();
super::cfg::test::build_basic_cfg(&mut cfg_builder).unwrap();
cfg_builder.finish_prelude_hugr().unwrap()
}

/// A helper method which creates a DFG rooted hugr with closed resources,
/// for tests which want to avoid having open extension variables after
/// inference. Using DFGBuilder will default to a root node with an open
Expand Down
4 changes: 2 additions & 2 deletions src/builder/cfg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,7 @@ impl BlockBuilder<Hugr> {
}

#[cfg(test)]
mod test {
pub(crate) mod test {
use crate::builder::build_traits::HugrBuilder;
use crate::builder::{DataflowSubContainer, ModuleBuilder};

Expand Down Expand Up @@ -453,7 +453,7 @@ mod test {
Ok(())
}

fn build_basic_cfg<T: AsMut<Hugr> + AsRef<Hugr>>(
pub(crate) fn build_basic_cfg<T: AsMut<Hugr> + AsRef<Hugr>>(
cfg_builder: &mut CFGBuilder<T>,
) -> Result<(), BuildError> {
let sum2_variants = vec![type_row![NAT], type_row![NAT]];
Expand Down
97 changes: 54 additions & 43 deletions src/hugr/views.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

pub mod descendants;
pub mod petgraph;
pub mod render;
mod root_checked;
pub mod sibling;
pub mod sibling_subgraph;
Expand All @@ -12,19 +13,20 @@ mod tests;
use std::iter::Map;

pub use self::petgraph::PetgraphWrapper;
use self::render::RenderConfig;
pub use descendants::DescendantsGraph;
pub use root_checked::RootChecked;
pub use sibling::SiblingGraph;
pub use sibling_subgraph::SiblingSubgraph;

use context_iterators::{ContextIterator, IntoContextIterator, MapWithCtx};
use itertools::{Itertools, MapInto};
use portgraph::dot::{DotFormat, EdgeStyle, NodeStyle, PortStyle};
use portgraph::render::{DotFormat, MermaidFormat};
use portgraph::{multiportgraph, LinkView, MultiPortGraph, PortView};

use super::{Hugr, HugrError, NodeMetadata, NodeMetadataMap, NodeType, DEFAULT_NODETYPE};
use crate::ops::handle::NodeHandle;
use crate::ops::{OpName, OpParent, OpTag, OpTrait, OpType};
use crate::ops::{OpParent, OpTag, OpTrait, OpType};

use crate::types::Type;
use crate::types::{EdgeKind, FunctionType};
Expand Down Expand Up @@ -347,52 +349,61 @@ pub trait HugrView: sealed::HugrInternals {
PetgraphWrapper { hugr: self }
}

/// Return dot string showing underlying graph and hierarchy side by side.
fn dot_string(&self) -> String {
/// Return the mermaid representation of the underlying hierarchical graph.
///
/// The hierarchy is represented using subgraphs. Edges are labelled with
/// their source and target ports.
///
/// For a more detailed representation, use the [`HugrView::dot_string`]
/// format instead.
fn mermaid_string(&self) -> String
where
Self: Sized,
{
self.mermaid_string_with_config(RenderConfig {
node_indices: true,
port_offsets_in_edges: true,
type_labels_in_edges: true,
})
}

/// Return the mermaid representation of the underlying hierarchical graph.
///
/// The hierarchy is represented using subgraphs. Edges are labelled with
/// their source and target ports.
///
/// For a more detailed representation, use the [`HugrView::dot_string`]
/// format instead.
fn mermaid_string_with_config(&self, config: RenderConfig) -> String
where
Self: Sized,
{
let hugr = self.base_hugr();
let graph = self.portgraph();
graph
.mermaid_format()
.with_hierarchy(&hugr.hierarchy)
.with_node_style(render::node_style(self, config))
.with_edge_style(render::edge_style(self, config))
.finish()
}

/// Return the graphviz representation of the underlying graph and hierarchy side by side.
///
/// For a simpler representation, use the [`HugrView::mermaid_string`] format instead.
fn dot_string(&self) -> String
where
Self: Sized,
{
let hugr = self.base_hugr();
let graph = self.portgraph();
let config = RenderConfig::default();
graph
.dot_format()
.with_hierarchy(&hugr.hierarchy)
.with_node_style(|n| {
NodeStyle::Box(format!(
"({ni}) {name}",
ni = n.index(),
name = self.get_optype(n.into()).name()
))
})
.with_port_style(|port| {
let node = graph.port_node(port).unwrap();
let optype = self.get_optype(node.into());
let offset = graph.port_offset(port).unwrap();
match optype.port_kind(offset).unwrap() {
EdgeKind::Static(ty) => {
PortStyle::new(html_escape::encode_text(&format!("{}", ty)))
}
EdgeKind::Value(ty) => {
PortStyle::new(html_escape::encode_text(&format!("{}", ty)))
}
EdgeKind::StateOrder => match graph.port_links(port).count() > 0 {
true => PortStyle::text("", false),
false => PortStyle::Hidden,
},
_ => PortStyle::text("", true),
}
})
.with_edge_style(|src, tgt| {
let src_node = graph.port_node(src).unwrap();
let src_optype = self.get_optype(src_node.into());
let src_offset = graph.port_offset(src).unwrap();
let tgt_node = graph.port_node(tgt).unwrap();

if hugr.hierarchy.parent(src_node) != hugr.hierarchy.parent(tgt_node) {
EdgeStyle::Dashed
} else if src_optype.port_kind(src_offset) == Some(EdgeKind::StateOrder) {
EdgeStyle::Dotted
} else {
EdgeStyle::Solid
}
})
.with_node_style(render::node_style(self, config))
.with_port_style(render::port_style(self, config))
.with_edge_style(render::edge_style(self, config))
.finish()
}

Expand Down
120 changes: 120 additions & 0 deletions src/hugr/views/render.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
//! Helper methods to compute the node/edge/port style when rendering a HUGR
//! into dot or mermaid format.

use portgraph::render::{EdgeStyle, NodeStyle, PortStyle};
use portgraph::{LinkView, NodeIndex, PortIndex, PortView};

use crate::ops::OpName;
use crate::types::EdgeKind;
use crate::HugrView;

/// Configuration for rendering a HUGR graph.
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub struct RenderConfig {
/// Show the node index in the graph nodes.
pub node_indices: bool,
/// Show port offsets in the graph edges.
pub port_offsets_in_edges: bool,
/// Show type labels on edges.
pub type_labels_in_edges: bool,
}

impl Default for RenderConfig {
fn default() -> Self {
Self {
node_indices: true,
port_offsets_in_edges: true,
type_labels_in_edges: true,
}
}
}

/// Formatter method to compute a node style.
pub(super) fn node_style<H: HugrView>(
h: &H,
config: RenderConfig,
) -> Box<dyn FnMut(NodeIndex) -> NodeStyle + '_> {
if config.node_indices {
Box::new(move |n| {
NodeStyle::Box(format!(
"({ni}) {name}",
ni = n.index(),
name = h.get_optype(n.into()).name()
))
})
} else {
Box::new(move |n| NodeStyle::Box(h.get_optype(n.into()).name().to_string()))
}
}

/// Formatter method to compute a port style.
pub(super) fn port_style<H: HugrView>(
h: &H,
_config: RenderConfig,
) -> Box<dyn FnMut(PortIndex) -> PortStyle + '_> {
let graph = h.portgraph();
Box::new(move |port| {
let node = graph.port_node(port).unwrap();
let optype = h.get_optype(node.into());
let offset = graph.port_offset(port).unwrap();
match optype.port_kind(offset).unwrap() {
EdgeKind::Static(ty) => PortStyle::new(html_escape::encode_text(&format!("{}", ty))),
EdgeKind::Value(ty) => PortStyle::new(html_escape::encode_text(&format!("{}", ty))),
EdgeKind::StateOrder => match graph.port_links(port).count() > 0 {
true => PortStyle::text("", false),
false => PortStyle::Hidden,
},
_ => PortStyle::text("", true),
}
})
}

/// Formatter method to compute an edge style.
#[allow(clippy::type_complexity)]
pub(super) fn edge_style<H: HugrView>(
h: &H,
config: RenderConfig,
) -> Box<
dyn FnMut(
<H::Portgraph<'_> as LinkView>::LinkEndpoint,
<H::Portgraph<'_> as LinkView>::LinkEndpoint,
) -> EdgeStyle
+ '_,
> {
let graph = h.portgraph();
Box::new(move |src, tgt| {
let src_node = graph.port_node(src).unwrap();
let src_optype = h.get_optype(src_node.into());
let src_offset = graph.port_offset(src).unwrap();
let tgt_offset = graph.port_offset(tgt).unwrap();

let port_kind = src_optype.port_kind(src_offset).unwrap();

// StateOrder edges: Dotted line.
// Control flow edges: Dashed line.
// Static and Value edges: Solid line with label.
let style = match port_kind {
EdgeKind::StateOrder => EdgeStyle::Dotted,
EdgeKind::ControlFlow => EdgeStyle::Dashed,
EdgeKind::Static(_) | EdgeKind::Value(_) => EdgeStyle::Solid,
};

// Compute the label for the edge, given the setting flags.
//
// Only static and value edges have types to display.
let label = match (
config.port_offsets_in_edges,
config.type_labels_in_edges,
port_kind,
) {
(true, true, EdgeKind::Static(ty) | EdgeKind::Value(ty)) => {
format!("{}:{}\n{ty}", src_offset.index(), tgt_offset.index())
}
(true, _, _) => format!("{}:{}", src_offset.index(), tgt_offset.index()),
(false, true, EdgeKind::Static(ty) | EdgeKind::Value(ty)) => format!("{}", ty),
_ => return style,
};
style.with_label(label)
})
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
source: src/hugr/views/tests.rs
expression: h.dot_string()
---
"digraph {\n0 [shape=plain label=<<table border=\"1\"><tr><td align=\"text\" border=\"0\" colspan=\"1\">(0) CFG</td></tr></table>>]\n1 [shape=plain label=<<table border=\"1\"><tr><td port=\"in0\" align=\"text\" colspan=\"1\" cellpadding=\"1\" border=\"0\">0</td></tr><tr><td align=\"text\" border=\"0\" colspan=\"1\">(1) ExitBlock</td></tr></table>>]\n2 [shape=plain label=<<table border=\"1\"><tr><td port=\"in0\" align=\"text\" colspan=\"2\" cellpadding=\"1\" border=\"0\">0</td></tr><tr><td align=\"text\" border=\"0\" colspan=\"2\">(2) DataflowBlock</td></tr><tr><td port=\"out0\" align=\"text\" colspan=\"1\" cellpadding=\"1\" border=\"0\">0</td><td port=\"out1\" align=\"text\" colspan=\"1\" cellpadding=\"1\" border=\"0\">1</td></tr></table>>]\n2:out0 -> 7:in0 [style=\"dashed\"]\n2:out1 -> 1:in0 [style=\"dashed\"]\n3 [shape=plain label=<<table border=\"1\"><tr><td align=\"text\" border=\"0\" colspan=\"1\">(3) Input</td></tr><tr><td port=\"out0\" align=\"text\" colspan=\"1\" cellpadding=\"1\" >0: usize</td></tr></table>>]\n3:out0 -> 5:in0 [style=\"\"]\n4 [shape=plain label=<<table border=\"1\"><tr><td port=\"in0\" align=\"text\" colspan=\"1\" cellpadding=\"1\" >0: Sum([Tuple([usize]), Tuple([usize])])</td></tr><tr><td align=\"text\" border=\"0\" colspan=\"1\">(4) Output</td></tr></table>>]\n5 [shape=plain label=<<table border=\"1\"><tr><td port=\"in0\" align=\"text\" colspan=\"1\" cellpadding=\"1\" >0: usize</td></tr><tr><td align=\"text\" border=\"0\" colspan=\"1\">(5) MakeTuple</td></tr><tr><td port=\"out0\" align=\"text\" colspan=\"1\" cellpadding=\"1\" >0: Tuple([usize])</td></tr></table>>]\n5:out0 -> 6:in0 [style=\"\"]\n6 [shape=plain label=<<table border=\"1\"><tr><td port=\"in0\" align=\"text\" colspan=\"1\" cellpadding=\"1\" >0: Tuple([usize])</td></tr><tr><td align=\"text\" border=\"0\" colspan=\"1\">(6) Tag</td></tr><tr><td port=\"out0\" align=\"text\" colspan=\"1\" cellpadding=\"1\" >0: Sum([Tuple([usize]), Tuple([usize])])</td></tr></table>>]\n6:out0 -> 4:in0 [style=\"\"]\n7 [shape=plain label=<<table border=\"1\"><tr><td port=\"in0\" align=\"text\" colspan=\"1\" cellpadding=\"1\" border=\"0\">0</td></tr><tr><td align=\"text\" border=\"0\" colspan=\"1\">(7) DataflowBlock</td></tr><tr><td port=\"out0\" align=\"text\" colspan=\"1\" cellpadding=\"1\" border=\"0\">0</td></tr></table>>]\n7:out0 -> 1:in0 [style=\"dashed\"]\n8 [shape=plain label=<<table border=\"1\"><tr><td align=\"text\" border=\"0\" colspan=\"1\">(8) Input</td></tr><tr><td port=\"out0\" align=\"text\" colspan=\"1\" cellpadding=\"1\" >0: usize</td></tr></table>>]\n8:out0 -> 9:in1 [style=\"\"]\n9 [shape=plain label=<<table border=\"1\"><tr><td port=\"in0\" align=\"text\" colspan=\"1\" cellpadding=\"1\" >0: Sum(UnitSum(1))</td><td port=\"in1\" align=\"text\" colspan=\"1\" cellpadding=\"1\" >1: usize</td></tr><tr><td align=\"text\" border=\"0\" colspan=\"2\">(9) Output</td></tr></table>>]\n10 [shape=plain label=<<table border=\"1\"><tr><td align=\"text\" border=\"0\" colspan=\"1\">(10) const:sum:{tag:0, val:const:seq:{}}</td></tr><tr><td port=\"out0\" align=\"text\" colspan=\"1\" cellpadding=\"1\" >0: Sum(UnitSum(1))</td></tr></table>>]\n10:out0 -> 11:in0 [style=\"\"]\n11 [shape=plain label=<<table border=\"1\"><tr><td port=\"in0\" align=\"text\" colspan=\"1\" cellpadding=\"1\" >0: Sum(UnitSum(1))</td></tr><tr><td align=\"text\" border=\"0\" colspan=\"1\">(11) LoadConstant</td></tr><tr><td port=\"out0\" align=\"text\" colspan=\"1\" cellpadding=\"1\" >0: Sum(UnitSum(1))</td></tr></table>>]\n11:out0 -> 9:in0 [style=\"\"]\nhier0 [shape=plain label=\"0\"]\nhier0 -> hier2 [style = \"dashed\"] \nhier0 -> hier1 [style = \"dashed\"] \nhier0 -> hier7 [style = \"dashed\"] \nhier1 [shape=plain label=\"1\"]\nhier2 [shape=plain label=\"2\"]\nhier2 -> hier3 [style = \"dashed\"] \nhier2 -> hier4 [style = \"dashed\"] \nhier2 -> hier5 [style = \"dashed\"] \nhier2 -> hier6 [style = \"dashed\"] \nhier3 [shape=plain label=\"3\"]\nhier4 [shape=plain label=\"4\"]\nhier5 [shape=plain label=\"5\"]\nhier6 [shape=plain label=\"6\"]\nhier7 [shape=plain label=\"7\"]\nhier7 -> hier8 [style = \"dashed\"] \nhier7 -> hier9 [style = \"dashed\"] \nhier7 -> hier10 [style = \"dashed\"] \nhier7 -> hier11 [style = \"dashed\"] \nhier8 [shape=plain label=\"8\"]\nhier9 [shape=plain label=\"9\"]\nhier10 [shape=plain label=\"10\"]\nhier11 [shape=plain label=\"11\"]\n}\n"
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,3 @@ source: src/hugr/views/tests.rs
expression: h.dot_string()
---
"digraph {\n0 [shape=plain label=<<table border=\"1\"><tr><td align=\"text\" border=\"0\" colspan=\"1\">(0) DFG</td></tr></table>>]\n1 [shape=plain label=<<table border=\"1\"><tr><td align=\"text\" border=\"0\" colspan=\"2\">(1) Input</td></tr><tr><td port=\"out0\" align=\"text\" colspan=\"1\" cellpadding=\"1\" >0: qubit</td><td port=\"out1\" align=\"text\" colspan=\"1\" cellpadding=\"1\" >1: qubit</td></tr></table>>]\n1:out0 -> 3:in0 [style=\"\"]\n1:out1 -> 3:in1 [style=\"\"]\n2 [shape=plain label=<<table border=\"1\"><tr><td port=\"in0\" align=\"text\" colspan=\"1\" cellpadding=\"1\" >0: qubit</td><td port=\"in1\" align=\"text\" colspan=\"1\" cellpadding=\"1\" >1: qubit</td></tr><tr><td align=\"text\" border=\"0\" colspan=\"2\">(2) Output</td></tr></table>>]\n3 [shape=plain label=<<table border=\"1\"><tr><td port=\"in0\" align=\"text\" colspan=\"3\" cellpadding=\"1\" >0: qubit</td><td port=\"in1\" align=\"text\" colspan=\"3\" cellpadding=\"1\" >1: qubit</td></tr><tr><td align=\"text\" border=\"0\" colspan=\"6\">(3) test.quantum.CX</td></tr><tr><td port=\"out0\" align=\"text\" colspan=\"2\" cellpadding=\"1\" >0: qubit</td><td port=\"out1\" align=\"text\" colspan=\"2\" cellpadding=\"1\" >1: qubit</td><td port=\"out2\" align=\"text\" colspan=\"2\" cellpadding=\"1\" border=\"0\"></td></tr></table>>]\n3:out0 -> 4:in1 [style=\"\"]\n3:out1 -> 4:in0 [style=\"\"]\n3:out2 -> 4:in2 [style=\"dotted\"]\n4 [shape=plain label=<<table border=\"1\"><tr><td port=\"in0\" align=\"text\" colspan=\"2\" cellpadding=\"1\" >0: qubit</td><td port=\"in1\" align=\"text\" colspan=\"2\" cellpadding=\"1\" >1: qubit</td><td port=\"in2\" align=\"text\" colspan=\"2\" cellpadding=\"1\" border=\"0\"></td></tr><tr><td align=\"text\" border=\"0\" colspan=\"6\">(4) test.quantum.CX</td></tr><tr><td port=\"out0\" align=\"text\" colspan=\"3\" cellpadding=\"1\" >0: qubit</td><td port=\"out1\" align=\"text\" colspan=\"3\" cellpadding=\"1\" >1: qubit</td></tr></table>>]\n4:out0 -> 2:in0 [style=\"\"]\n4:out1 -> 2:in1 [style=\"\"]\nhier0 [shape=plain label=\"0\"]\nhier0 -> hier1 [style = \"dashed\"] \nhier0 -> hier2 [style = \"dashed\"] \nhier0 -> hier3 [style = \"dashed\"] \nhier0 -> hier4 [style = \"dashed\"] \nhier1 [shape=plain label=\"1\"]\nhier2 [shape=plain label=\"2\"]\nhier3 [shape=plain label=\"3\"]\nhier4 [shape=plain label=\"4\"]\n}\n"

Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
source: src/hugr/views/tests.rs
expression: h.dot_string()
---
"digraph {\n0 [shape=plain label=<<table border=\"1\"><tr><td align=\"text\" border=\"0\" colspan=\"1\">(0) DFG</td></tr></table>>]\n1 [shape=plain label=<<table border=\"1\"><tr><td align=\"text\" border=\"0\" colspan=\"1\">(1) Input</td></tr><tr><td port=\"out0\" align=\"text\" colspan=\"1\" cellpadding=\"1\" >0: Sum(UnitSum(2))</td></tr></table>>]\n1:out0 -> 2:in0 [style=\"\"]\n2 [shape=plain label=<<table border=\"1\"><tr><td port=\"in0\" align=\"text\" colspan=\"1\" cellpadding=\"1\" >0: Sum(UnitSum(2))</td></tr><tr><td align=\"text\" border=\"0\" colspan=\"1\">(2) Output</td></tr></table>>]\nhier0 [shape=plain label=\"0\"]\nhier0 -> hier1 [style = \"dashed\"] \nhier0 -> hier2 [style = \"dashed\"] \nhier1 [shape=plain label=\"1\"]\nhier2 [shape=plain label=\"2\"]\n}\n"
32 changes: 32 additions & 0 deletions src/hugr/views/snapshots/hugr__hugr__views__tests__mmd_cfg.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
source: src/hugr/views/tests.rs
expression: h.mermaid_string()
---
graph LR
subgraph 0 ["(0) CFG"]
direction LR
subgraph 2 ["(2) DataflowBlock"]
direction LR
3["(3) Input"]
3--"0:0<br>usize"-->5
4["(4) Output"]
5["(5) MakeTuple"]
5--"0:0<br>Tuple([usize])"-->6
6["(6) Tag"]
6--"0:0<br>Sum([Tuple([usize]), Tuple([usize])])"-->4
end
2-."0:0".->7
2-."1:0".->1
1["(1) ExitBlock"]
subgraph 7 ["(7) DataflowBlock"]
direction LR
8["(8) Input"]
8--"0:1<br>usize"-->9
9["(9) Output"]
10["(10) const:sum:{tag:0, val:const:seq:{}}"]
10--"0:0<br>Sum(UnitSum(1))"-->11
11["(11) LoadConstant"]
11--"0:0<br>Sum(UnitSum(1))"-->9
end
7-."0:0".->1
end
Loading

0 comments on commit 3fb833a

Please sign in to comment.