Skip to content

Commit

Permalink
Merge pull request dotnet-state-machine#586 from dotnet-state-machine…
Browse files Browse the repository at this point in the history
…/feature/add-mermaid-graph-style

Feature/add mermaid graph style
  • Loading branch information
mclift authored Dec 24, 2024
2 parents a128944 + fcf2b75 commit 95096e5
Show file tree
Hide file tree
Showing 8 changed files with 676 additions and 23 deletions.
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

0 comments on commit 95096e5

Please sign in to comment.