Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/add mermaid graph style #586

Open
wants to merge 7 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ Some useful extensions are also provided:
* Parameterised triggers
* Reentrant states
* Export to DOT graph
* Export to mermaid graph

### Hierarchical States

Expand Down Expand Up @@ -206,6 +207,33 @@ digraph {
This can then be rendered by tools that support the DOT graph language, such as the [dot command line tool](http://www.graphviz.org/doc/info/command.html) from [graphviz.org](http://www.graphviz.org) or [viz.js](https://github.com/mdaines/viz.js). See http://www.webgraphviz.com for instant gratification.
Command line example: `dot -T pdf -o phoneCall.pdf phoneCall.dot` to generate a PDF file.

### Export to mermaid graph

It can be useful to visualize state machines on runtime. With this approach the code is the authoritative source and state diagrams are by-products which are always up to date.

```csharp
phoneCall.Configure(State.OffHook)
.PermitIf(Trigger.CallDialled, State.Ringing);

string graph = MermaidGraph.Format(phoneCall.GetInfo());
```

The `MermaidGraph.Format()` method returns a string representation of the state machine in the [Mermaid](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/creating-diagrams#creating-mermaid-diagrams), e.g.:

```
stateDiagram-v2
[*] --> OffHook
OffHook --> Ringing : CallDialled
```

This can then be rendered by GitHub or [Obsidian](https://github.com/obsidianmd)

``` mermaid
stateDiagram-v2
[*] --> OffHook
OffHook --> Ringing : CallDialled
```

### Async triggers

On platforms that provide `Task<T>`, the `StateMachine` supports `async` entry/exit actions and so on:
Expand Down
26 changes: 26 additions & 0 deletions src/Stateless/Graph/MermaidGraph.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using Stateless.Reflection;
using System.Collections;

namespace Stateless.Graph
{
/// <summary>
/// Class to generate a MermaidGraph
/// </summary>
public static class MermaidGraph
{
/// <summary>
/// Generate a Mermaid graph from the state machine info
/// </summary>
/// <param name="machineInfo"></param>
/// <param name="direction">
/// When set, includes a <c>direction</c> setting in the output indicating the direction of flow.
/// </param>
/// <returns></returns>
public static string Format(StateMachineInfo machineInfo, MermaidGraphDirection? direction = null)
{
var graph = new StateGraph(machineInfo);

return graph.ToGraph(new MermaidGraphStyle(graph, direction));
}
}
}
17 changes: 17 additions & 0 deletions src/Stateless/Graph/MermaidGraphDirection.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace Stateless.Graph
{
/// <summary>
/// The directions of flow that can be chosen for a Mermaid graph.
/// </summary>
public enum MermaidGraphDirection
{
/// <summary>Left-to-right flow</summary>
LeftToRight,
/// <summary>Right-to-left flow</summary>
RightToLeft,
/// <summary>Top-to-bottom flow</summary>
TopToBottom,
/// <summary>Bottom-to-top flow</summary>
BottomToTop
}
}
173 changes: 173 additions & 0 deletions src/Stateless/Graph/MermaidGraphStyle.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
using Stateless.Reflection;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

namespace Stateless.Graph
{
/// <summary>
/// Class to generate a graph in mermaid format
/// </summary>
public class MermaidGraphStyle : GraphStyleBase
{
private readonly StateGraph _graph;
private readonly MermaidGraphDirection? _direction;
private readonly Dictionary<string, State> _stateMap = new Dictionary<string, State>();
private bool _stateMapInitialized = false;

/// <summary>
/// Create a new instance of <see cref="MermaidGraphStyle"/>
/// </summary>
/// <param name="graph">The state graph</param>
/// <param name="direction">When non-null, sets the flow direction in the output.</param>
public MermaidGraphStyle(StateGraph graph, MermaidGraphDirection? direction)
: base()
{
_graph = graph;
_direction = direction;
}

/// <inheritdoc/>
public override string FormatOneCluster(SuperState stateInfo)
{
StringBuilder sb = new StringBuilder();
sb.AppendLine();
sb.AppendLine($"\tstate {GetSanitizedStateName(stateInfo.StateName)} {{");
foreach (var subState in stateInfo.SubStates)
{
sb.AppendLine($"\t\t{GetSanitizedStateName(subState.StateName)}");
}

sb.Append("\t}");

return sb.ToString();
}

/// <summary>
/// Generate the text for a single decision node
/// </summary>
/// <param name="nodeName">Name of the node</param>
/// <param name="label">Label for the node</param>
/// <returns></returns>
public override string FormatOneDecisionNode(string nodeName, string label)
{
return $"{Environment.NewLine}\tstate {nodeName} <<choice>>";
}

/// <inheritdoc/>
public override string FormatOneState(State state)
{
return string.Empty;
}

/// <summary>Get the text that starts a new graph</summary>
/// <returns></returns>
public override string GetPrefix()
{
BuildSanitizedNamedStateMap();
string prefix = "stateDiagram-v2";
if (_direction.HasValue)
{
prefix += $"{Environment.NewLine}\tdirection {GetDirectionCode(_direction.Value)}";
}

foreach (var state in _stateMap.Where(x => !x.Key.Equals(x.Value.StateName, StringComparison.Ordinal)))
{
prefix += $"{Environment.NewLine}\t{state.Key} : {state.Value.StateName}";
}

return prefix;
}

/// <inheritdoc/>
public override string GetInitialTransition(StateInfo initialState)
{
var sanitizedStateName = GetSanitizedStateName(initialState.ToString());

return $"{Environment.NewLine}[*] --> {sanitizedStateName}";
}

/// <inheritdoc/>
public override string FormatOneTransition(string sourceNodeName, string trigger, IEnumerable<string> actions, string destinationNodeName, IEnumerable<string> guards)
{
string label = trigger ?? "";

if (actions?.Count() > 0)
label += " / " + string.Join(", ", actions);

if (guards.Any())
{
foreach (var info in guards)
{
if (label.Length > 0)
label += " ";
label += "[" + info + "]";
}
}

var sanitizedSourceNodeName = GetSanitizedStateName(sourceNodeName);
var sanitizedDestinationNodeName = GetSanitizedStateName(destinationNodeName);

return FormatOneLine(sanitizedSourceNodeName, sanitizedDestinationNodeName, label);
}

internal string FormatOneLine(string fromNodeName, string toNodeName, string label)
{
return $"\t{fromNodeName} --> {toNodeName} : {label}";
}

private static string GetDirectionCode(MermaidGraphDirection direction)
{
switch(direction)
{
case MermaidGraphDirection.TopToBottom:
return "TB";
case MermaidGraphDirection.BottomToTop:
return "BT";
case MermaidGraphDirection.LeftToRight:
return "LR";
case MermaidGraphDirection.RightToLeft:
return "RL";
default:
throw new ArgumentOutOfRangeException(nameof(direction), direction, $"Unsupported {nameof(MermaidGraphDirection)}: {direction}.");
}
}

private void BuildSanitizedNamedStateMap()
{
if (_stateMapInitialized)
{
return;
}

// Ensures that state names are unique and do not contain characters that would cause an invalid Mermaid graph.
var uniqueAliases = new HashSet<string>();
foreach (var state in _graph.States)
{
var sanitizedStateName = string.Concat(state.Value.StateName.Where(c => !(char.IsWhiteSpace(c) || c == ':' || c == '-')));
if (!sanitizedStateName.Equals(state.Value.StateName, StringComparison.Ordinal))
{
int count = 1;
var tempName = sanitizedStateName;
while (uniqueAliases.Contains(tempName) || _graph.States.ContainsKey(tempName))
{
tempName = $"{sanitizedStateName}_{count++}";
}

sanitizedStateName = tempName;
uniqueAliases.Add(sanitizedStateName);
}

_stateMap[sanitizedStateName] = state.Value;
}

_stateMapInitialized = true;
}

private string GetSanitizedStateName(string stateName)
{
return _stateMap.FirstOrDefault(x => x.Value.StateName == stateName).Key ?? stateName;
}
}
}
9 changes: 4 additions & 5 deletions src/Stateless/Graph/StateGraph.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,27 +58,26 @@ public StateGraph(StateMachineInfo machineInfo)
/// <returns></returns>
public string ToGraph(GraphStyleBase style)
{
string dirgraphText = style.GetPrefix().Replace("\n", System.Environment.NewLine);
string dirgraphText = style.GetPrefix();

// Start with the clusters
foreach (var state in States.Values.Where(x => x is SuperState))
{
dirgraphText += style.FormatOneCluster((SuperState)state).Replace("\n", System.Environment.NewLine);
dirgraphText += style.FormatOneCluster((SuperState)state);
}

// Next process all non-cluster states
foreach (var state in States.Values)
{
if (state is SuperState || state is Decision || state.SuperState != null)
continue;
dirgraphText += style.FormatOneState(state).Replace("\n", System.Environment.NewLine);
dirgraphText += style.FormatOneState(state);
}

// Finally, add decision nodes
foreach (var dec in Decisions)
{
dirgraphText += style.FormatOneDecisionNode(dec.NodeName, dec.Method.Description)
.Replace("\n", System.Environment.NewLine);
dirgraphText += style.FormatOneDecisionNode(dec.NodeName, dec.Method.Description);
}

// now build behaviours
Expand Down
38 changes: 21 additions & 17 deletions src/Stateless/Graph/UmlDotGraphStyle.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,13 @@ public class UmlDotGraphStyle : GraphStyleBase
/// <returns>The prefix for the DOT graph document.</returns>
public override string GetPrefix()
{
return "digraph {\n"
+ "compound=true;\n"
+ "node [shape=Mrecord]\n"
+ "rankdir=\"LR\"\n";
var sb = new StringBuilder();
sb.AppendLine("digraph {")
.AppendLine("compound=true;")
.AppendLine("node [shape=Mrecord]")
.AppendLine("rankdir=\"LR\"");

return sb.ToString();
}

/// <summary>
Expand All @@ -28,30 +31,31 @@ public override string GetPrefix()
/// <inheritdoc/>
public override string FormatOneCluster(SuperState stateInfo)
{
string stateRepresentationString = "";
var sb = new StringBuilder();
var sourceName = stateInfo.StateName;

StringBuilder label = new StringBuilder($"{EscapeLabel(stateInfo.StateName)}");

if (stateInfo.EntryActions.Count > 0 || stateInfo.ExitActions.Count > 0)
if (stateInfo.EntryActions.Any() || stateInfo.ExitActions.Any())
{
label.Append("\\n----------");
label.Append(string.Concat(stateInfo.EntryActions.Select(act => "\\nentry / " + EscapeLabel(act))));
label.Append(string.Concat(stateInfo.ExitActions.Select(act => "\\nexit / " + EscapeLabel(act))));
}

stateRepresentationString = "\n"
+ $"subgraph \"cluster{EscapeLabel(stateInfo.NodeName)}\"" + "\n"
+ "\t{" + "\n"
+ $"\tlabel = \"{label}\"" + "\n";
sb.AppendLine()
.AppendLine($"subgraph \"cluster{EscapeLabel(stateInfo.NodeName)}\"")
.AppendLine("\t{")
.AppendLine($"\tlabel = \"{label.ToString()}\"");

foreach (var subState in stateInfo.SubStates)
{
stateRepresentationString += FormatOneState(subState);
sb.Append(FormatOneState(subState));
}

stateRepresentationString += "}\n";
sb.AppendLine("}");

return stateRepresentationString;
return sb.ToString();
}

/// <summary>
Expand All @@ -64,7 +68,7 @@ public override string FormatOneState(State state)
var escapedStateName = EscapeLabel(state.StateName);

if (state.EntryActions.Count == 0 && state.ExitActions.Count == 0)
return $"\"{escapedStateName}\" [label=\"{escapedStateName}\"];\n";
return $"\"{escapedStateName}\" [label=\"{escapedStateName}\"];{Environment.NewLine}";

string f = $"\"{escapedStateName}\" [label=\"{escapedStateName}|";

Expand All @@ -74,7 +78,7 @@ public override string FormatOneState(State state)

f += string.Join("\\n", es);

f += "\"];\n";
f += $"\"];{Environment.NewLine}";

return f;
}
Expand Down Expand Up @@ -105,13 +109,13 @@ public override string FormatOneTransition(string sourceNodeName, string trigger
}

/// <summary>
/// Generate the text for a single decision node.
/// Generate the text for a single decision node
/// </summary>
/// <returns>A DOT graph representation of the decision node for a dynamic transition.</returns>
/// <inheritdoc/>
public override string FormatOneDecisionNode(string nodeName, string label)
{
return $"\"{EscapeLabel(nodeName)}\" [shape = \"diamond\", label = \"{EscapeLabel(label)}\"];\n";
return $"\"{EscapeLabel(nodeName)}\" [shape = \"diamond\", label = \"{EscapeLabel(label)}\"];{Environment.NewLine}";
}

/// <summary>
Expand Down
2 changes: 1 addition & 1 deletion test/Stateless.Tests/DotGraphFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,7 @@ public void DestinationStateIsCalculatedBasedOnTriggerParametersAsync()
var sm = new StateMachine<State, Trigger>(State.A);
var trigger = sm.SetTriggerParameters<int>(Trigger.X);
sm.Configure(State.A)
.PermitDynamicAsync(trigger, i =>Task.FromResult(i == 1 ? State.B : State.C));
.PermitDynamicAsync(trigger, i => Task.FromResult(i == 1 ? State.B : State.C));

string dotGraph = UmlDotGraph.Format(sm.GetInfo());

Expand Down
Loading