Skip to content

Commit cb1ef6c

Browse files
Copilotjongalloway
andcommitted
Implement Issue #12: Mermaid mindmap support
Co-authored-by: jongalloway <68539+jongalloway@users.noreply.github.com>
1 parent 0c74a75 commit cb1ef6c

8 files changed

Lines changed: 249 additions & 5 deletions

File tree

src/DiagramForge/Parsers/Mermaid/MermaidDiagramKind.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ internal enum MermaidDiagramKind
44
{
55
Unknown,
66
Flowchart,
7+
Mindmap,
78
}

src/DiagramForge/Parsers/Mermaid/MermaidDocument.cs

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,21 @@ namespace DiagramForge.Parsers.Mermaid;
44

55
internal sealed class MermaidDocument
66
{
7-
private MermaidDocument(MermaidDiagramKind kind, string headerLine, string[] lines)
7+
private MermaidDocument(MermaidDiagramKind kind, string headerLine, string[] lines, string[] rawLines)
88
{
99
Kind = kind;
1010
HeaderLine = headerLine;
1111
Lines = lines;
12+
RawLines = rawLines;
1213
}
1314

1415
public MermaidDiagramKind Kind { get; }
1516

1617
public string HeaderLine { get; }
1718

19+
/// <summary>Content lines with leading whitespace preserved (comments and blank lines filtered).</summary>
20+
public string[] RawLines { get; }
21+
1822
public string[] Lines { get; }
1923

2024
public static bool TryParse(string diagramText, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out MermaidDocument? document)
@@ -29,14 +33,20 @@ public static bool TryParse(string diagramText, [System.Diagnostics.CodeAnalysis
2933
.Where(line => !string.IsNullOrEmpty(line) && !line.StartsWith("%%", StringComparison.Ordinal))
3034
.ToArray();
3135

36+
var rawLines = diagramText
37+
.Split('\n')
38+
.Select(line => line.TrimEnd())
39+
.Where(line => !string.IsNullOrWhiteSpace(line) && !line.TrimStart().StartsWith("%%", StringComparison.Ordinal))
40+
.ToArray();
41+
3242
if (lines.Length == 0)
3343
return false;
3444

3545
var headerLine = lines[0];
3646
if (!TryDetectKind(headerLine, out var kind))
3747
return false;
3848

39-
document = new MermaidDocument(kind, headerLine, lines);
49+
document = new MermaidDocument(kind, headerLine, lines, rawLines);
4050
return true;
4151
}
4252

@@ -54,7 +64,6 @@ public static bool TryParse(string diagramText, [System.Diagnostics.CodeAnalysis
5464
"gantt",
5565
"pie",
5666
"gitgraph",
57-
"mindmap",
5867
"timeline",
5968
"quadrantchart",
6069
"requirementdiagram",
@@ -98,6 +107,12 @@ private static bool TryDetectKind(string headerLine, out MermaidDiagramKind kind
98107
return true;
99108
}
100109

110+
if (normalizedHeader.Equals("mindmap", StringComparison.Ordinal))
111+
{
112+
kind = MermaidDiagramKind.Mindmap;
113+
return true;
114+
}
115+
101116
var spaceIndex = normalizedHeader.IndexOf(' ');
102117
var keyword = spaceIndex >= 0 ? normalizedHeader[..spaceIndex] : normalizedHeader;
103118
if (KnownUnsupportedMermaidKeywords.Contains(keyword))

src/DiagramForge/Parsers/Mermaid/MermaidFlowchartParser.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ private static void ParseLine(
155155
}
156156
}
157157

158-
private static (string id, string label, Shape? shape) ParseNodeDeclaration(string token)
158+
internal static (string id, string label, Shape? shape) ParseNodeDeclaration(string token)
159159
{
160160
token = token.Trim();
161161
if (string.IsNullOrEmpty(token))
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
using DiagramForge.Abstractions;
2+
using DiagramForge.Models;
3+
4+
namespace DiagramForge.Parsers.Mermaid;
5+
6+
internal sealed class MermaidMindmapParser : IMermaidDiagramParser
7+
{
8+
public bool CanParse(MermaidDiagramKind kind) => kind == MermaidDiagramKind.Mindmap;
9+
10+
public Diagram Parse(MermaidDocument document)
11+
{
12+
var builder = new DiagramSemanticModelBuilder()
13+
.WithSourceSyntax("mermaid")
14+
.WithDiagramType("mindmap");
15+
16+
builder.WithLayoutHints(new LayoutHints { Direction = LayoutDirection.TopToBottom });
17+
18+
var stack = new Stack<(int indent, string nodeId)>();
19+
int nodeCounter = 0;
20+
21+
// Skip index 0 (the "mindmap" header); use RawLines to preserve indentation.
22+
for (int i = 1; i < document.RawLines.Length; i++)
23+
{
24+
var raw = document.RawLines[i];
25+
if (string.IsNullOrWhiteSpace(raw))
26+
continue;
27+
28+
int indent = raw.Length - raw.TrimStart().Length;
29+
var text = raw.Trim();
30+
if (string.IsNullOrEmpty(text))
31+
continue;
32+
33+
// ParseNodeDeclaration returns a source-text ID (e.g. "root" from "root((Product))"),
34+
// but mindmap nodes are identified by generated IDs — the parsed ID is intentionally unused.
35+
var (_, label, shape) = MermaidFlowchartParser.ParseNodeDeclaration(text);
36+
37+
var nodeId = $"node_{nodeCounter++}";
38+
var node = new Node(nodeId, label);
39+
if (shape.HasValue)
40+
node.Shape = shape.Value;
41+
42+
builder.AddNode(node);
43+
44+
// Pop stack until we find a parent with strictly smaller indentation.
45+
while (stack.Count > 0 && stack.Peek().indent >= indent)
46+
stack.Pop();
47+
48+
if (stack.Count > 0)
49+
builder.AddEdge(new Edge(stack.Peek().nodeId, nodeId));
50+
51+
stack.Push((indent, nodeId));
52+
}
53+
54+
return builder.Build();
55+
}
56+
}

src/DiagramForge/Parsers/Mermaid/MermaidParser.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,18 @@ namespace DiagramForge.Parsers.Mermaid;
1010
/// Supported Mermaid diagram types (v1 subset):
1111
/// <list type="bullet">
1212
/// <item>Flowchart (LR, RL, TB, BT, TD)</item>
13+
/// <item>Mindmap</item>
1314
/// </list>
1415
/// </remarks>
1516
public sealed class MermaidParser : IDiagramParser
1617
{
1718
private readonly IReadOnlyList<IMermaidDiagramParser> _diagramParsers =
1819
[
1920
new MermaidFlowchartParser(),
21+
new MermaidMindmapParser(),
2022
];
2123

22-
private static readonly string[] SupportedDiagramTypes = ["flowchart"];
24+
private static readonly string[] SupportedDiagramTypes = ["flowchart", "mindmap"];
2325

2426
public string SyntaxId => "mermaid";
2527

Lines changed: 37 additions & 0 deletions
Loading
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
mindmap
2+
root((Product))
3+
Engineering
4+
Backend
5+
Frontend
6+
Design
7+
Product

tests/DiagramForge.Tests/Parsers/MermaidParserTests.cs

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,4 +422,130 @@ subgraph inner
422422
Assert.Contains("A", outer.ChildNodeIds);
423423
Assert.Contains("B", outer.ChildNodeIds);
424424
}
425+
426+
// ── Mindmap: CanParse ─────────────────────────────────────────────────────
427+
428+
[Fact]
429+
public void CanParse_ReturnsTrue_ForMindmap()
430+
{
431+
const string text = """
432+
mindmap
433+
root((Product))
434+
Engineering
435+
""";
436+
437+
Assert.True(_parser.CanParse(text));
438+
}
439+
440+
// ── Mindmap: Parse ────────────────────────────────────────────────────────
441+
442+
[Fact]
443+
public void Parse_Mindmap_DiagramTypeIsMindmap()
444+
{
445+
const string text = """
446+
mindmap
447+
root((Product))
448+
Engineering
449+
""";
450+
451+
var diagram = _parser.Parse(text);
452+
453+
Assert.Equal("mindmap", diagram.DiagramType);
454+
Assert.Equal("mermaid", diagram.SourceSyntax);
455+
}
456+
457+
[Fact]
458+
public void Parse_Mindmap_RootHasNoParent()
459+
{
460+
const string text = """
461+
mindmap
462+
root((Product))
463+
Engineering
464+
""";
465+
466+
var diagram = _parser.Parse(text);
467+
468+
// The root node (node_0) must have no incoming edges.
469+
var rootNode = diagram.Nodes.Values.First();
470+
Assert.DoesNotContain(diagram.Edges, e => e.TargetId == rootNode.Id);
471+
}
472+
473+
[Fact]
474+
public void Parse_Mindmap_ChildrenLinkedToParent()
475+
{
476+
const string text = """
477+
mindmap
478+
root((Product))
479+
Engineering
480+
Design
481+
""";
482+
483+
var diagram = _parser.Parse(text);
484+
485+
// root → Engineering, root → Design
486+
Assert.Equal(2, diagram.Edges.Count);
487+
var rootId = diagram.Nodes.Values.First(n => n.Label.Text == "Product").Id;
488+
Assert.All(diagram.Edges, e => Assert.Equal(rootId, e.SourceId));
489+
}
490+
491+
[Fact]
492+
public void Parse_Mindmap_GrandchildLinkedToChild()
493+
{
494+
const string text = """
495+
mindmap
496+
root((Product))
497+
Engineering
498+
Backend
499+
Frontend
500+
""";
501+
502+
var diagram = _parser.Parse(text);
503+
504+
var engineeringId = diagram.Nodes.Values.First(n => n.Label.Text == "Engineering").Id;
505+
var backendId = diagram.Nodes.Values.First(n => n.Label.Text == "Backend").Id;
506+
var frontendId = diagram.Nodes.Values.First(n => n.Label.Text == "Frontend").Id;
507+
508+
Assert.Contains(diagram.Edges, e => e.SourceId == engineeringId && e.TargetId == backendId);
509+
Assert.Contains(diagram.Edges, e => e.SourceId == engineeringId && e.TargetId == frontendId);
510+
}
511+
512+
[Fact]
513+
public void Parse_Mindmap_NodeShapes_AreRecognized()
514+
{
515+
const string text = """
516+
mindmap
517+
root((Circle))
518+
square[Square]
519+
rounded(Rounded)
520+
""";
521+
522+
var diagram = _parser.Parse(text);
523+
524+
var circle = diagram.Nodes.Values.First(n => n.Label.Text == "Circle");
525+
var square = diagram.Nodes.Values.First(n => n.Label.Text == "Square");
526+
var rounded = diagram.Nodes.Values.First(n => n.Label.Text == "Rounded");
527+
528+
Assert.Equal(Shape.Circle, circle.Shape);
529+
Assert.Equal(Shape.Rectangle, square.Shape);
530+
Assert.Equal(Shape.RoundedRectangle, rounded.Shape);
531+
}
532+
533+
[Fact]
534+
public void Parse_Mindmap_NodeCount_MatchesTreeSize()
535+
{
536+
const string text = """
537+
mindmap
538+
root((Product))
539+
Engineering
540+
Backend
541+
Frontend
542+
Design
543+
Product
544+
""";
545+
546+
var diagram = _parser.Parse(text);
547+
548+
Assert.Equal(6, diagram.Nodes.Count);
549+
Assert.Equal(5, diagram.Edges.Count);
550+
}
425551
}

0 commit comments

Comments
 (0)