Skip to content

Commit

Permalink
Mermaid graph features: graph direction; support state names with spa…
Browse files Browse the repository at this point in the history
…ces; support substates.
  • Loading branch information
mclift committed Jun 21, 2024
1 parent d6131a6 commit 3760d58
Show file tree
Hide file tree
Showing 7 changed files with 589 additions and 121 deletions.
9 changes: 6 additions & 3 deletions src/Stateless/Graph/MermaidGraph.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Stateless.Reflection;
using System.Collections;

namespace Stateless.Graph
{
Expand All @@ -11,13 +12,15 @@ public static class MermaidGraph
/// 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)
public static string Format(StateMachineInfo machineInfo, MermaidGraphDirection? direction = null)
{
var graph = new StateGraph(machineInfo);

return graph.ToGraph(new MermaidGraphStyle());
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
}
}
152 changes: 119 additions & 33 deletions src/Stateless/Graph/MermaidGraphStyle.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
using Stateless.Reflection;
using System;
using System.Collections.Generic;
using System.Reflection.Emit;
using System.Linq;
using System.Text;

namespace Stateless.Graph
Expand All @@ -11,16 +11,37 @@ namespace Stateless.Graph
/// </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>
/// Returns the formatted text for a single superstate and its substates.
/// For example, for DOT files this would be a subgraph containing nodes for all the substates.
/// Create a new instance of <see cref="MermaidGraphStyle"/>
/// </summary>
/// <param name="stateInfo">The superstate to generate text for</param>
/// <returns>Description of the superstate, and all its substates, in the desired format</returns>
/// <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)
{
string stateRepresentationString = "";
return stateRepresentationString;
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>
Expand All @@ -31,57 +52,122 @@ public override string FormatOneCluster(SuperState stateInfo)
/// <returns></returns>
public override string FormatOneDecisionNode(string nodeName, string label)
{
return String.Empty;
return $"{Environment.NewLine}\tstate {nodeName} <<choice>>";
}

/// <summary>
/// Generate the text for a single state
/// </summary>
/// <param name="state">The state to generate text for</param>
/// <returns></returns>
/// <inheritdoc/>
public override string FormatOneState(State state)
{
return String.Empty;
return string.Empty;
}

/// <summary>Get the text that starts a new graph</summary>
/// <returns></returns>
public override string GetPrefix()
{
return "stateDiagram-v2";
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;
}

/// <summary>
///
/// </summary>
/// <param name="initialState"></param>
/// <returns></returns>
/// <inheritdoc/>
public override string GetInitialTransition(StateInfo initialState)
{
return $"\r\n[*] --> {initialState}";
}
var sanitizedStateName = GetSanitizedStateName(initialState.ToString());


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

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

return FormatOneLine(sourceNodeName, destinationNodeName, label);
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
Loading

0 comments on commit 3760d58

Please sign in to comment.