Skip to content

Commit e6a2fd7

Browse files
Copilotjongalloway
andcommitted
feat: Add Mermaid state diagram (stateDiagram-v2) support (Issue #13)
Co-authored-by: jongalloway <68539+jongalloway@users.noreply.github.com>
1 parent 4268cde commit e6a2fd7

7 files changed

Lines changed: 294 additions & 3 deletions

File tree

src/DiagramForge/Parsers/Mermaid/MermaidDiagramKind.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ internal enum MermaidDiagramKind
55
Unknown,
66
Flowchart,
77
Mindmap,
8+
StateDiagram,
89
}

src/DiagramForge/Parsers/Mermaid/MermaidDocument.cs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,6 @@ public static bool TryParse(string diagramText, [System.Diagnostics.CodeAnalysis
5757
{
5858
"sequencediagram",
5959
"classdiagram",
60-
"statediagram",
61-
"statediagram-v2",
6260
"erdiagram",
6361
"journey",
6462
"gantt",
@@ -113,6 +111,13 @@ private static bool TryDetectKind(string headerLine, out MermaidDiagramKind kind
113111
return true;
114112
}
115113

114+
if (normalizedHeader.Equals("statediagram", StringComparison.Ordinal)
115+
|| normalizedHeader.Equals("statediagram-v2", StringComparison.Ordinal))
116+
{
117+
kind = MermaidDiagramKind.StateDiagram;
118+
return true;
119+
}
120+
116121
var spaceIndex = normalizedHeader.IndexOf(' ');
117122
var keyword = spaceIndex >= 0 ? normalizedHeader[..spaceIndex] : normalizedHeader;
118123
if (KnownUnsupportedMermaidKeywords.Contains(keyword))

src/DiagramForge/Parsers/Mermaid/MermaidParser.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ namespace DiagramForge.Parsers.Mermaid;
1111
/// <list type="bullet">
1212
/// <item>Flowchart (LR, RL, TB, BT, TD)</item>
1313
/// <item>Mindmap</item>
14+
/// <item>State diagram (stateDiagram / stateDiagram-v2)</item>
1415
/// </list>
1516
/// </remarks>
1617
public sealed class MermaidParser : IDiagramParser
@@ -19,9 +20,10 @@ public sealed class MermaidParser : IDiagramParser
1920
[
2021
new MermaidFlowchartParser(),
2122
new MermaidMindmapParser(),
23+
new MermaidStateParser(),
2224
];
2325

24-
private static readonly string[] SupportedDiagramTypes = ["flowchart", "mindmap"];
26+
private static readonly string[] SupportedDiagramTypes = ["flowchart", "mindmap", "statediagram"];
2527

2628
public string SyntaxId => "mermaid";
2729

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
using DiagramForge.Abstractions;
2+
using DiagramForge.Models;
3+
4+
namespace DiagramForge.Parsers.Mermaid;
5+
6+
internal sealed class MermaidStateParser : IMermaidDiagramParser
7+
{
8+
// Synthetic IDs for the [*] terminal markers.
9+
internal const string StartNodeId = "__start__";
10+
internal const string EndNodeId = "__end__";
11+
12+
private static readonly string[] EdgeOperators = ["--->", "-->>", "-.->", "==>", "===", "-->", "-.-", "---"];
13+
14+
public bool CanParse(MermaidDiagramKind kind) => kind == MermaidDiagramKind.StateDiagram;
15+
16+
public Diagram Parse(MermaidDocument document)
17+
{
18+
var builder = new DiagramSemanticModelBuilder()
19+
.WithSourceSyntax("mermaid")
20+
.WithDiagramType("statediagram");
21+
22+
builder.WithLayoutHints(new LayoutHints { Direction = LayoutDirection.TopToBottom });
23+
24+
var nodesSeen = new Dictionary<string, Node>(StringComparer.Ordinal);
25+
26+
Node GetOrCreateNode(string id, string label)
27+
{
28+
if (!nodesSeen.TryGetValue(id, out var node))
29+
{
30+
var shape = (id == StartNodeId || id == EndNodeId) ? Shape.Circle : Shape.Rectangle;
31+
node = new Node(id, label) { Shape = shape };
32+
nodesSeen[id] = node;
33+
builder.AddNode(node);
34+
}
35+
36+
return node;
37+
}
38+
39+
for (int i = 1; i < document.Lines.Length; i++)
40+
{
41+
var line = document.Lines[i];
42+
ParseLine(line, builder, GetOrCreateNode);
43+
}
44+
45+
return builder.Build();
46+
}
47+
48+
private static void ParseLine(
49+
string line,
50+
IDiagramSemanticModelBuilder builder,
51+
Func<string, string, Node> getOrCreate)
52+
{
53+
var (matchedOp, opIndex) = FindEdgeOperator(line);
54+
55+
if (matchedOp is not null)
56+
{
57+
var left = line[..opIndex].Trim();
58+
var right = line[(opIndex + matchedOp.Length)..].Trim();
59+
60+
// State diagrams use `: label` suffix on the right side for transition labels.
61+
string? edgeLabel = null;
62+
int colonIdx = right.IndexOf(" : ", StringComparison.Ordinal);
63+
if (colonIdx >= 0)
64+
{
65+
edgeLabel = right[(colonIdx + 3)..].Trim();
66+
right = right[..colonIdx].Trim();
67+
}
68+
69+
var srcId = ResolveTerminalId(left, isSource: true);
70+
var tgtId = ResolveTerminalId(right, isSource: false);
71+
72+
var srcLabel = srcId == StartNodeId ? "[*]" : srcId;
73+
var tgtLabel = tgtId == EndNodeId ? "[*]" : tgtId;
74+
75+
getOrCreate(srcId, srcLabel);
76+
getOrCreate(tgtId, tgtLabel);
77+
78+
var edge = new Edge(srcId, tgtId);
79+
if (edgeLabel is not null)
80+
edge.Label = new Label(edgeLabel);
81+
82+
edge.LineStyle = matchedOp.Contains('=') ? EdgeLineStyle.Thick
83+
: matchedOp.Contains('.') ? EdgeLineStyle.Dotted
84+
: EdgeLineStyle.Solid;
85+
edge.ArrowHead = matchedOp.Contains('>') ? ArrowHeadStyle.Arrow : ArrowHeadStyle.None;
86+
87+
builder.AddEdge(edge);
88+
return;
89+
}
90+
91+
// State definition: `id` or `id : Label`
92+
int defColon = line.IndexOf(" : ", StringComparison.Ordinal);
93+
if (defColon >= 0)
94+
{
95+
var id = line[..defColon].Trim();
96+
var label = line[(defColon + 3)..].Trim();
97+
if (!string.IsNullOrEmpty(id))
98+
getOrCreate(id, label);
99+
}
100+
else if (!string.IsNullOrWhiteSpace(line))
101+
{
102+
var id = line.Trim();
103+
getOrCreate(id, id);
104+
}
105+
}
106+
107+
/// <summary>
108+
/// Finds the earliest (and longest, on a tie) edge operator in <paramref name="line"/>.
109+
/// </summary>
110+
private static (string? op, int index) FindEdgeOperator(string line)
111+
{
112+
string? matchedOp = null;
113+
int opIndex = -1;
114+
115+
foreach (var op in EdgeOperators)
116+
{
117+
int idx = line.IndexOf(op, StringComparison.Ordinal);
118+
if (idx >= 0 && (opIndex < 0 || idx < opIndex || (idx == opIndex && op.Length > matchedOp!.Length)))
119+
{
120+
opIndex = idx;
121+
matchedOp = op;
122+
}
123+
}
124+
125+
return (matchedOp, opIndex);
126+
}
127+
128+
/// <summary>
129+
/// Maps the literal <c>[*]</c> token to its synthetic node ID based on whether
130+
/// it appears as a transition source (<c>__start__</c>) or target (<c>__end__</c>).
131+
/// </summary>
132+
private static string ResolveTerminalId(string token, bool isSource) =>
133+
token == "[*]"
134+
? (isSource ? StartNodeId : EndNodeId)
135+
: token;
136+
}
Lines changed: 30 additions & 0 deletions
Loading
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
stateDiagram-v2
2+
[*] --> Idle
3+
Idle --> Running : start
4+
Running --> Idle : stop
5+
Running --> [*]

tests/DiagramForge.Tests/Parsers/MermaidParserTests.cs

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,4 +548,116 @@ public void Parse_Mindmap_NodeCount_MatchesTreeSize()
548548
Assert.Equal(6, diagram.Nodes.Count);
549549
Assert.Equal(5, diagram.Edges.Count);
550550
}
551+
552+
// ── State diagram: CanParse ───────────────────────────────────────────────
553+
554+
[Theory]
555+
[InlineData("stateDiagram-v2\n [*] --> Idle")]
556+
[InlineData("stateDiagram\n [*] --> Idle")]
557+
public void CanParse_ReturnsTrue_ForStateDiagram(string text)
558+
{
559+
Assert.True(_parser.CanParse(text));
560+
}
561+
562+
// ── State diagram: Parse ──────────────────────────────────────────────────
563+
564+
[Fact]
565+
public void Parse_StateDiagram_DiagramTypeIsStateDiagram()
566+
{
567+
var diagram = _parser.Parse("stateDiagram-v2\n [*] --> Idle");
568+
569+
Assert.Equal("statediagram", diagram.DiagramType);
570+
Assert.Equal("mermaid", diagram.SourceSyntax);
571+
}
572+
573+
[Fact]
574+
public void Parse_StateDiagram_SimpleTransition_ProducesNodesAndEdge()
575+
{
576+
var diagram = _parser.Parse("stateDiagram-v2\n Idle --> Running");
577+
578+
Assert.True(diagram.Nodes.ContainsKey("Idle"));
579+
Assert.True(diagram.Nodes.ContainsKey("Running"));
580+
Assert.Single(diagram.Edges);
581+
Assert.Equal("Idle", diagram.Edges[0].SourceId);
582+
Assert.Equal("Running", diagram.Edges[0].TargetId);
583+
}
584+
585+
[Fact]
586+
public void Parse_StateDiagram_TransitionLabel_IsAttachedToEdge()
587+
{
588+
var diagram = _parser.Parse("stateDiagram-v2\n Idle --> Running : start");
589+
590+
Assert.Single(diagram.Edges);
591+
Assert.Equal("start", diagram.Edges[0].Label?.Text);
592+
}
593+
594+
[Fact]
595+
public void Parse_StateDiagram_StartTerminal_ProducesDistinctStartNode()
596+
{
597+
var diagram = _parser.Parse("stateDiagram-v2\n [*] --> Idle\n Idle --> [*]");
598+
599+
// [*] on the left → __start__, [*] on the right → __end__
600+
Assert.True(diagram.Nodes.ContainsKey("__start__"));
601+
Assert.True(diagram.Nodes.ContainsKey("__end__"));
602+
}
603+
604+
[Fact]
605+
public void Parse_StateDiagram_StartAndEndTerminals_AreDistinctNodes()
606+
{
607+
// [*] on both sides of the diagram must produce two distinct terminal nodes.
608+
const string text = """
609+
stateDiagram-v2
610+
[*] --> Idle
611+
Idle --> Running : start
612+
Running --> Idle : stop
613+
Running --> [*]
614+
""";
615+
616+
var diagram = _parser.Parse(text);
617+
618+
Assert.True(diagram.Nodes.ContainsKey("__start__"));
619+
Assert.True(diagram.Nodes.ContainsKey("__end__"));
620+
Assert.False(diagram.Nodes.ContainsKey("[*]"));
621+
}
622+
623+
[Fact]
624+
public void Parse_StateDiagram_TerminalNodes_HaveCircleShape()
625+
{
626+
var diagram = _parser.Parse("stateDiagram-v2\n [*] --> Idle\n Idle --> [*]");
627+
628+
Assert.Equal(Shape.Circle, diagram.Nodes["__start__"].Shape);
629+
Assert.Equal(Shape.Circle, diagram.Nodes["__end__"].Shape);
630+
}
631+
632+
[Fact]
633+
public void Parse_StateDiagram_StateDefinition_SetsLabel()
634+
{
635+
const string text = """
636+
stateDiagram-v2
637+
s1 : My State
638+
s1 --> s2
639+
""";
640+
641+
var diagram = _parser.Parse(text);
642+
643+
Assert.Equal("My State", diagram.Nodes["s1"].Label.Text);
644+
}
645+
646+
[Fact]
647+
public void Parse_StateDiagram_FullExample_ProducesCorrectNodeAndEdgeCount()
648+
{
649+
const string text = """
650+
stateDiagram-v2
651+
[*] --> Idle
652+
Idle --> Running : start
653+
Running --> Idle : stop
654+
Running --> [*]
655+
""";
656+
657+
var diagram = _parser.Parse(text);
658+
659+
// __start__, Idle, Running, __end__
660+
Assert.Equal(4, diagram.Nodes.Count);
661+
Assert.Equal(4, diagram.Edges.Count);
662+
}
551663
}

0 commit comments

Comments
 (0)