Skip to content

Commit cb6a281

Browse files
authored
Merge pull request #19 from jongalloway/mermaid-parser-facade
Refactor Mermaid parser into facade and flowchart subparser
2 parents 602045b + 066dc47 commit cb6a281

6 files changed

Lines changed: 411 additions & 295 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
using DiagramForge.Models;
2+
3+
namespace DiagramForge.Parsers.Mermaid;
4+
5+
internal interface IMermaidDiagramParser
6+
{
7+
bool CanParse(MermaidDiagramKind kind);
8+
9+
Diagram Parse(MermaidDocument document);
10+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
namespace DiagramForge.Parsers.Mermaid;
2+
3+
internal enum MermaidDiagramKind
4+
{
5+
Unknown,
6+
Flowchart,
7+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
using DiagramForge.Abstractions;
2+
3+
namespace DiagramForge.Parsers.Mermaid;
4+
5+
internal sealed class MermaidDocument
6+
{
7+
private MermaidDocument(MermaidDiagramKind kind, string headerLine, string[] lines)
8+
{
9+
Kind = kind;
10+
HeaderLine = headerLine;
11+
Lines = lines;
12+
}
13+
14+
public MermaidDiagramKind Kind { get; }
15+
16+
public string HeaderLine { get; }
17+
18+
public string[] Lines { get; }
19+
20+
public static bool TryParse(string diagramText, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out MermaidDocument? document)
21+
{
22+
document = null;
23+
if (string.IsNullOrWhiteSpace(diagramText))
24+
return false;
25+
26+
var lines = diagramText
27+
.Split('\n')
28+
.Select(line => line.Trim())
29+
.Where(line => !string.IsNullOrEmpty(line) && !line.StartsWith("%%", StringComparison.Ordinal))
30+
.ToArray();
31+
32+
if (lines.Length == 0)
33+
return false;
34+
35+
var headerLine = lines[0];
36+
if (!TryDetectKind(headerLine, out var kind))
37+
return false;
38+
39+
document = new MermaidDocument(kind, headerLine, lines);
40+
return true;
41+
}
42+
43+
// Known Mermaid diagram-type keywords (lowercased) that are recognized but not yet
44+
// supported by any registered IMermaidDiagramParser. Detecting them as Unknown lets
45+
// MermaidParser emit a specific "unsupported type" error instead of a generic one.
46+
private static readonly HashSet<string> KnownUnsupportedMermaidKeywords = new(StringComparer.Ordinal)
47+
{
48+
"sequencediagram",
49+
"classdiagram",
50+
"statediagram",
51+
"statediagram-v2",
52+
"erdiagram",
53+
"journey",
54+
"gantt",
55+
"pie",
56+
"gitgraph",
57+
"mindmap",
58+
"timeline",
59+
"quadrantchart",
60+
"requirementdiagram",
61+
"block-beta",
62+
"packet-beta",
63+
"architecture-beta",
64+
"kanban",
65+
"sankey-beta",
66+
"xychart-beta",
67+
"zenuml",
68+
};
69+
70+
public static MermaidDocument Parse(string diagramText)
71+
{
72+
if (TryParse(diagramText, out var document))
73+
return document;
74+
75+
if (string.IsNullOrWhiteSpace(diagramText))
76+
throw new DiagramParseException("Diagram text cannot be null or whitespace.");
77+
78+
var firstContentLine = diagramText
79+
.Split('\n')
80+
.Select(line => line.Trim())
81+
.FirstOrDefault(line => !string.IsNullOrEmpty(line) && !line.StartsWith("%%", StringComparison.Ordinal));
82+
83+
if (firstContentLine is null)
84+
throw new DiagramParseException("Diagram text cannot be empty or contain only comments.");
85+
86+
throw new DiagramParseException($"Unsupported Mermaid diagram type '{firstContentLine}'.");
87+
}
88+
89+
private static bool TryDetectKind(string headerLine, out MermaidDiagramKind kind)
90+
{
91+
var normalizedHeader = headerLine.Trim().ToLowerInvariant();
92+
if (normalizedHeader.StartsWith("graph ", StringComparison.Ordinal)
93+
|| normalizedHeader.StartsWith("flowchart ", StringComparison.Ordinal)
94+
|| normalizedHeader.Equals("graph", StringComparison.Ordinal)
95+
|| normalizedHeader.Equals("flowchart", StringComparison.Ordinal))
96+
{
97+
kind = MermaidDiagramKind.Flowchart;
98+
return true;
99+
}
100+
101+
var spaceIndex = normalizedHeader.IndexOf(' ');
102+
var keyword = spaceIndex >= 0 ? normalizedHeader[..spaceIndex] : normalizedHeader;
103+
if (KnownUnsupportedMermaidKeywords.Contains(keyword))
104+
{
105+
kind = MermaidDiagramKind.Unknown;
106+
return true;
107+
}
108+
109+
kind = default;
110+
return false;
111+
}
112+
}
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
using DiagramForge.Abstractions;
2+
using DiagramForge.Models;
3+
4+
namespace DiagramForge.Parsers.Mermaid;
5+
6+
internal sealed class MermaidFlowchartParser : IMermaidDiagramParser
7+
{
8+
public bool CanParse(MermaidDiagramKind kind) => kind == MermaidDiagramKind.Flowchart;
9+
10+
public Diagram Parse(MermaidDocument document)
11+
{
12+
var builder = new DiagramSemanticModelBuilder()
13+
.WithSourceSyntax("mermaid")
14+
.WithDiagramType("flowchart");
15+
16+
var direction = ParseDirection(document.HeaderLine.ToLowerInvariant());
17+
builder.WithLayoutHints(new LayoutHints { Direction = direction });
18+
19+
var nodesSeen = new Dictionary<string, Node>(StringComparer.Ordinal);
20+
var groupStack = new Stack<(Group group, HashSet<string> members)>();
21+
int autoSubgraphId = 0;
22+
23+
Node GetOrCreateNode(string id, string label)
24+
{
25+
if (!nodesSeen.TryGetValue(id, out var node))
26+
{
27+
node = new Node(id, label);
28+
nodesSeen[id] = node;
29+
builder.AddNode(node);
30+
}
31+
32+
foreach (var frame in groupStack)
33+
frame.members.Add(id);
34+
35+
return node;
36+
}
37+
38+
void CloseGroup()
39+
{
40+
var (group, members) = groupStack.Pop();
41+
group.ChildNodeIds.AddRange(members.OrderBy(member => member, StringComparer.Ordinal));
42+
builder.AddGroup(group);
43+
}
44+
45+
for (int i = 1; i < document.Lines.Length; i++)
46+
{
47+
var line = document.Lines[i];
48+
49+
if (line.StartsWith("subgraph", StringComparison.OrdinalIgnoreCase)
50+
&& (line.Length == 8 || char.IsWhiteSpace(line[8])))
51+
{
52+
var (id, title) = ParseSubgraphHeader(line.Length > 8 ? line[8..] : string.Empty);
53+
id ??= $"__subgraph{autoSubgraphId++}";
54+
groupStack.Push((new Group(id, title), new HashSet<string>(StringComparer.Ordinal)));
55+
continue;
56+
}
57+
58+
if (line.Equals("end", StringComparison.OrdinalIgnoreCase))
59+
{
60+
if (groupStack.Count > 0)
61+
CloseGroup();
62+
continue;
63+
}
64+
65+
if (groupStack.Count > 0
66+
&& line.StartsWith("direction ", StringComparison.OrdinalIgnoreCase))
67+
continue;
68+
69+
ParseLine(line, builder, GetOrCreateNode);
70+
}
71+
72+
while (groupStack.Count > 0)
73+
CloseGroup();
74+
75+
return builder.Build();
76+
}
77+
78+
private static LayoutDirection ParseDirection(string headerLine)
79+
{
80+
if (headerLine.Contains(" lr", StringComparison.Ordinal)) return LayoutDirection.LeftToRight;
81+
if (headerLine.Contains(" rl", StringComparison.Ordinal)) return LayoutDirection.RightToLeft;
82+
if (headerLine.Contains(" bt", StringComparison.Ordinal)) return LayoutDirection.BottomToTop;
83+
return LayoutDirection.TopToBottom;
84+
}
85+
86+
private static void ParseLine(
87+
string line,
88+
IDiagramSemanticModelBuilder builder,
89+
Func<string, string, Node> getOrCreate)
90+
{
91+
string[] edgeOps = ["--->", "-->>", "-.->", "==>", "===", "-->", "-.-", "---"];
92+
93+
string? matchedOp = null;
94+
int opIndex = -1;
95+
96+
foreach (var op in edgeOps)
97+
{
98+
int idx = line.IndexOf(op, StringComparison.Ordinal);
99+
if (idx >= 0 && (opIndex < 0 || idx < opIndex || (idx == opIndex && op.Length > matchedOp!.Length)))
100+
{
101+
opIndex = idx;
102+
matchedOp = op;
103+
}
104+
}
105+
106+
if (matchedOp is not null)
107+
{
108+
var left = line[..opIndex].Trim();
109+
var right = line[(opIndex + matchedOp.Length)..].Trim();
110+
111+
string? edgeLabel = null;
112+
int pipeIdx = left.IndexOf('|');
113+
if (pipeIdx >= 0)
114+
{
115+
edgeLabel = left[(pipeIdx + 1)..].TrimEnd('|').Trim();
116+
left = left[..pipeIdx].Trim();
117+
}
118+
else if (right.StartsWith('|'))
119+
{
120+
int endPipe = right.IndexOf('|', 1);
121+
if (endPipe > 0)
122+
{
123+
edgeLabel = right[1..endPipe].Trim();
124+
right = right[(endPipe + 1)..].Trim();
125+
}
126+
}
127+
128+
var (srcId, srcLabel, srcShape) = ParseNodeDeclaration(left);
129+
var (tgtId, tgtLabel, tgtShape) = ParseNodeDeclaration(right);
130+
131+
var srcNode = getOrCreate(srcId, srcLabel);
132+
if (srcShape.HasValue) srcNode.Shape = srcShape.Value;
133+
134+
var tgtNode = getOrCreate(tgtId, tgtLabel);
135+
if (tgtShape.HasValue) tgtNode.Shape = tgtShape.Value;
136+
137+
var edge = new Edge(srcId, tgtId);
138+
if (edgeLabel is not null)
139+
edge.Label = new Label(edgeLabel);
140+
141+
edge.LineStyle = matchedOp.Contains('=') ? EdgeLineStyle.Thick
142+
: matchedOp.Contains('.') ? EdgeLineStyle.Dotted
143+
: EdgeLineStyle.Solid;
144+
edge.ArrowHead = matchedOp.Contains('>') ? ArrowHeadStyle.Arrow : ArrowHeadStyle.None;
145+
146+
builder.AddEdge(edge);
147+
return;
148+
}
149+
150+
var (id, label, shape) = ParseNodeDeclaration(line);
151+
if (!string.IsNullOrEmpty(id))
152+
{
153+
var node = getOrCreate(id, label);
154+
if (shape.HasValue) node.Shape = shape.Value;
155+
}
156+
}
157+
158+
private static (string id, string label, Shape? shape) ParseNodeDeclaration(string token)
159+
{
160+
token = token.Trim();
161+
if (string.IsNullOrEmpty(token))
162+
return (string.Empty, string.Empty, null);
163+
164+
int bracketStart = -1;
165+
for (int i = 0; i < token.Length; i++)
166+
{
167+
char c = token[i];
168+
if (c == '[' || c == '(' || c == '{' || c == '>')
169+
{
170+
bracketStart = i;
171+
break;
172+
}
173+
}
174+
175+
if (bracketStart < 0)
176+
return (token, token, null);
177+
178+
var id = token[..bracketStart].Trim();
179+
var rest = token[bracketStart..].Trim();
180+
181+
Shape? shape = rest.StartsWith("((", StringComparison.Ordinal) ? Shape.Circle
182+
: rest[0] == '[' ? Shape.Rectangle
183+
: rest[0] == '(' ? Shape.RoundedRectangle
184+
: rest[0] == '{' ? Shape.Diamond
185+
: (Shape?)null;
186+
187+
var label = rest
188+
.TrimStart('[', '(', '{', '>')
189+
.TrimEnd(']', ')', '}', '<')
190+
.Trim('"');
191+
192+
return (id, string.IsNullOrEmpty(label) ? id : label, shape);
193+
}
194+
195+
private static (string? id, string title) ParseSubgraphHeader(string remainder)
196+
{
197+
remainder = remainder.Trim();
198+
if (remainder.Length == 0)
199+
return (null, string.Empty);
200+
201+
int sqStart = remainder.IndexOf('[');
202+
if (sqStart >= 0)
203+
{
204+
int sqEnd = remainder.LastIndexOf(']');
205+
if (sqEnd > sqStart)
206+
{
207+
string idPart = remainder[..sqStart].Trim();
208+
string title = remainder[(sqStart + 1)..sqEnd].Trim().Trim('"');
209+
return (idPart.Length > 0 ? idPart : null, title);
210+
}
211+
}
212+
213+
if (remainder.Length >= 2 && remainder[0] == '"' && remainder[^1] == '"')
214+
return (null, remainder[1..^1]);
215+
216+
if (!remainder.Any(char.IsWhiteSpace))
217+
return (remainder, remainder);
218+
219+
return (null, remainder);
220+
}
221+
}

0 commit comments

Comments
 (0)